diff --git a/gitnexus/README.md b/gitnexus/README.md index 6826534c1..12bd96d20 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -240,7 +240,7 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS gitnexus analyze --embeddings # Enable embedding generation (slower, better search) gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force) gitnexus analyze --skills # Generate repo-specific skill files from detected communities -gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits +gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected) gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index ca02f2b08..c7b3a934f 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -11,6 +11,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { type GeneratedSkillInfo } from './generated-skill.js'; import { STANDARD_SKILL_CATALOG } from './standard-skills.js'; +import { isEnoent } from './editor-targets.js'; import { logger } from '../core/logger.js'; // ESM equivalent of __dirname @@ -440,17 +441,84 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } +} + +function skillBytesDiverge(existing: string | null, bundled: string): boolean { + return existing !== null && existing !== bundled; +} + +/** Write bundled skill bytes unless an existing file already differs. */ +async function writeSkillUnlessDivergent(filePath: string, content: string): Promise { + const existing = await readUtf8IfPresent(filePath); + if (skillBytesDiverge(existing, content)) { + logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`); + return true; + } + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf-8'); + return false; +} + +async function inspectLegacySkillDir( + legacyDir: string, +): Promise<{ nestedExisting: string | null; hasSiblings: boolean } | null> { + let entries: string[]; + try { + entries = await fs.readdir(legacyDir); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + const nestedExisting = entries.includes('SKILL.md') + ? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8') + : null; + return { + nestedExisting, + hasSiblings: entries.some((entry) => entry !== 'SKILL.md'), + }; +} + +function formatSkillInstallLine( + prefix: string, + total: number, + preserved: number, + allWrittenSuffix: string, + partialSuffix: string, +): string { + if (preserved > 0) { + return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`; + } + return `${prefix} (${total} ${allWrittenSuffix})`; +} + /** * Install GitNexus skills as direct children of .claude/skills/ * Works natively with Claude Code, Cursor, and GitHub Copilot. * Mirrored to .agents/skills/ when .agents/ exists. */ -async function installSkills( - repoPath: string, -): Promise<{ skills: string[]; agentsMirror: boolean }> { +async function installSkills(repoPath: string): Promise<{ + skills: string[]; + agentsMirror: boolean; + claudePreserved: number; + agentsPreserved: number; + legacyPreserved: number; +}> { const skillsDir = path.join(repoPath, '.claude', 'skills'); const legacySkillsDir = path.join(skillsDir, 'gitnexus'); const installedSkills: string[] = []; + let claudePreserved = 0; + let agentsPreserved = 0; + let legacyPreserved = 0; const agentsMirror = await shouldMirrorSkillsToAgents(repoPath); for (const skill of STANDARD_SKILL_CATALOG.filter( @@ -460,9 +528,6 @@ async function installSkills( 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; @@ -484,14 +549,13 @@ Use GitNexus tools to accomplish this task. `; } - await fs.writeFile(skillPath, skillContent, 'utf-8'); + if (await writeSkillUnlessDivergent(skillPath, skillContent)) claudePreserved += 1; // Mirror to .agents/skills/ for agents that read repo-local skills if (agentsMirror) { try { - const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name); - await fs.mkdir(agentsSkillDir, { recursive: true }); - await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8'); + const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md'); + if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent)) agentsPreserved += 1; } catch (err) { logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`); } @@ -503,7 +567,20 @@ Use GitNexus tools to accomplish this task. // deep. Remove only the child owned by this installer; unknown siblings // under the legacy grouping directory may be user-authored and survive. try { - await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true }); + const legacyDir = path.join(legacySkillsDir, skill.name); + const nestedSkill = path.join(legacyDir, 'SKILL.md'); + const leftover = await inspectLegacySkillDir(legacyDir); + if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) { + logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`); + legacyPreserved += 1; + } else if (leftover?.hasSiblings) { + logger.warn( + `Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`, + ); + legacyPreserved += 1; + } else if (leftover !== null) { + await fs.rm(legacyDir, { recursive: true, force: true }); + } } catch (err) { logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`); } @@ -513,7 +590,13 @@ Use GitNexus tools to accomplish this task. } } - return { skills: installedSkills, agentsMirror }; + return { + skills: installedSkills, + agentsMirror, + claudePreserved, + agentsPreserved, + legacyPreserved, + }; } /** @@ -592,12 +675,37 @@ export async function generateAIContextFiles( // Install standard skills directly under .claude/skills/ (unless --skip-skills) if (!options?.skipSkills) { - const { skills: installedSkills, agentsMirror } = await installSkills(repoPath); + const { + skills: installedSkills, + agentsMirror, + claudePreserved, + agentsPreserved, + legacyPreserved, + } = await installSkills(repoPath); if (installedSkills.length > 0) { - createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`); + createdFiles.push( + formatSkillInstallLine( + '.claude/skills/gitnexus-*/', + installedSkills.length, + claudePreserved, + 'skills', + 'preserved', + ), + ); if (agentsMirror) { createdFiles.push( - `.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`, + formatSkillInstallLine( + '.agents/skills/gitnexus-*/', + installedSkills.length, + agentsPreserved, + 'skills mirrored for .agents', + 'preserved for .agents', + ), + ); + } + if (legacyPreserved > 0) { + createdFiles.push( + `.claude/skills/gitnexus// (legacy directories preserved: ${legacyPreserved})`, ); } } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index f5654d1bf..aba8e78f0 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -201,7 +201,7 @@ export const en = { 'help.option.analyze.skills': 'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).', 'help.option.analyze.skipAgentsMd': - 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md', + 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.', 'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md', 'help.option.analyze.selfCommit': 'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 4aff202b6..5f3b52040 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -188,7 +188,8 @@ export const zhCN = { '重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。', 'help.option.analyze.skills': '根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。', - 'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块', + 'help.option.analyze.skipAgentsMd': + '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill;如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。', 'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数', 'help.option.analyze.selfCommit': '在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 216cc87dd..e53312d62 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -76,7 +76,10 @@ program 'Generate repo-specific skill files from detected communities ' + '(no-op when --index-only is also set).', ) - .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') + .option( + '--skip-agents-md', + 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.', + ) .option( '--pdg', 'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' + diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 003e548a9..6dd0f9b85 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise { const skillDir = path.join(targetDir, skillName); try { - if (source.isDirectory) { - const dirSource = path.join(skillsRoot, skillName); - await copyDirRecursive(dirSource, skillDir); - } else { - const flatSource = path.join(skillsRoot, `${skillName}.md`); - const content = await fs.readFile(flatSource, 'utf-8'); + const sourceSkillPath = source.isDirectory + ? path.join(skillsRoot, skillName, 'SKILL.md') + : path.join(skillsRoot, `${skillName}.md`); + const destinationSkillPath = path.join(skillDir, 'SKILL.md'); + const [sourceSkillContent, destinationSkillContent] = await Promise.all([ + fs.readFile(sourceSkillPath, 'utf-8'), + fs.readFile(destinationSkillPath, 'utf-8').catch((err) => { + if (!isEnoent(err)) throw err; + return null; + }), + ]); + + const preserved = + destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent; + if (preserved && !source.isDirectory) { + console.log( + `[gitnexus] preserved customized skill ${destinationSkillPath}; ` + + 'delete the file and rerun setup to refresh it.', + ); + } else if (source.isDirectory) { + await copyDirRecursive(path.join(skillsRoot, skillName), skillDir); + } else if (!preserved) { await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); + await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8'); } // A directory superseded by a shipped rename is warned about, never @@ -1113,7 +1129,7 @@ async function installSkillsTo(targetDir: string): Promise { ); } } - installed.push(skillName); + if (!preserved) installed.push(skillName); } catch { // Source skill not found — skip } @@ -1133,9 +1149,23 @@ async function copyDirRecursive(src: string, dest: string): Promise { const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await copyDirRecursive(srcPath, destPath); - } else { - await fs.copyFile(srcPath, destPath); + continue; } + const [srcBuf, destBuf] = await Promise.all([ + fs.readFile(srcPath), + fs.readFile(destPath).catch((err) => { + if (!isEnoent(err)) throw err; + return null; + }), + ]); + if (destBuf !== null && !destBuf.equals(srcBuf)) { + console.log( + `[gitnexus] preserved customized skill ${destPath}; ` + + 'delete the file and rerun setup to refresh it.', + ); + continue; + } + await fs.writeFile(destPath, srcBuf); } } diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 8e1a08e7c..df610f8e7 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -8,6 +8,7 @@ import { refreshBaseRefLine, markdownSafeBranch, } from '../../src/cli/ai-context.js'; +import { _captureLogger } from '../../src/core/logger.js'; describe('generateAIContextFiles', () => { let tmpDir: string; @@ -497,7 +498,11 @@ Old content here. await expect( fs.access(path.join(dir, '.claude', 'skills', 'gitnexus-exploring', 'SKILL.md')), ).resolves.toBeUndefined(); - await expect(fs.access(legacyKnown)).rejects.toThrow(); + // Divergent nested SKILL.md is preserved (#3080); only byte-identical + // leftovers are still removed. + await expect(fs.readFile(path.join(legacyKnown, 'SKILL.md'), 'utf-8')).resolves.toBe( + 'legacy', + ); await expect(fs.readFile(path.join(legacyUnknown, 'SKILL.md'), 'utf-8')).resolves.toBe( 'custom nested', ); @@ -538,6 +543,198 @@ Old content here. } }); + it('skipSkills does not remove nested leftover standard skills (#3080 / AE4)', async () => { + const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-nested-')); + const skipStorage = path.join(skipDir, '.gitnexus'); + const nested = path.join(skipDir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli'); + const bundled = await fs.readFile( + path.join(__dirname, '../../skills/gitnexus-cli.md'), + 'utf-8', + ); + await fs.mkdir(nested, { recursive: true }); + await fs.mkdir(skipStorage, { recursive: true }); + await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8'); + try { + await generateAIContextFiles(skipDir, skipStorage, 'TestProject', { nodes: 1 }, undefined, { + skipAgentsMd: true, + skipSkills: true, + }); + await expect(fs.readFile(path.join(nested, 'SKILL.md'), 'utf-8')).resolves.toBe(bundled); + await expect( + fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus-cli')), + ).rejects.toThrow(); + } finally { + await fs.rm(skipDir, { recursive: true, force: true }); + } + }); + + it('preserves customized flat SKILL.md under skipAgentsMd (#3080 / AE1)', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-flat-')); + const storage = path.join(dir, '.gitnexus'); + const cliSkill = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'); + await fs.mkdir(path.dirname(cliSkill), { recursive: true }); + await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(cliSkill, 'CUSTOM-COMMITTED-SKILL-3080-cli\n', 'utf-8'); + const cap = _captureLogger(); + try { + const result = await generateAIContextFiles( + dir, + storage, + 'TestProject', + { nodes: 1 }, + undefined, + { skipAgentsMd: true }, + ); + expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)'); + expect(result.files.some((f) => f.includes('skipped via --skip-skills'))).toBe(false); + await expect(fs.readFile(cliSkill, 'utf-8')).resolves.toBe( + 'CUSTOM-COMMITTED-SKILL-3080-cli\n', + ); + const msgs = cap.records().map((r) => r.msg ?? ''); + expect(msgs.some((m) => m.includes(cliSkill) && m.includes('--skip-skills'))).toBe(true); + } finally { + cap.restore(); + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('creates a missing standard skill from the bundle (#3080 / AE2)', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-missing-')); + const storage = path.join(dir, '.gitnexus'); + await fs.mkdir(storage, { recursive: true }); + try { + await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, { + skipAgentsMd: true, + }); + const created = await fs.readFile( + path.join(dir, '.claude', 'skills', 'gitnexus-debugging', 'SKILL.md'), + 'utf-8', + ); + const bundled = await fs.readFile( + path.join(__dirname, '../../skills/gitnexus-debugging.md'), + 'utf-8', + ); + expect(created).toBe(bundled); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('rewrites a SKILL.md that already matches the current bundle (R2)', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-ident-')); + const storage = path.join(dir, '.gitnexus'); + const dest = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'); + const bundled = await fs.readFile( + path.join(__dirname, '../../skills/gitnexus-cli.md'), + 'utf-8', + ); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(dest, bundled, 'utf-8'); + const writeSpy = vi.spyOn(fs, 'writeFile'); + try { + await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, { + skipAgentsMd: true, + }); + await expect(fs.readFile(dest, 'utf-8')).resolves.toBe(bundled); + expect(writeSpy).toHaveBeenCalledWith(dest, bundled, 'utf-8'); + } finally { + writeSpy.mockRestore(); + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('removes nested leftover when SKILL.md matches the bundle', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-ident-')); + const storage = path.join(dir, '.gitnexus'); + const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli'); + const bundled = await fs.readFile( + path.join(__dirname, '../../skills/gitnexus-cli.md'), + 'utf-8', + ); + await fs.mkdir(nested, { recursive: true }); + await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8'); + try { + await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, { + skipAgentsMd: true, + }); + await expect(fs.access(nested)).rejects.toThrow(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('preserves nested leftover siblings even when SKILL.md matches the bundle', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-sibling-')); + const storage = path.join(dir, '.gitnexus'); + const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli'); + const bundled = await fs.readFile( + path.join(__dirname, '../../skills/gitnexus-cli.md'), + 'utf-8', + ); + await fs.mkdir(nested, { recursive: true }); + await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8'); + await fs.writeFile(path.join(nested, 'notes.md'), 'operator notes\n', 'utf-8'); + const cap = _captureLogger(); + try { + const result = await generateAIContextFiles( + dir, + storage, + 'TestProject', + { nodes: 1 }, + undefined, + { skipAgentsMd: true }, + ); + await expect(fs.readFile(path.join(nested, 'notes.md'), 'utf-8')).resolves.toBe( + 'operator notes\n', + ); + expect(result.files).toContain( + '.claude/skills/gitnexus// (legacy directories preserved: 1)', + ); + expect(cap.records().some((record) => record.msg?.includes('operator-owned files'))).toBe( + true, + ); + } finally { + cap.restore(); + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('preserves a divergent .agents mirror while writing a missing .claude copy', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-agents-')); + const storage = path.join(dir, '.gitnexus'); + const agentsSkill = path.join(dir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'); + await fs.mkdir(path.dirname(agentsSkill), { recursive: true }); + await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(agentsSkill, 'CUSTOM-AGENTS-MIRROR\n', 'utf-8'); + try { + const result = await generateAIContextFiles( + dir, + storage, + 'TestProject', + { nodes: 1 }, + undefined, + { + skipAgentsMd: true, + }, + ); + await expect(fs.readFile(agentsSkill, 'utf-8')).resolves.toBe('CUSTOM-AGENTS-MIRROR\n'); + const claudeCopy = await fs.readFile( + path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(claudeCopy).not.toBe('CUSTOM-AGENTS-MIRROR\n'); + expect(claudeCopy.length).toBeGreaterThan(0); + expect(result.files).toContain( + '.agents/skills/gitnexus-*/ (5 written, 1 preserved for .agents)', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it('mirrors standard skills to .agents/skills/ when .agents/ exists', async () => { // Some agents prefer repo-local .agents/skills over the global // ~/.agents/skills install. When the repo contains an .agents/ directory, diff --git a/gitnexus/test/unit/setup-antigravity.test.ts b/gitnexus/test/unit/setup-antigravity.test.ts index 80b3b055a..338b75806 100644 --- a/gitnexus/test/unit/setup-antigravity.test.ts +++ b/gitnexus/test/unit/setup-antigravity.test.ts @@ -64,6 +64,11 @@ describe('setupAntigravity', () => { }); }; + const restoreSkillsRoot = (previous: string | undefined) => { + if (previous === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT; + else process.env.GITNEXUS_TEST_SKILLS_ROOT = previous; + }; + beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); @@ -263,6 +268,7 @@ describe('setupAntigravity', () => { '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n', 'utf-8', ); + const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot; try { @@ -278,7 +284,117 @@ describe('setupAntigravity', () => { fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')), ).resolves.toBeUndefined(); } finally { - delete process.env.GITNEXUS_TEST_SKILLS_ROOT; + restoreSkillsRoot(originalSkillsRoot); + } + }); + + it('preserves a customized installed skill when setup is rerun', async () => { + const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills'); + const installedSkill = path.join( + tempHome, + '.gemini', + 'antigravity', + 'skills', + 'gitnexus-test', + 'SKILL.md', + ); + await fs.mkdir(fixtureSkillsRoot, { recursive: true }); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test.md'), + '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n', + 'utf-8', + ); + const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; + process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot; + + try { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + await fs.writeFile(installedSkill, 'customized by operator\n', 'utf-8'); + + await setupCommand(); + + await expect(fs.readFile(installedSkill, 'utf-8')).resolves.toBe('customized by operator\n'); + } finally { + restoreSkillsRoot(originalSkillsRoot); + } + }); + + it('preserves customized files inside a directory skill when SKILL.md still matches', async () => { + const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills'); + const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test'); + const referencePath = path.join(skillDir, 'references', 'note.md'); + await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), { + recursive: true, + }); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'), + '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n', + 'utf-8', + ); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'), + 'bundled note\n', + 'utf-8', + ); + const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; + process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot; + + try { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + await fs.writeFile(referencePath, 'operator note\n', 'utf-8'); + + await setupCommand(); + + await expect(fs.readFile(referencePath, 'utf-8')).resolves.toBe('operator note\n'); + await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe( + '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n', + ); + } finally { + restoreSkillsRoot(originalSkillsRoot); + } + }); + + it('copies new bundled companions even when SKILL.md was customized', async () => { + const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills'); + const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test'); + await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), { + recursive: true, + }); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'), + '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n', + 'utf-8', + ); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'), + 'bundled note\n', + 'utf-8', + ); + const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; + process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot; + + try { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'customized by operator\n', 'utf-8'); + await fs.writeFile( + path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'added.md'), + 'new bundled companion\n', + 'utf-8', + ); + + await setupCommand(); + + await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe( + 'customized by operator\n', + ); + await expect( + fs.readFile(path.join(skillDir, 'references', 'added.md'), 'utf-8'), + ).resolves.toBe('new bundled companion\n'); + } finally { + restoreSkillsRoot(originalSkillsRoot); } }); }); diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts index 9163e8692..66dc82474 100644 --- a/gitnexus/test/unit/skip-git-cli.test.ts +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -39,11 +39,15 @@ describe('--skip-git CLI flag', () => { cwd: path.resolve(__dirname, '../..'), encoding: 'utf8', timeout: 10000, + env: { ...process.env, GITNEXUS_LANG: 'en' }, }); expect(helpOutput).toContain('--skip-git'); - expect(helpOutput).toContain('--skip-agents-md'); - expect(helpOutput).toContain('--skip-skills'); + const helpFlat = helpOutput.replace(/\s+/g, ' '); + expect(helpFlat).toContain('--skip-agents-md'); + expect(helpFlat).toContain('Does not skip standard skills in .claude/skills'); + expect(helpFlat).toContain('Community skills from --skills are unaffected'); + expect(helpFlat).toContain('--skip-skills'); expect(helpOutput).toContain('directly under .claude/skills/'); expect(helpOutput).toContain('.agents/skills/'); expect(helpOutput).toContain('.claude/skills/gitnexus-area-*');