From 240e1f3b1052d63834a868c19431841473b59b39 Mon Sep 17 00:00:00 2001 From: Linus Beckhaus Date: Thu, 2 Apr 2026 11:52:26 +0200 Subject: [PATCH] refactor(skills): unify installation in setup, remove from analyze Consolidated skill installation logic into setup command and removed it from analyze. Added stale project-local skill detection and cleanup: - Remove dead installSkills function and unused imports from ai-context - Add checkStaleProjectSkills() warning in analyze after summary - Add findRepoRoot() and cleanupProjectLocalSkills() in setup - Update tests to reflect new skill installation behavior - Add analyze-skills-notice.test.ts for stale skills warnings - Fix marketplace config (name, homepage, version bump) - Update README with plugin installation note This centralizes skill lifecycle management in setup and ensures users are warned about stale project-local skills during analysis. --- .claude-plugin/marketplace.json | 6 +- README.md | 6 +- gitnexus/src/cli/ai-context.ts | 93 ------------------- gitnexus/src/cli/analyze.ts | 29 +++++- gitnexus/src/cli/setup.ts | 53 +++++++++++ gitnexus/test/unit/ai-context.test.ts | 57 +++++++++--- .../test/unit/analyze-skills-notice.test.ts | 83 +++++++++++++++++ 7 files changed, 216 insertions(+), 111 deletions(-) create mode 100644 gitnexus/test/unit/analyze-skills-notice.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 719473def..be9d3af45 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,17 +1,17 @@ { - "name": "gitnexus-marketplace", + "name": "gitnexus", "owner": { "name": "GitNexus", "email": "nico@gitnexus.dev" }, "metadata": { "description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search", - "homepage": "https://github.com/nicosxt/gitnexus" + "homepage": "https://github.com/abhigyanpatwari/GitNexus" }, "plugins": [ { "name": "gitnexus", - "version": "1.3.3", + "version": "1.3.6", "source": "./gitnexus-claude-plugin", "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase." } diff --git a/README.md b/README.md index ae00065df..2b195a32a 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,13 @@ npx gitnexus analyze That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command. -To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below. +To configure MCP, install agent skills, and register hooks for your editor, run `npx gitnexus setup` once — or set it up manually below. + +> **Claude Code plugin users:** If you installed GitNexus as a [Claude Code plugin](https://docs.anthropic.com/en/docs/claude-code/plugins), you can skip `gitnexus setup` entirely — the plugin bundles MCP, skills, and hooks automatically. Just run `gitnexus analyze` to index your repos. ### MCP Setup -`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. +`gitnexus setup` auto-detects your editors and writes the correct global MCP config, installs agent skills, and registers hooks. You only need to run it once. ### Editor Support diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 81aaa496d..9291520de 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -8,13 +8,8 @@ import fs from 'fs/promises'; import path from 'path'; -import { fileURLToPath } from 'url'; import { type GeneratedSkillInfo } from './skill-gen.js'; -// ESM equivalent of __dirname -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - interface RepoStats { files?: number; nodes?: number; @@ -212,88 +207,6 @@ async function upsertGitNexusSection( return 'appended'; } -/** - * Install GitNexus skills to .claude/skills/gitnexus/ - * Works natively with Claude Code, Cursor, and GitHub Copilot - */ -async function installSkills(repoPath: string): Promise { - const skillsDir = path.join(repoPath, '.claude', 'skills', 'gitnexus'); - const installedSkills: string[] = []; - - // Skill definitions bundled with the package - const skills = [ - { - name: 'gitnexus-exploring', - description: - 'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"', - }, - { - name: 'gitnexus-debugging', - description: - 'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"', - }, - { - name: 'gitnexus-impact-analysis', - description: - 'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"', - }, - { - name: 'gitnexus-refactoring', - description: - 'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"', - }, - { - name: 'gitnexus-guide', - description: - 'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"', - }, - { - name: 'gitnexus-cli', - description: - 'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"', - }, - ]; - - for (const skill of skills) { - const skillDir = path.join(skillsDir, skill.name); - const skillPath = path.join(skillDir, 'SKILL.md'); - - try { - // Create skill directory - await fs.mkdir(skillDir, { recursive: true }); - - // Try to read from package skills directory - const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`); - let skillContent: string; - - try { - skillContent = await fs.readFile(packageSkillPath, 'utf-8'); - } catch { - // Fallback: generate minimal skill content - skillContent = `--- -name: ${skill.name} -description: ${skill.description} ---- - -# ${skill.name.charAt(0).toUpperCase() + skill.name.slice(1)} - -${skill.description} - -Use GitNexus tools to accomplish this task. -`; - } - - await fs.writeFile(skillPath, skillContent, 'utf-8'); - installedSkills.push(skill.name); - } catch (err) { - // Skip on error, don't fail the whole process - console.warn(`Warning: Could not install skill ${skill.name}:`, err); - } - } - - return installedSkills; -} - /** * Generate AI context files after indexing */ @@ -323,11 +236,5 @@ export async function generateAIContextFiles( createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)'); } - // Install skills to .claude/skills/gitnexus/ - const installedSkills = await installSkills(repoPath); - if (installedSkills.length > 0) { - createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`); - } - return { files: createdFiles }; } diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index c77903de0..eebe5a558 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -40,6 +40,26 @@ function ensureHeap(): boolean { return true; } +/** + * Check for stale project-local skills left by a previous `analyze` run. + * Prints a deprecation notice but does NOT delete — cleanup is handled by `setup`. + */ +export async function checkStaleProjectSkills(repoPath: string): Promise { + const skillsDir = path.join(repoPath, '.claude', 'skills', 'gitnexus'); + try { + const stat = await fs.stat(skillsDir); + if (stat.isDirectory()) { + console.log( + ` Note: Skills are no longer installed by analyze. Run 'gitnexus setup' to manage skills globally.`, + ); + return true; + } + } catch { + // Directory doesn't exist — nothing to warn about + } + return false; +} + export interface AnalyzeOptions { force?: boolean; embeddings?: boolean; @@ -193,6 +213,8 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption console.warn = origWarn; console.error = origError; bar.stop(); + // Keep migration notice visible even on no-op analyze runs + await checkStaleProjectSkills(repoPath); console.log(' Already up to date\n'); // Safe to return without process.exit(0) — the early-return path in // runFullAnalysis never opens LadybugDB, so no native handles prevent exit. @@ -268,10 +290,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); console.log(` ${repoPath}`); + // Warn if stale project-local skills exist from a previous analyze run + await checkStaleProjectSkills(repoPath); + try { await fs.access(getGlobalRegistryPath()); } catch { - console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.'); + console.log( + '\n Tip: Run `gitnexus setup` to configure MCP and install agent skills for your editor.', + ); } console.log(''); diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 4e89549ab..5ed3a9a8e 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -438,6 +438,56 @@ async function installCodexSkills(result: SetupResult): Promise { } } +// ─── Project-local skill cleanup ─────────────────────────────────── + +/** + * Find the nearest git repository root by walking upward and checking for + * a .git marker (directory for standard repos, file for worktrees/submodules). + */ +async function findRepoRoot(startPath: string): Promise { + let current = path.resolve(startPath); + const root = path.parse(current).root; + + while (true) { + const gitMarker = path.join(current, '.git'); + try { + const stat = await fs.stat(gitMarker); + if (stat.isDirectory() || stat.isFile()) { + return current; + } + } catch { + // Keep walking up + } + + if (current === root) return null; + current = path.dirname(current); + } +} + +/** + * Remove stale project-local skills left by previous `analyze` runs. + * Cleans up at repo root and supports both .git directories and files. + */ +async function cleanupProjectLocalSkills(result: SetupResult): Promise { + const repoRoot = await findRepoRoot(process.cwd()); + if (!repoRoot) return; // Not inside a git repo + + const localSkillsDir = path.join(repoRoot, '.claude', 'skills', 'gitnexus'); + try { + const stat = await fs.stat(localSkillsDir); + if (!stat.isDirectory()) return; + } catch { + return; // No project-local skills + } + + try { + await fs.rm(localSkillsDir, { recursive: true, force: true }); + result.configured.push('Removed project-local skills (now installed globally)'); + } catch (err: any) { + result.errors.push(`Project-local skill cleanup: ${err.message}`); + } +} + // ─── Main command ────────────────────────────────────────────────── export const setupCommand = async () => { @@ -469,6 +519,9 @@ export const setupCommand = async () => { await installOpenCodeSkills(result); await installCodexSkills(result); + // Clean up stale project-local skills left by previous `analyze` runs + await cleanupProjectLocalSkills(result); + // Print results if (result.configured.length > 0) { console.log(' Configured:'); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 0a9f52a68..48ca58832 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; @@ -8,13 +8,13 @@ describe('generateAIContextFiles', () => { let tmpDir: string; let storagePath: string; - beforeAll(async () => { + beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-test-')); storagePath = path.join(tmpDir, '.gitnexus'); await fs.mkdir(storagePath, { recursive: true }); }); - afterAll(async () => { + afterEach(async () => { try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { @@ -66,18 +66,51 @@ describe('generateAIContextFiles', () => { expect(starts).toBe(1); }); - it('installs skills files', async () => { + it('does NOT install skills after refactor', async () => { + const stats = { nodes: 10 }; + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + + const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus'); + await expect(fs.stat(skillsDir)).rejects.toThrow(); + }); + + it('return value does not mention skills after refactor', async () => { const stats = { nodes: 10 }; const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + const skillFiles = result.files.filter((f) => f.includes('skills')); + expect(skillFiles).toHaveLength(0); + }); - // Should have installed skill files - const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus'); - try { - const entries = await fs.readdir(skillsDir, { recursive: true }); - expect(entries.length).toBeGreaterThan(0); - } catch { - // Skills dir may not be created if skills source doesn't exist in test context - } + it('preserves existing CLAUDE.md content', async () => { + const claudePath = path.join(tmpDir, 'CLAUDE.md'); + await fs.writeFile(claudePath, '# My Custom Instructions\n\nDo not remove this.\n', 'utf-8'); + + const stats = { nodes: 10 }; + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + + const content = await fs.readFile(claudePath, 'utf-8'); + expect(content).toContain('My Custom Instructions'); + expect(content).toContain('Do not remove this.'); + expect(content).toContain('gitnexus:start'); + }); + + it('existing CLAUDE.md with gitnexus section but no skills dir works', async () => { + const claudePath = path.join(tmpDir, 'CLAUDE.md'); + await fs.writeFile( + claudePath, + '\nold content\n\n', + 'utf-8', + ); + + const stats = { nodes: 99 }; + await generateAIContextFiles(tmpDir, storagePath, 'UpdatedProject', stats); + + const content = await fs.readFile(claudePath, 'utf-8'); + expect(content).not.toContain('old content'); + expect(content).toContain('99 symbols'); + const starts = (content.match(/gitnexus:start/g) || []).length; + expect(starts).toBe(1); + await expect(fs.stat(path.join(tmpDir, '.claude', 'skills', 'gitnexus'))).rejects.toThrow(); }); it('preserves manual AGENTS.md and CLAUDE.md edits when skipAgentsMd is enabled', async () => { diff --git a/gitnexus/test/unit/analyze-skills-notice.test.ts b/gitnexus/test/unit/analyze-skills-notice.test.ts new file mode 100644 index 000000000..a92e40b0f --- /dev/null +++ b/gitnexus/test/unit/analyze-skills-notice.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +/** + * Contract tests for analyze stale-skills notice. + * These verify that analyze exports and uses checkStaleProjectSkills(). + */ + +async function getCheckStaleProjectSkills(): Promise<(repoPath: string) => Promise> { + const analyzeModule = await import('../../src/cli/analyze.js'); + const candidate = (analyzeModule as any).checkStaleProjectSkills; + expect( + typeof candidate, + 'analyze.ts must export checkStaleProjectSkills(repoPath) for unit testing', + ).toBe('function'); + return candidate as (repoPath: string) => Promise; +} + +describe('analyze — stale project-local skills notice', () => { + let tmpDir: string; + let consoleOutput: string[]; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-analyze-notice-test-')); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: any[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + }); + + it('prints deprecation notice when .claude/skills/gitnexus/ exists', async () => { + const skillSubDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus', 'gitnexus-exploring'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile(path.join(skillSubDir, 'SKILL.md'), 'stale'); + + const checkStaleProjectSkills = await getCheckStaleProjectSkills(); + const detected = await checkStaleProjectSkills(tmpDir); + + expect(detected).toBe(true); + const notice = consoleOutput.find((line) => line.includes('no longer installed by analyze')); + expect(notice).toBeDefined(); + }); + + it('prints no notice when .claude/skills/gitnexus/ does not exist', async () => { + const checkStaleProjectSkills = await getCheckStaleProjectSkills(); + const detected = await checkStaleProjectSkills(tmpDir); + + expect(detected).toBe(false); + const notice = consoleOutput.find((line) => line.includes('no longer installed by analyze')); + expect(notice).toBeUndefined(); + }); + + it('does NOT delete the directory — only warns', async () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus'); + await fs.mkdir(skillsDir, { recursive: true }); + + const checkStaleProjectSkills = await getCheckStaleProjectSkills(); + await checkStaleProjectSkills(tmpDir); + + // Directory must still exist + const stat = await fs.stat(skillsDir); + expect(stat.isDirectory()).toBe(true); + }); + + it('handles .claude dir existing without skills/gitnexus/', async () => { + await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); + + const checkStaleProjectSkills = await getCheckStaleProjectSkills(); + const detected = await checkStaleProjectSkills(tmpDir); + expect(detected).toBe(false); + }); +});