mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
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:
parent
ba5de0bde4
commit
240e1f3b10
7 changed files with 216 additions and 111 deletions
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
83
gitnexus/test/unit/analyze-skills-notice.test.ts
Normal file
83
gitnexus/test/unit/analyze-skills-notice.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue