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.
This commit is contained in:
Linus Beckhaus 2026-04-02 11:52:26 +02:00
parent ba5de0bde4
commit 240e1f3b10
7 changed files with 216 additions and 111 deletions

View file

@ -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."
}

View file

@ -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

View file

@ -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<string[]> {
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 };
}

View file

@ -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<boolean> {
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('');

View file

@ -438,6 +438,56 @@ async function installCodexSkills(result: SetupResult): Promise<void> {
}
}
// ─── 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<string | null> {
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<void> {
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:');

View file

@ -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,
'<!-- gitnexus:start -->\nold content\n<!-- gitnexus:end -->\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 () => {

View file

@ -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<boolean>> {
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<boolean>;
}
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);
});
});