diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index c62a2558b..63e4efc32 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -724,6 +724,8 @@ function isUpToDate(root, providers, bundleDir, scope) { if (bundleHash !== localHash) return false; } } + + if (!providerAgentsUpToDate(bundleDir, root, provider, scope)) return false; } return true; } @@ -1251,7 +1253,9 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) { } // Native subagent definitions that ship in the bundle next to a provider's -// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`: +// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project +// agents take precedence over user agents. GitHub Copilot's live at +// `.github/agents/impeccable-*.agent.md`: // project installs commit them at `/.github/agents/`, user-level // installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT // `~/.github/`). On a name conflict Copilot lets the user-level file shadow @@ -1261,6 +1265,11 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) { // `~/.cursor/agents/`; project agents take precedence there, so no shadow // warning is needed. const PROVIDER_AGENT_ARTIFACTS = { + '.claude': { + ext: '.md', + userDir: home => join(home, '.claude', 'agents'), + userShadowsProject: false, + }, '.github': { ext: '.agent.md', userDir: home => join(home, '.copilot', 'agents'), @@ -1273,6 +1282,23 @@ const PROVIDER_AGENT_ARTIFACTS = { }, }; +function providerAgentsUpToDate(bundleDir, root, provider, scope) { + const artifact = PROVIDER_AGENT_ARTIFACTS[provider]; + if (!artifact) return true; + const srcDir = join(bundleDir, provider, 'agents'); + if (!existsSync(srcDir)) return true; + + const destDir = scope === 'user' + ? artifact.userDir(root) + : join(root, provider, 'agents'); + const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext)); + return agentFiles.every(name => { + const localPath = join(destDir, name); + return existsSync(localPath) + && hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath); + }); +} + function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) { const targets = Array.isArray(providers) ? providers : [providers]; const results = []; diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index f4e9b799d..bda4e03b2 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -84,11 +84,13 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("local bundle context");\n'); } if (providers.includes('.claude')) { - mkdirSync(join(bundleRoot, '.claude'), { recursive: true }); + mkdirSync(join(bundleRoot, '.claude', 'agents'), { recursive: true }); writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({ description: 'fresh claude hook', hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }] }, }, null, 2)); + writeFileSync(join(bundleRoot, '.claude', 'agents', 'impeccable-finish-reviewer.md'), + '---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nClaude reviewer body.\n'); } if (providers.includes('.cursor')) { mkdirSync(join(bundleRoot, '.cursor'), { recursive: true }); @@ -228,7 +230,51 @@ describe('copyProviderSkills: symlink handling', () => { }); }); -describe('copyProviderAgents: Copilot and Cursor subagents', () => { +describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => { + test('Claude project and user scopes use .claude/agents, with project copies taking precedence', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-')); + const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-home-')); + const bundle = createFakeUniversalBundle(tmp, ['.claude']); + mkdirSync(join(home, '.claude', 'agents'), { recursive: true }); + writeFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'stale copy\n'); + + const projectResults = copyProviderAgents(bundle, tmp, ['.claude'], { scope: 'project', home }); + const userResults = copyProviderAgents(bundle, home, ['.claude'], { scope: 'user' }); + + expect(projectResults).toHaveLength(1); + expect(projectResults[0].shadowed).toEqual([]); + expect(userResults).toHaveLength(1); + expect(readFileSync(join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8')) + .toContain('Claude reviewer body.'); + expect(readFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8')) + .toContain('Claude reviewer body.'); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + test('Claude install and update backfill bundled agents beside an unchanged skill', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-')); + const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-home-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + const agentPath = join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md'); + + const installOutput = run('skills install -y --no-hooks --providers=claude', { cwd: tmp, env }); + expect(installOutput).toContain('Installed Claude Code agents into:'); + expect(existsSync(agentPath)).toBe(true); + + rmSync(agentPath); + const updateOutput = run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(updateOutput).toContain('Updated'); + expect(updateOutput).toContain('Installed Claude Code agents into:'); + expect(existsSync(agentPath)).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }, 15000); + test('project scope places agents at .github/agents/ and .cursor/agents/', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-')); const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);