From 7b94585653c117f4fbb8c46594e314a1711100ec Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 21 Aug 2026 09:10:31 -0700 Subject: [PATCH 1/3] Fix Claude agent installation Prepared with AI assistance from Codex under explicit maintainer authorization. --- cli/bin/commands/skills.mjs | 28 ++++++++++++++++++++- tests/skills-cli.test.js | 50 +++++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) 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']); From 16a218e6326f8b8892c7a818da4ba953efe9f158 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 21 Aug 2026 09:23:41 -0700 Subject: [PATCH 2/3] Fix home-scoped agent freshness Prepared with AI assistance from Codex under explicit maintainer authorization. --- cli/bin/commands/skills.mjs | 13 ++++++++----- tests/skills-cli.test.js | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 63e4efc32..a314e239a 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -1288,15 +1288,18 @@ function providerAgentsUpToDate(bundleDir, root, provider, scope) { const srcDir = join(bundleDir, provider, 'agents'); if (!existsSync(srcDir)) return true; - const destDir = scope === 'user' - ? artifact.userDir(root) - : join(root, provider, 'agents'); + const projectDir = join(root, provider, 'agents'); + const destDirs = scope === 'user' + ? [artifact.userDir(root)] + : scope === 'project' || !isHomeDir(root) + ? [projectDir] + : [...new Set([artifact.userDir(root), projectDir])]; const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext)); - return agentFiles.every(name => { + return agentFiles.every(name => destDirs.some(destDir => { const localPath = join(destDir, name); return existsSync(localPath) && hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath); - }); + })); } function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) { diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index bda4e03b2..fde99abac 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -308,6 +308,23 @@ describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => { rmSync(home, { recursive: true, force: true }); }); + test('skills check accepts current Copilot user agents in a home-rooted checkout', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-agents-check-home-')); + execSync('git init', { cwd: home }); + const bundleRoot = createFakeUniversalBundle(home, ['.github']); + const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env }); + expect(existsSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true); + expect(existsSync(join(home, '.github', 'agents'))).toBe(false); + + const output = run('skills check', { cwd: home, env }); + expect(output).toContain('Skills are up to date'); + expect(output).not.toContain('Updates available'); + + rmSync(home, { recursive: true, force: true }); + }, 15000); + test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-')); const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-')); From d2a9efb90fd7b9375851c1fb43c2de16c31db225 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 21 Aug 2026 09:37:53 -0700 Subject: [PATCH 3/3] Preserve inferred agent update scope Prepared with AI assistance from Codex under explicit maintainer authorization. --- cli/bin/commands/skills.mjs | 30 +++++++++++++++--------------- tests/skills-cli.test.js | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index a314e239a..7923a0989 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -699,7 +699,7 @@ function deduplicateProviders(root, providers, scope) { * SKILL.md, so script-only fixes and removed files are detected. * Returns true if every bundle skill matches the local copy. */ -function isUpToDate(root, providers, bundleDir, scope) { +function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) { const unique = deduplicateProviders(root, providers, scope); if (unique.length === 0) return false; @@ -725,7 +725,7 @@ function isUpToDate(root, providers, bundleDir, scope) { } } - if (!providerAgentsUpToDate(bundleDir, root, provider, scope)) return false; + if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false; } return true; } @@ -747,7 +747,8 @@ async function check() { console.log('Checking for updates...\n'); try { const bundleDir = await downloadAndExtractBundle(); - const upToDate = isUpToDate(root, providers, bundleDir); + const agentScope = isHomeDir(root) ? 'user' : undefined; + const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope); rmSync(bundleDir, { recursive: true, force: true }); if (upToDate) { @@ -1288,18 +1289,15 @@ function providerAgentsUpToDate(bundleDir, root, provider, scope) { const srcDir = join(bundleDir, provider, 'agents'); if (!existsSync(srcDir)) return true; - const projectDir = join(root, provider, 'agents'); - const destDirs = scope === 'user' - ? [artifact.userDir(root)] - : scope === 'project' || !isHomeDir(root) - ? [projectDir] - : [...new Set([artifact.userDir(root), projectDir])]; + 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 => destDirs.some(destDir => { + 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() } = {}) { @@ -2101,7 +2099,9 @@ function resolveUpdateTarget({ projectRoot, home, explicitScope }) { const homeRooted = isHomeDir(projectRoot); if (homeRooted && !explicitScope) { const providers = findInstalledProviders(home); - return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null; + return providers.length + ? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' } + : null; } const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project'); @@ -2232,7 +2232,7 @@ async function update(flags = []) { : { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' }; } - const { root, scope } = target; + const { root, scope, agentScope = scope } = target; console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`); const providers = target.providers; const linkedProviders = findLinkedProviders(root, providers, scope); @@ -2256,7 +2256,7 @@ async function update(flags = []) { } // Compare local vs remote -- skip if already up to date - if (isUpToDate(root, copyProviders, tmpDir, scope)) { + if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) { try { const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes }); const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : []; @@ -2292,7 +2292,7 @@ async function update(flags = []) { if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).'); const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope); - reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope })); + reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope })); const wantHooks = installHooks && await decideHookInstall(root, providers, { yes }); const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : []; diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index fde99abac..d01b7b83c 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -325,6 +325,29 @@ describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => { rmSync(home, { recursive: true, force: true }); }, 15000); + test('inferred home-rooted updates refresh stale or missing Copilot user agents', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-agents-update-home-')); + execSync('git init', { cwd: home }); + const bundleRoot = createFakeUniversalBundle(home, ['.github']); + const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + const userAgent = join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'); + const projectAgent = join(home, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'); + + run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env }); + writeFileSync(userAgent, 'stale copy\n'); + + run('skills update -y --no-hooks', { cwd: home, env }); + expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.'); + expect(existsSync(projectAgent)).toBe(false); + + rmSync(userAgent); + run('skills update -y --no-hooks', { cwd: home, env }); + expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.'); + expect(existsSync(projectAgent)).toBe(false); + + rmSync(home, { recursive: true, force: true }); + }, 20000); + test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-')); const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));