From c7a9b7efc207827009639d183b8ee9713ec99670 Mon Sep 17 00:00:00 2001 From: ArgonarioD Date: Tue, 14 Jul 2026 16:14:08 +0800 Subject: [PATCH] feat(cli): mirror skills to .agents/skills/ when .agents/ exists Some agents prefer repo-local .agents/skills/ over the global install. When .agents/ is present, mirror the standard and generated skills written to .claude/skills/ so those agents serve up-to-date copies. Opt-in via .agents/; absent directory leaves the layout untouched. Co-Authored-By: Claude --- gitnexus/src/cli/ai-context.ts | 46 ++++++++++++++++--- gitnexus/src/cli/skill-gen.ts | 29 ++++++++++++ gitnexus/test/unit/ai-context.test.ts | 57 ++++++++++++++++++++++++ gitnexus/test/unit/skill-gen.test.ts | 64 +++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index b1c4a6194..1437653dd 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -364,12 +364,31 @@ async function upsertGitNexusSection( } /** - * Install GitNexus skills to .claude/skills/gitnexus/ - * Works natively with Claude Code, Cursor, and GitHub Copilot + * Some agents read skills from a repo-local `.agents/skills/` directory and + * prefer it over the global `~/.agents/skills/` install. When the repo contains + * an `.agents/` directory, skills written to `.claude/skills/` are mirrored + * there too so those agents serve the up-to-date copies. */ -async function installSkills(repoPath: string): Promise { +export async function shouldMirrorSkillsToAgents(repoPath: string): Promise { + try { + const stat = await fs.stat(path.join(repoPath, '.agents')); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Install GitNexus skills to .claude/skills/gitnexus/ + * Works natively with Claude Code, Cursor, and GitHub Copilot. + * Mirrored to .agents/skills/gitnexus/ when .agents/ exists. + */ +async function installSkills( + repoPath: string, +): Promise<{ skills: string[]; agentsMirror: boolean }> { const skillsDir = path.join(repoPath, '.claude', 'skills', 'gitnexus'); const installedSkills: string[] = []; + const agentsMirror = await shouldMirrorSkillsToAgents(repoPath); // Skill definitions bundled with the package const skills = [ @@ -435,6 +454,18 @@ Use GitNexus tools to accomplish this task. } await fs.writeFile(skillPath, skillContent, 'utf-8'); + + // Mirror to .agents/skills/ for agents that read repo-local skills + if (agentsMirror) { + try { + const agentsSkillDir = path.join(repoPath, '.agents', 'skills', 'gitnexus', skill.name); + await fs.mkdir(agentsSkillDir, { recursive: true }); + await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8'); + } catch (err) { + logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`); + } + } + installedSkills.push(skill.name); } catch (err) { // Skip on error, don't fail the whole process @@ -442,7 +473,7 @@ Use GitNexus tools to accomplish this task. } } - return installedSkills; + return { skills: installedSkills, agentsMirror }; } /** @@ -520,9 +551,14 @@ export async function generateAIContextFiles( // Install skills to .claude/skills/gitnexus/ (unless --skip-skills) if (!options?.skipSkills) { - const installedSkills = await installSkills(repoPath); + const { skills: installedSkills, agentsMirror } = await installSkills(repoPath); if (installedSkills.length > 0) { createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`); + if (agentsMirror) { + createdFiles.push( + `.agents/skills/gitnexus/ (${installedSkills.length} skills mirrored for .agents)`, + ); + } } } else { createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)'); diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index d718a91dd..ad5fbf817 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -13,6 +13,7 @@ import { PipelineResult } from '../types/pipeline.js'; import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js'; import { ProcessNode } from '../core/ingestion/process-processor.js'; import { KnowledgeGraph } from '../core/graph/types.js'; +import { shouldMirrorSkillsToAgents } from './ai-context.js'; // ============================================================================ // TYPES @@ -69,6 +70,12 @@ export const generateSkillFiles = async ( ): Promise<{ skills: GeneratedSkillInfo[]; outputPath: string }> => { const { communityResult, processResult, graph } = pipelineResult; const outputDir = path.join(repoPath, '.claude', 'skills', 'generated'); + // Some agents prioritize repo-local .agents/skills over the global + // ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/ + // exists, mirror the generated community skills there too so those agents + // serve the up-to-date copies. + const agentsOutputDir = path.join(repoPath, '.agents', 'skills', 'generated'); + const mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath); if (!communityResult || !communityResult.memberships.length) { console.log('\n Skills: no communities detected, skipping skill generation'); @@ -115,6 +122,17 @@ export const generateSkillFiles = async ( } await fs.mkdir(outputDir, { recursive: true }); + // Keep the .agents-facing mirror in lockstep with .claude/skills/generated/: + // clear stale community skills before writing the fresh set. + if (mirrorToAgents) { + try { + await fs.rm(agentsOutputDir, { recursive: true, force: true }); + } catch { + /* may not exist */ + } + await fs.mkdir(agentsOutputDir, { recursive: true }); + } + // Step 5: Generate skill files const skills: GeneratedSkillInfo[] = []; const usedNames = new Set(); @@ -163,6 +181,14 @@ export const generateSkillFiles = async ( await fs.mkdir(skillDir, { recursive: true }); await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); + // Mirror to .agents/skills/generated/ for agents that read .agents/ + // (see mirrorToAgents above). + if (mirrorToAgents) { + const agentsSkillDir = path.join(agentsOutputDir, kebabName); + await fs.mkdir(agentsSkillDir, { recursive: true }); + await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8'); + } + const info: GeneratedSkillInfo = { name: kebabName, label: community.label, @@ -177,6 +203,9 @@ export const generateSkillFiles = async ( } console.log(`\n ${skills.length} skills generated \u2192 .claude/skills/generated/`); + if (mirrorToAgents) { + console.log(` ${skills.length} skills mirrored \u2192 .agents/skills/generated/ (.agents)`); + } return { skills, outputPath: outputDir }; }; diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index c3eca3458..082613e85 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -386,6 +386,63 @@ Old content here. } }); + it('mirrors standard skills to .agents/skills/gitnexus/ when .agents/ exists', async () => { + // Some agents prefer repo-local .agents/skills over the global + // ~/.agents/skills install. When the repo contains an .agents/ directory, + // installSkills() must mirror the same SKILL.md files there so those agents + // serve up-to-date repo-specific skills instead of stale global copies. + const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-')); + const agentsStorage = path.join(agentsDir, '.gitnexus'); + await fs.mkdir(agentsStorage, { recursive: true }); + // Opt-in: create the repo-local .agents/ directory. + await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); + + // Canonical .claude copy is always written. + expect(result.files.some((f) => f.startsWith('.claude/skills/gitnexus/'))).toBe(true); + // Mirror is reported. + expect(result.files).toContain('.agents/skills/gitnexus/ (6 skills mirrored for .agents)'); + + const claudeSkill = await fs.readFile( + path.join(agentsDir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + const agentsSkill = await fs.readFile( + path.join(agentsDir, '.agents', 'skills', 'gitnexus', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(agentsSkill).toBe(claudeSkill); + expect(agentsSkill.length).toBeGreaterThan(0); + } finally { + await fs.rm(agentsDir, { recursive: true, force: true }); + } + }); + + it('does not mirror skills to .agents/ when the directory is absent', async () => { + // Without an .agents/ opt-in, only the canonical .claude/skills/ copy is + // written — no .agents/ tree should be created. + const noAgentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-agents-')); + const noAgentsStorage = path.join(noAgentsDir, '.gitnexus'); + await fs.mkdir(noAgentsStorage, { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles( + noAgentsDir, + noAgentsStorage, + 'TestProject', + stats, + ); + + expect(result.files.some((f) => f.startsWith('.claude/skills/gitnexus/'))).toBe(true); + expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); + await expect(fs.access(path.join(noAgentsDir, '.agents'))).rejects.toThrow(); + } finally { + await fs.rm(noAgentsDir, { recursive: true, force: true }); + } + }); + it('writes nothing when both skipAgentsMd and skipSkills are true (--index-only, #742)', async () => { // Regression guard for #742. analyzeCommand() resolves --index-only // into BOTH skipAgentsMd=true and skipSkills=true. This test pins diff --git a/gitnexus/test/unit/skill-gen.test.ts b/gitnexus/test/unit/skill-gen.test.ts index 21f993ff3..9c37c5645 100644 --- a/gitnexus/test/unit/skill-gen.test.ts +++ b/gitnexus/test/unit/skill-gen.test.ts @@ -597,6 +597,70 @@ describe('generateSkillFiles — file output', () => { expect(betaSkill.length).toBeGreaterThan(0); }); + /** + * When the repo contains an .agents/ directory, generated community skills + * must be mirrored to .agents/skills/generated/ so agents that prefer + * repo-local .agents/skills over the global ~/.agents/skills install serve + * the up-to-date set. The mirror content must match the .claude copy. + */ + it('mirrors generated skills to .agents/skills/generated/ when .agents/ exists', async () => { + const { graph, communities, memberships } = twoCommSetup(); + await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true }); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ + graph, + repoPath: tmpDir, + communities, + memberships, + }), + ); + + const claudeAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'), + 'utf-8', + ); + const agentsAlpha = await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'generated', 'alpha', 'SKILL.md'), + 'utf-8', + ); + const agentsBeta = await fs.readFile( + path.join(tmpDir, '.agents', 'skills', 'generated', 'beta', 'SKILL.md'), + 'utf-8', + ); + expect(agentsAlpha).toBe(claudeAlpha); + expect(agentsBeta.length).toBeGreaterThan(0); + }); + + /** + * Without an .agents/ opt-in, no .agents/skills/ tree should be created — + * only the canonical .claude/skills/generated/ copy is written. + */ + it('does not mirror generated skills to .agents/ when the directory is absent', async () => { + const { graph, communities, memberships } = twoCommSetup(); + + await generateSkillFiles( + tmpDir, + 'TestProject', + buildPipelineResult({ + graph, + repoPath: tmpDir, + communities, + memberships, + }), + ); + + // Canonical copy exists, mirror does not. + const claudeAlpha = await fs.readFile( + path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'), + 'utf-8', + ); + expect(claudeAlpha.length).toBeGreaterThan(0); + await expect(fs.access(path.join(tmpDir, '.agents'))).rejects.toThrow(); + }); + /** * SKILL.md files should start with YAML frontmatter containing * name and description fields.