mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
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:
parent
1482c0bc89
commit
c7a9b7efc2
4 changed files with 191 additions and 5 deletions
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue