fix: stop analyze from clobbering customized GitNexus skills (#3124)

* fix: preserve customized SKILL.md files during analyze

Skip overwriting standard GitNexus skill files when they already differ from the bundled template, and keep divergent nested leftovers instead of deleting them.

Fixes #3080

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: note --skip-agents-md does not skip skills

Point operators at --skip-skills so the AGENTS/CLAUDE skip flag is not mistaken for a full agent-file freeze.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): treat unreadable SKILL.md as an error, not missing

Only ENOENT counts as absent so EACCES cannot fall through to recursive rm. Strengthen the skipSkills nested leftover test with a bundle-identical file that install would otherwise delete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): prettier-format ai-context unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: clarify --skip-agents-md vs community --skills

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: pin GITNEXUS_LANG=en for skip-agents-md help assertions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: preserve customized skills in analyze summaries and setup

Report preserved vs written skills, keep leftover nested directories that
contain extra files, and skip setup overwrite of divergent SKILL.md.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3124)

Preserve operator-edited files inside directory skills during setup, and
restore GITNEXUS_TEST_SKILLS_ROOT after the Antigravity skill tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3124)

Copy directory-skill companions even when SKILL.md is customized, while
still skipping files whose bytes already differ from the bundle.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergő Magyar 2026-09-01 10:56:48 +01:00 committed by GitHub
parent 9f82ffd6bf
commit a82362765c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 492 additions and 33 deletions

View file

@ -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 <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)

View file

@ -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<bool
}
}
const SKILL_PRESERVE_HINT =
'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
async function readUtf8IfPresent(filePath: string): Promise<string | null> {
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<boolean> {
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/<name>/ (legacy directories preserved: ${legacyPreserved})`,
);
}
}

View file

@ -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.',

View file

@ -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 身份,则不执行任何操作。',

View file

@ -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) ' +

View file

@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
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<string[]> {
);
}
}
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<void> {
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);
}
}

View file

@ -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/<name>/ (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,

View file

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

View file

@ -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-*');