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 <noreply@anthropic.com>
This commit is contained in:
ArgonarioD 2026-07-14 16:14:08 +08:00
parent 1482c0bc89
commit c7a9b7efc2
4 changed files with 191 additions and 5 deletions

View file

@ -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<string[]> {
export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<boolean> {
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)');

View file

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

View file

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

View file

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