mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 17:46:36 +03:00
Fix Claude agent installation
Prepared with AI assistance from Codex under explicit maintainer authorization.
This commit is contained in:
@@ -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 `<repo>/.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 = [];
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user