From 5e426b1397517a8d95f32c26277d582f98e5be53 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 7 May 2026 16:53:28 -0600 Subject: [PATCH 1/4] feat: package Claude plugin integration --- harnessIntegrations/AGENTS.md | 16 ++++++++++++++++ harnessIntegrations/CLAUDE.md | 1 + .../claude/.claude-plugin/plugin.json | 12 ++++++++++++ harnessIntegrations/claude/hooks/hooks.json | 16 ++++++++++++++++ release-please-config.json | 7 +++++++ 5 files changed, 52 insertions(+) create mode 100644 harnessIntegrations/AGENTS.md create mode 100644 harnessIntegrations/CLAUDE.md create mode 100644 harnessIntegrations/claude/.claude-plugin/plugin.json create mode 100644 harnessIntegrations/claude/hooks/hooks.json diff --git a/harnessIntegrations/AGENTS.md b/harnessIntegrations/AGENTS.md new file mode 100644 index 0000000..9ab3b9b --- /dev/null +++ b/harnessIntegrations/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md — harness integrations + +This directory holds source assets for first-class harness integrations. These files are consumed by external agent harnesses, so keep guidance here generic and let agents inspect the current files for exact hook names, commands, manifest fields, and IDs. + +## Maintenance Rules + +- Keep integration assets small, declarative, and scoped to the harness directory they belong to. +- Keep each harness' checked-in assets aligned with its installer in `packages/cli/src/harnesses`. +- Install through the harness' public command surface when one exists; avoid copying files directly into user config directories unless that is the harness' supported integration model. +- Treat checked-in harness assets as release artifacts. If a manifest or generated integration file needs version management, wire it through release automation rather than hand-maintaining drift-prone versions. + +## Update And Migration Behavior + +When a release changes harness contracts, plugin identity, hook shape, or installed assets, the CLI should preserve existing users by refreshing already-managed harness state after the binary update. The refresh should re-run the normal installer for scopes where PlanBridge has installed hook/plugin state and skip harnesses that PlanBridge has never managed. + +For renames or migrations, install the new integration successfully before removing legacy state, and only remove legacy state at the same scope being migrated. diff --git a/harnessIntegrations/CLAUDE.md b/harnessIntegrations/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/harnessIntegrations/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/harnessIntegrations/claude/.claude-plugin/plugin.json b/harnessIntegrations/claude/.claude-plugin/plugin.json new file mode 100644 index 0000000..0f58fe3 --- /dev/null +++ b/harnessIntegrations/claude/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "planbridge", + "description": "PlanBridge — opens plan reviews in the browser on ExitPlanMode.", + "version": "0.2.0", + "author": { + "name": "ContextBridge AI, Inc." + }, + "homepage": "https://plan.contextbridge.ai", + "repository": "https://github.com/contextbridge/planbridge", + "license": "MIT", + "keywords": ["contextbridge", "planbridge", "planning", "review", "hooks", "ExitPlanMode"] +} diff --git a/harnessIntegrations/claude/hooks/hooks.json b/harnessIntegrations/claude/hooks/hooks.json new file mode 100644 index 0000000..40b6518 --- /dev/null +++ b/harnessIntegrations/claude/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PermissionRequest": [ + { + "matcher": "ExitPlanMode", + "hooks": [ + { + "type": "command", + "command": "contextbridge hook claude", + "timeout": 345600 + } + ] + } + ] + } +} diff --git a/release-please-config.json b/release-please-config.json index 802c28e..acc09f4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -6,6 +6,13 @@ "package-name": "contextbridge", "include-component-in-tag": false, "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "json", + "path": "harnessIntegrations/claude/.claude-plugin/plugin.json", + "jsonpath": "$.version" + } + ], "changelog-sections": [ { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, From a7f5e7b4691db1af852d782f2b0f56597688a02e Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 7 May 2026 16:54:00 -0600 Subject: [PATCH 2/4] feat!: rename Claude plugin to planbridge --- packages/cli/src/cli.test.ts | 9 +- packages/cli/src/commands/install.test.ts | 22 +- .../cli/src/commands/installStatus.test.ts | 12 +- packages/cli/src/commands/uninstall.test.ts | 11 +- .../cli/src/harnesses/ClaudeInstaller.test.ts | 344 ++++++++++++++---- packages/cli/src/harnesses/ClaudeInstaller.ts | 53 ++- 6 files changed, 333 insertions(+), 118 deletions(-) diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index a085f0d..1bff0e9 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -91,6 +91,7 @@ describe('runCli', () => { it('routes argv into the install claude subcommand with default user scope', async () => { const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); @@ -153,7 +154,7 @@ describe('runCli', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) .resolves(marketplaceListResult([{ name: 'contextbridge' }])); @@ -165,7 +166,7 @@ describe('runCli', () => { expect(exitCode).toBe(0); const uninstallCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall']); expect(uninstallCalls).toHaveLength(1); - expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'project']); + expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'project']); }); it('routes argv into the no-target install orchestrator with --yes', async () => { @@ -191,7 +192,7 @@ describe('runCli', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); const exitCode = await runCli(context, ['install', 'status', '--json']); @@ -207,7 +208,7 @@ describe('runCli', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); const exitCode = await runCli(context, ['install', 'status', '--json']); diff --git a/packages/cli/src/commands/install.test.ts b/packages/cli/src/commands/install.test.ts index ab369a6..fa8754e 100644 --- a/packages/cli/src/commands/install.test.ts +++ b/packages/cli/src/commands/install.test.ts @@ -34,7 +34,7 @@ describe('runInstall', () => { 'user', ]); expect(pluginInstall).toHaveLength(1); - expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', 'cli@contextbridge', '--scope', 'user']); + expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user']); expect(prompter.calls).toEqual([]); const stderr = io.stderr.text(); expect(stderr).toContain('Claude Code: not installed'); @@ -49,7 +49,7 @@ describe('runInstall', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); await runInstall(context, { yes: true }); @@ -57,7 +57,9 @@ describe('runInstall', () => { expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install'])).toEqual([]); expect(prompter.calls).toEqual([]); const stderr = io.stderr.text(); - expect(stderr).toContain('Claude Code: installed (marketplace contextbridge; plugin cli@contextbridge @ user)'); + expect(stderr).toContain( + 'Claude Code: installed (marketplace contextbridge; plugin planbridge@contextbridge @ user)', + ); expect(stderr).toContain('Installed 0 of 1 detected harness (1 already installed, skipped).'); }); @@ -69,14 +71,14 @@ describe('runInstall', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves(); await runInstall(context, { yes: true, force: true }); expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'add'])).toHaveLength(1); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install'])).toHaveLength(1); + expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'update'])).toHaveLength(1); expect(prompter.calls).toEqual([]); const stderr = io.stderr.text(); expect(stderr).toContain('Installed 1 of 1 detected harness'); @@ -91,14 +93,16 @@ describe('runInstall', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); await runInstall(context, { yes: true }); expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'add'])).toEqual([]); expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install'])).toEqual([]); const stderr = io.stderr.text(); - expect(stderr).toContain('Claude Code: installed (marketplace contextbridge; plugin cli@contextbridge @ project)'); + expect(stderr).toContain( + 'Claude Code: installed (marketplace contextbridge; plugin planbridge@contextbridge @ project)', + ); expect(stderr).toContain('(1 already installed, skipped)'); }); @@ -155,7 +159,7 @@ describe('runInstall', () => { 'user', ]); expect(pluginInstall).toHaveLength(1); - expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', 'cli@contextbridge', '--scope', 'user']); + expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user']); const stderr = io.stderr.text(); expect(stderr).toContain('Codex CLI: status unavailable (invalid Codex hooks.json'); expect(stderr).toContain('Installed 1 of 2 detected harnesses.'); diff --git a/packages/cli/src/commands/installStatus.test.ts b/packages/cli/src/commands/installStatus.test.ts index 2170202..8d998df 100644 --- a/packages/cli/src/commands/installStatus.test.ts +++ b/packages/cli/src/commands/installStatus.test.ts @@ -14,14 +14,14 @@ describe('runInstallStatus', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); await runInstallStatus(context); const stderr = io.stderr.text(); expect(stderr).toContain('Claude Code: installed'); expect(stderr).toContain('marketplace contextbridge'); - expect(stderr).toContain('plugin cli@contextbridge @ user'); + expect(stderr).toContain('plugin planbridge@contextbridge @ user'); expect(io.stdout.text()).toBe(''); }); @@ -57,11 +57,11 @@ describe('runInstallStatus', () => { commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); await runInstallStatus(context); - expect(io.stderr.text()).toContain('plugin cli@contextbridge @ project'); + expect(io.stderr.text()).toContain('plugin planbridge@contextbridge @ project'); }); it('reports "not detected" when claude is not on PATH and runs no shellouts', async () => { @@ -82,7 +82,7 @@ describe('runInstallStatus', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); await runInstallStatus(context, { json: true }); @@ -95,7 +95,7 @@ describe('runInstallStatus', () => { installed: true, managed: [ { kind: 'marketplace', identifier: 'contextbridge' }, - { kind: 'plugin', identifier: 'cli@contextbridge', scope: 'user' }, + { kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'user' }, ], }); expect(payload[1]).toMatchObject({ diff --git a/packages/cli/src/commands/uninstall.test.ts b/packages/cli/src/commands/uninstall.test.ts index e0cb6b6..6d76bd8 100644 --- a/packages/cli/src/commands/uninstall.test.ts +++ b/packages/cli/src/commands/uninstall.test.ts @@ -15,7 +15,7 @@ describe('runUninstall', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); @@ -24,7 +24,7 @@ describe('runUninstall', () => { const uninstallCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall']); const marketplaceRemoveCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']); expect(uninstallCalls).toHaveLength(1); - expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user']); + expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user']); expect(marketplaceRemoveCalls).toHaveLength(1); expect(marketplaceRemoveCalls[0]?.args).toEqual(['plugin', 'marketplace', 'remove', 'contextbridge']); expect(prompter.calls).toEqual([]); @@ -39,8 +39,7 @@ describe('runUninstall', () => { await runUninstall(context, { yes: true }); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toEqual([]); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove'])).toEqual([]); + expect(commandRunner.calls).toHaveLength(2); expect(prompter.calls).toEqual([]); const stderr = io.stderr.text(); expect(stderr).toContain('Claude Code: not installed'); @@ -58,8 +57,8 @@ describe('runUninstall', () => { await runUninstall(context, { yes: true }); - const marketplaceRemoveCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']); expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toEqual([]); + const marketplaceRemoveCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']); expect(marketplaceRemoveCalls).toHaveLength(1); expect(marketplaceRemoveCalls[0]?.args).toEqual(['plugin', 'marketplace', 'remove', 'contextbridge']); expect(prompter.calls).toEqual([]); @@ -91,7 +90,7 @@ describe('runUninstall', () => { .resolves(marketplaceListResult([{ name: 'contextbridge' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); prompter.setConfirm(false); await runUninstall(context); diff --git a/packages/cli/src/harnesses/ClaudeInstaller.test.ts b/packages/cli/src/harnesses/ClaudeInstaller.test.ts index 8cebf7d..41a5889 100644 --- a/packages/cli/src/harnesses/ClaudeInstaller.test.ts +++ b/packages/cli/src/harnesses/ClaudeInstaller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { CommanderError } from 'commander'; import { + type FakeCommandRunner, createStubContext, marketplaceListResult, pluginListResult, @@ -11,49 +12,114 @@ import { ClaudeInstaller } from './ClaudeInstaller.ts'; import { getDescriptor } from './registry.ts'; const CLAUDE_BINARY = getDescriptor('claude').binaryName; +type PluginFixture = Parameters[0][number]; +type MarketplaceFixture = Parameters[0][number]; describe('ClaudeInstaller.install', () => { it('runs marketplace-add then plugin-install at user scope and emits onboarding prose', async () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, []); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); await installer.install(context, { yes: true }); - // Ordering is load-bearing: marketplace-add must precede plugin-install. expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, ]); const stderr = io.stderr.text(); expect(stderr).toContain('PlanBridge plugin installed for Claude Code (scope: user)'); expect(stderr).toContain('Restart Claude Code'); + expect(stderr).not.toContain('renamed from cli@contextbridge'); expect(io.stdout.text()).toBe(''); }); - it('succeeds on re-install because the claude CLI is naturally idempotent', async () => { + it('updates the plugin when the new id is already installed at the target scope', async () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves({ stdout: "Adding marketplace…✔ Marketplace 'contextbridge' already on disk — declared in user settings", }); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves({ - stdout: 'Installing plugin "cli@contextbridge"...✔ Plugin "cli@contextbridge" is already installed (scope: user)', + commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves({ + stdout: 'Updated plugin "planbridge@contextbridge" to latest version', }); await installer.install(context, { yes: true }); + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { + cmd: CLAUDE_BINARY, + args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + opts: {}, + }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'update', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + ]); expect(io.stderr.text()).toContain('installed for Claude Code'); }); + it('migrates a legacy cli@contextbridge install at the target scope after installing the new id', async () => { + const installer = new ClaudeInstaller(); + const { context, io, commandRunner } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); + + await installer.install(context, { yes: true }); + + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { + cmd: CLAUDE_BINARY, + args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + opts: {}, + }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + ]); + + const stderr = io.stderr.text(); + expect(stderr).toContain( + 'PlanBridge plugin renamed from cli@contextbridge to planbridge@contextbridge — migrated automatically.', + ); + expect(stderr).toContain('PlanBridge plugin installed for Claude Code (scope: user)'); + }); + + it('leaves legacy cli@contextbridge alone when it is at a different scope than the install target', async () => { + const installer = new ClaudeInstaller(); + const { context, io, commandRunner } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'project' }]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); + + await installer.install(context, { yes: true }); + + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { + cmd: CLAUDE_BINARY, + args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + opts: {}, + }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + ]); + expect(io.stderr.text()).not.toContain('renamed from cli@contextbridge'); + }); + it('aborts when claude is not on PATH and never invokes a shellout', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); @@ -67,13 +133,13 @@ describe('ClaudeInstaller.install', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, []); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']) .resolves({ exitCode: 1, stderr: 'network unreachable' }); expect(installer.install(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'add'])).toHaveLength(1); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install'])).toEqual([]); + expect(commandRunner.calls).toHaveLength(2); expect(readErrorLogs(logs).some((r) => r.msg.includes('network unreachable'))).toBe(true); }); @@ -81,21 +147,69 @@ describe('ClaudeInstaller.install', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, []); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'install']) + .resolves({ exitCode: 1, stderr: 'plugin not found in marketplace' }); + + expect(installer.install(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); + expect(commandRunner.calls).toHaveLength(3); + expect(readErrorLogs(logs).some((r) => r.msg.includes('plugin not found in marketplace'))).toBe(true); + }); + + it('does not remove a legacy install when the new plugin install fails', () => { + const installer = new ClaudeInstaller(); + const { context, commandRunner, logs } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner .on(CLAUDE_BINARY, ['plugin', 'install']) .resolves({ exitCode: 1, stderr: 'plugin not found in marketplace' }); expect(installer.install(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'add'])).toHaveLength(1); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install'])).toHaveLength(1); + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { + cmd: CLAUDE_BINARY, + args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + opts: {}, + }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + ]); expect(readErrorLogs(logs).some((r) => r.msg.includes('plugin not found in marketplace'))).toBe(true); }); + it('does not remove a legacy install when the new plugin update fails', () => { + const installer = new ClaudeInstaller(); + const { context, commandRunner, logs } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [ + { id: 'planbridge@contextbridge', scope: 'user' }, + { id: 'cli@contextbridge', scope: 'user' }, + ]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves({ exitCode: 1, stderr: 'update failed' }); + + expect(installer.install(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { + cmd: CLAUDE_BINARY, + args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + opts: {}, + }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'update', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + ]); + expect(readErrorLogs(logs).some((r) => r.msg.includes('update failed'))).toBe(true); + }); + it('logs a synthetic detail when stderr is empty on a non-zero exit', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, []); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves({ exitCode: 3 }); expect(installer.install(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); @@ -108,21 +222,16 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); await installer.uninstall(context, { yes: true }); - // Ordering is load-bearing: list → uninstall → list-marketplaces → remove-marketplace. expect(commandRunner.calls).toEqual([ { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, ]); @@ -134,18 +243,17 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'project' }]); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); await installer.uninstall(context, { yes: true }); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toEqual([]); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove'])).toHaveLength(1); + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed'); expect(readLogs(logs).some((r) => r.msg.includes('not installed at scope user'))).toBe(true); }); @@ -154,31 +262,78 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); + stubMarketplaceList(commandRunner, []); await installer.uninstall(context, { yes: true }); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toHaveLength(1); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove'])).toEqual([]); + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, + ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed'); expect(readLogs(logs).some((r) => r.msg.includes('marketplace is not configured'))).toBe(true); }); + it('uninstalls a legacy cli@contextbridge install at the target scope', async () => { + const installer = new ClaudeInstaller(); + const { context, io, commandRunner, logs } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); + + await installer.uninstall(context, { yes: true }); + + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + ]); + expect(io.stderr.text()).toContain('PlanBridge plugin removed'); + expect(readLogs(logs).some((r) => r.msg.includes('not installed at scope user'))).toBe(true); + }); + + it('uninstalls both the new id and the legacy cli@contextbridge when both are at the target scope', async () => { + const installer = new ClaudeInstaller(); + const { context, commandRunner } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, [ + { id: 'planbridge@contextbridge', scope: 'user' }, + { id: 'cli@contextbridge', scope: 'user' }, + ]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); + + await installer.uninstall(context, { yes: true }); + + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + ]); + }); + it('is fully idempotent when both the plugin and marketplace are already absent', async () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); + stubPluginList(commandRunner, []); + stubMarketplaceList(commandRunner, []); await installer.uninstall(context, { yes: true }); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toEqual([]); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove'])).toEqual([]); + expect(commandRunner.calls).toEqual([ + { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, + ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed'); }); @@ -208,15 +363,11 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves({ exitCode: 1, stderr: 'disk full' }); expect(installer.uninstall(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'list', '--json'])).toHaveLength(1); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toHaveLength(1); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json'])).toEqual([]); + expect(commandRunner.calls).toHaveLength(2); expect(readErrorLogs(logs).some((r) => r.msg.includes('disk full'))).toBe(true); }); @@ -224,19 +375,15 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']) .resolves({ exitCode: 1, stderr: 'some other marketplace error' }); expect(installer.uninstall(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); - expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove'])).toHaveLength(1); + expect(commandRunner.calls).toHaveLength(4); expect(readErrorLogs(logs).some((r) => r.msg.includes('some other marketplace error'))).toBe(true); }); }); @@ -246,6 +393,7 @@ describe('ClaudeInstaller scope prompt', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, prompter } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, []); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); prompter.setSelect('project'); @@ -254,16 +402,15 @@ describe('ClaudeInstaller scope prompt', () => { expect(prompter.selectCalls).toHaveLength(1); expect(prompter.selectCalls[0]?.message).toBe('Install scope:'); - const marketplaceAdd = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']); - const pluginInstall = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install']); - expect(marketplaceAdd[0]?.args).toContain('project'); - expect(pluginInstall[0]?.args).toContain('project'); + expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'add'])[0]?.args).toContain('project'); + expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'install'])[0]?.args).toContain('project'); }); it('skips the scope prompt when yes=true and uses the user-scope default', async () => { const installer = new ClaudeInstaller(); const { context, commandRunner, prompter } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubPluginList(commandRunner, []); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); @@ -277,13 +424,9 @@ describe('ClaudeInstaller scope prompt', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, prompter } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'project' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); prompter.setSelect('project'); @@ -291,8 +434,13 @@ describe('ClaudeInstaller scope prompt', () => { expect(prompter.selectCalls).toHaveLength(1); expect(prompter.selectCalls[0]?.message).toBe('Uninstall scope:'); - const uninstallCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall']); - expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'project']); + expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])[0]?.args).toEqual([ + 'plugin', + 'uninstall', + 'planbridge@contextbridge', + '--scope', + 'project', + ]); }); }); @@ -314,12 +462,8 @@ describe('ClaudeInstaller.status', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); const status = await installer.status(context); @@ -327,7 +471,7 @@ describe('ClaudeInstaller.status', () => { expect(status.installed).toBe(true); expect(status.managed).toEqual([ { kind: 'marketplace', identifier: 'contextbridge' }, - { kind: 'plugin', identifier: 'cli@contextbridge', scope: 'user' }, + { kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'user' }, ]); }); @@ -335,26 +479,60 @@ describe('ClaudeInstaller.status', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + stubMarketplaceList(commandRunner, []); + stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'project' }]); const status = await installer.status(context); expect(status.detected).toBe(true); expect(status.installed).toBe(true); - expect(status.managed).toEqual([{ kind: 'plugin', identifier: 'cli@contextbridge', scope: 'project' }]); + expect(status.managed).toEqual([{ kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'project' }]); + }); + + it('reports a legacy cli@contextbridge install as managed but not installed', async () => { + const installer = new ClaudeInstaller(); + const { context, commandRunner } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + + const status = await installer.status(context); + + expect(status.detected).toBe(true); + expect(status.installed).toBe(false); + expect(status.managed).toEqual([ + { kind: 'marketplace', identifier: 'contextbridge' }, + { kind: 'plugin', identifier: 'cli@contextbridge', scope: 'user' }, + ]); + }); + + it('reports both new and legacy plugin entries when both are installed', async () => { + const installer = new ClaudeInstaller(); + const { context, commandRunner } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubPluginList(commandRunner, [ + { id: 'planbridge@contextbridge', scope: 'user' }, + { id: 'cli@contextbridge', scope: 'user' }, + ]); + + const status = await installer.status(context); + + expect(status.detected).toBe(true); + expect(status.installed).toBe(true); + expect(status.managed).toEqual([ + { kind: 'marketplace', identifier: 'contextbridge' }, + { kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'user' }, + { kind: 'plugin', identifier: 'cli@contextbridge', scope: 'user' }, + ]); }); it('reports marketplace-only partial state as managed but not installed', async () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner - .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); + stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubPluginList(commandRunner, []); const status = await installer.status(context); @@ -367,8 +545,8 @@ describe('ClaudeInstaller.status', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); - commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); + stubMarketplaceList(commandRunner, []); + stubPluginList(commandRunner, []); const status = await installer.status(context); @@ -387,3 +565,13 @@ describe('ClaudeInstaller.status', () => { expect(readErrorLogs(logs).some((r) => r.msg.includes('exited 2'))).toBe(true); }); }); + +function stubPluginList(commandRunner: FakeCommandRunner, plugins: readonly PluginFixture[]): void { + commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult(plugins)); +} + +function stubMarketplaceList(commandRunner: FakeCommandRunner, marketplaces: readonly MarketplaceFixture[]): void { + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult(marketplaces)); +} diff --git a/packages/cli/src/harnesses/ClaudeInstaller.ts b/packages/cli/src/harnesses/ClaudeInstaller.ts index 9deabe7..9c21400 100644 --- a/packages/cli/src/harnesses/ClaudeInstaller.ts +++ b/packages/cli/src/harnesses/ClaudeInstaller.ts @@ -7,7 +7,8 @@ import { getSupportedDescriptor } from './registry.ts'; import { INSTALL_SCOPES, type InstallScope, ScopedHarnessInstaller } from './ScopedHarnessInstaller.ts'; import type { SupportedHarnessDescriptor } from './types.ts'; -const PLUGIN_ID = 'cli@contextbridge'; +const PLUGIN_ID = 'planbridge@contextbridge'; +const LEGACY_PLUGIN_ID = 'cli@contextbridge'; const MARKETPLACE_SOURCE = 'contextbridge/claude-plugin'; const MARKETPLACE_NAME = 'contextbridge'; @@ -42,17 +43,26 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { if (await isMarketplaceConfigured(ctx, binaryName)) { managed.push({ kind: 'marketplace', identifier: MARKETPLACE_NAME }); } - const installedScopes = await getInstalledPluginScopes(ctx, binaryName, INSTALL_SCOPES); - for (const scope of installedScopes) { + const plugins = await listPlugins(ctx, binaryName); + const newScopes = filterPluginScopes(plugins, PLUGIN_ID, INSTALL_SCOPES); + const legacyScopes = filterPluginScopes(plugins, LEGACY_PLUGIN_ID, INSTALL_SCOPES); + for (const scope of newScopes) { managed.push({ kind: 'plugin', identifier: PLUGIN_ID, scope }); } - return { descriptor: this.descriptor, detected: true, installed: installedScopes.length > 0, managed }; + for (const scope of legacyScopes) { + managed.push({ kind: 'plugin', identifier: LEGACY_PLUGIN_ID, scope }); + } + return { descriptor: this.descriptor, detected: true, installed: newScopes.length > 0, managed }; } protected async runInstall(ctx: CliContext, scope: InstallScope): Promise { const { io } = ctx; const { binaryName } = this.descriptor; + const plugins = await listPlugins(ctx, binaryName); + const hasCurrentAtScope = hasPluginAtScope(plugins, PLUGIN_ID, scope); + const hasLegacyAtScope = hasPluginAtScope(plugins, LEGACY_PLUGIN_ID, scope); + await runPluginCommand(ctx, binaryName, 'marketplace add', [ 'marketplace', 'add', @@ -60,7 +70,16 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { '--scope', scope, ]); - await runPluginCommand(ctx, binaryName, 'install', ['install', PLUGIN_ID, '--scope', scope]); + if (hasCurrentAtScope) { + await runPluginCommand(ctx, binaryName, 'update', ['update', PLUGIN_ID, '--scope', scope]); + } else { + await runPluginCommand(ctx, binaryName, 'install', ['install', PLUGIN_ID, '--scope', scope]); + } + + if (hasLegacyAtScope) { + await runPluginCommand(ctx, binaryName, 'uninstall legacy', ['uninstall', LEGACY_PLUGIN_ID, '--scope', scope]); + io.stderr.write(`PlanBridge plugin renamed from ${LEGACY_PLUGIN_ID} to ${PLUGIN_ID} — migrated automatically.\n`); + } io.stderr.write(`✓ PlanBridge plugin installed for Claude Code (scope: ${scope}).\n`); io.stderr.write(`Restart Claude Code for the plugin to load.\n`); @@ -70,12 +89,18 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { const { io, logger } = ctx; const { binaryName } = this.descriptor; - if (await isPluginInstalledAtScope(ctx, binaryName, scope)) { + const plugins = await listPlugins(ctx, binaryName); + + if (hasPluginAtScope(plugins, PLUGIN_ID, scope)) { await runPluginCommand(ctx, binaryName, 'uninstall', ['uninstall', PLUGIN_ID, '--scope', scope]); } else { logger.info(`${PLUGIN_ID} is not installed at scope ${scope}; skipping plugin uninstall`); } + if (hasPluginAtScope(plugins, LEGACY_PLUGIN_ID, scope)) { + await runPluginCommand(ctx, binaryName, 'uninstall legacy', ['uninstall', LEGACY_PLUGIN_ID, '--scope', scope]); + } + if (await isMarketplaceConfigured(ctx, binaryName)) { await runPluginCommand(ctx, binaryName, 'marketplace remove', ['marketplace', 'remove', MARKETPLACE_NAME]); } else { @@ -86,18 +111,16 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { } } -async function isPluginInstalledAtScope(ctx: CliContext, binaryName: string, scope: string): Promise { - const scopes = await getInstalledPluginScopes(ctx, binaryName, [scope]); - return scopes.length > 0; +function hasPluginAtScope(plugins: readonly InstalledPlugin[], pluginId: string, scope: string): boolean { + return plugins.some((p) => p.id === pluginId && p.scope === scope); } -async function getInstalledPluginScopes( - ctx: CliContext, - binaryName: string, +function filterPluginScopes( + plugins: readonly InstalledPlugin[], + pluginId: string, scopes: readonly T[], -): Promise { - const plugins = await listPlugins(ctx, binaryName); - return scopes.filter((scope) => plugins.some((p) => p.id === PLUGIN_ID && p.scope === scope)); +): T[] { + return scopes.filter((scope) => hasPluginAtScope(plugins, pluginId, scope)); } async function isMarketplaceConfigured(ctx: CliContext, binaryName: string): Promise { From 28d9673f3d76eab9ca150543bc529482f1843baf Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Thu, 7 May 2026 16:54:19 -0600 Subject: [PATCH 3/4] feat: refresh harness integrations after update --- packages/cli/src/commands/update.test.ts | 204 +++++++++++++++++- packages/cli/src/commands/update.ts | 50 +++++ .../cli/src/harnesses/ClaudeInstaller.test.ts | 4 +- 3 files changed, 255 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts index 30d7501..18af292 100644 --- a/packages/cli/src/commands/update.test.ts +++ b/packages/cli/src/commands/update.test.ts @@ -1,8 +1,21 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'bun:test'; import { CommanderError } from 'commander'; -import { createStubContext, readErrorLogs, readWarnLogs } from '#src/testHelpers/index.ts'; +import { getDescriptor } from '#src/harnesses/registry.ts'; +import { + createStubContext, + marketplaceListResult, + pluginListResult, + readErrorLogs, + readWarnLogs, +} from '#src/testHelpers/index.ts'; import { runUpdate } from './update.ts'; +const CLAUDE_BINARY = getDescriptor('claude').binaryName; +const CODEX_BINARY = getDescriptor('codex').binaryName; + describe('runUpdate', () => { it('prints "up to date" and exits 0 when there is no notice', async () => { const { context, io } = createStubContext(); @@ -189,4 +202,193 @@ describe('runUpdate', () => { expect(updater.checkForUpdateCalls).toEqual([{ forceRefresh: true }]); expect(updater.performUpdateCallCount).toBe(1); }); + + it('refreshes Claude after a successful update when the plugin is wired up', async () => { + const { context, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + commandRunner.on(process.execPath, ['install', 'claude']).resolves(); + + await runUpdate(context); + + const spawnCall = commandRunner.calls.find((c) => c.cmd === process.execPath); + expect(spawnCall).toBeDefined(); + expect(spawnCall?.args).toEqual(['install', 'claude', '--scope', 'user']); + }); + + it('refreshes Claude after update when only the legacy cli@contextbridge plugin is installed', async () => { + const { context, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) + .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + commandRunner.on(process.execPath, ['install', 'claude']).resolves(); + + await runUpdate(context); + + const spawnCall = commandRunner.calls.find((c) => c.cmd === process.execPath); + expect(spawnCall).toBeDefined(); + expect(spawnCall?.args).toEqual(['install', 'claude', '--scope', 'user']); + }); + + it('refreshes Claude at project scope when the new plugin is installed at project scope', async () => { + const { context, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); + commandRunner.on(process.execPath, ['install', 'claude']).resolves(); + + await runUpdate(context); + + const spawnCall = commandRunner.calls.find((c) => c.cmd === process.execPath); + expect(spawnCall).toBeDefined(); + expect(spawnCall?.args).toEqual(['install', 'claude', '--scope', 'project']); + }); + + it('refreshes Claude at project scope when only the legacy plugin is installed at project scope', async () => { + const { context, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) + .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + commandRunner.on(process.execPath, ['install', 'claude']).resolves(); + + await runUpdate(context); + + const spawnCall = commandRunner.calls.find((c) => c.cmd === process.execPath); + expect(spawnCall).toBeDefined(); + expect(spawnCall?.args).toEqual(['install', 'claude', '--scope', 'project']); + }); + + it('refreshes Codex at project scope when the hook is installed at project scope', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'cb-update-codex-project-')); + const projectRoot = join(tmp, 'repo'); + const { context, commandRunner, updater } = createStubContext({ projectRoot }); + commandRunner.setWhich(CODEX_BINARY, '/usr/local/bin/codex'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + + try { + const configDir = join(projectRoot, '.codex'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'hooks.json'), + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'contextbridge hook codex' }] }] } }), + ); + writeFileSync(join(configDir, 'config.toml'), '[features]\ncodex_hooks = true\n'); + commandRunner.on(process.execPath, ['install', 'codex']).resolves(); + + await runUpdate(context); + + const spawnCall = commandRunner.calls.find((c) => c.cmd === process.execPath); + expect(spawnCall).toBeDefined(); + expect(spawnCall?.args).toEqual(['install', 'codex', '--scope', 'project']); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('skips refresh for harnesses with no PlanBridge state after update', async () => { + const { context, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); + + await runUpdate(context); + + const spawnCalls = commandRunner.calls.filter((c) => c.cmd === process.execPath); + expect(spawnCalls).toEqual([]); + }); + + it('skips refresh for Claude marketplace-only state after update', async () => { + const { context, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); + + await runUpdate(context); + + const spawnCalls = commandRunner.calls.filter((c) => c.cmd === process.execPath); + expect(spawnCalls).toEqual([]); + }); + + it('logs a warning when post-update refresh exits non-zero but still completes the update', async () => { + const { context, io, logs, commandRunner, updater } = createStubContext(); + commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); + updater.setCheckResult({ currentVersion: '0.1.0', latestVersion: '0.2.0', channel: 'stable' }); + updater.setPerformResult({ + status: 'executed', + command: ['brew', 'upgrade', '--cask', 'contextbridge/tap/cli'], + exitCode: 0, + }); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) + .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + commandRunner + .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) + .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + commandRunner.on(process.execPath, ['install', 'claude']).resolves({ exitCode: 1, stderr: 'install failed' }); + + await runUpdate(context); + + expect(io.stderr.text()).toContain('update complete'); + expect(readWarnLogs(logs).some((r) => r.msg.includes('post-update harness refresh failed'))).toBe(true); + }); }); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index bb27441..6ba3d75 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,6 +1,8 @@ import { GITHUB_REPO_URL } from '@contextbridge/shared/links'; import { type Command, CommanderError } from 'commander'; import type { CliContext } from '#src/context.ts'; +import type { ManagedEntry } from '#src/harnesses/HarnessInstaller.ts'; +import { ALL_INSTALLERS } from '#src/harnesses/installers.ts'; export async function runUpdate(ctx: CliContext): Promise { const { io, logger, updater } = ctx; @@ -33,6 +35,7 @@ export async function runUpdate(ctx: CliContext): Promise { return; case 'executed': + await refreshInstalledHarnesses(ctx); io.stderr.write('✓ update complete.\n'); return; @@ -57,6 +60,53 @@ export function registerUpdate(ctx: CliContext, program: Command): void { }); } +// After the binary has been swapped, re-run the new binary's per-harness install +// for any harness with existing PlanBridge state. Keeps installed plugins in +// sync with the binary across renames and hook-contract changes. Skips harnesses +// the user never wired up; never blocks update success on a refresh failure. +async function refreshInstalledHarnesses(ctx: CliContext): Promise { + const { commandRunner, logger } = ctx; + for (const installer of ALL_INSTALLERS) { + let refreshScopes: string[]; + try { + const status = await installer.status(ctx); + refreshScopes = getRefreshScopes(status.managed); + } catch (err) { + logger.warn({ err, harness: installer.descriptor.id }, 'post-update harness refresh failed'); + continue; + } + + for (const scope of refreshScopes) { + try { + const result = await commandRunner.run( + process.execPath, + ['install', installer.descriptor.id, '--scope', scope], + { stdio: 'inherit' }, + ); + if (result.exitCode !== 0) { + logger.warn( + { exitCode: result.exitCode, harness: installer.descriptor.id, scope }, + 'post-update harness refresh failed', + ); + } + } catch (err) { + logger.warn({ err, harness: installer.descriptor.id, scope }, 'post-update harness refresh failed'); + } + } + } +} + +function getRefreshScopes(managed: readonly ManagedEntry[]): string[] { + return [ + ...new Set( + managed.flatMap((entry) => { + if ((entry.kind !== 'plugin' && entry.kind !== 'hook') || !entry.scope) return []; + return [entry.scope]; + }), + ), + ]; +} + function assertNever(value: never): never { throw new Error(`unhandled PerformUpdateResult status: ${JSON.stringify(value)}`); } diff --git a/packages/cli/src/harnesses/ClaudeInstaller.test.ts b/packages/cli/src/harnesses/ClaudeInstaller.test.ts index 41a5889..92d8452 100644 --- a/packages/cli/src/harnesses/ClaudeInstaller.test.ts +++ b/packages/cli/src/harnesses/ClaudeInstaller.test.ts @@ -566,11 +566,11 @@ describe('ClaudeInstaller.status', () => { }); }); -function stubPluginList(commandRunner: FakeCommandRunner, plugins: readonly PluginFixture[]): void { +function stubPluginList(commandRunner: FakeCommandRunner, plugins: PluginFixture[]): void { commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult(plugins)); } -function stubMarketplaceList(commandRunner: FakeCommandRunner, marketplaces: readonly MarketplaceFixture[]): void { +function stubMarketplaceList(commandRunner: FakeCommandRunner, marketplaces: MarketplaceFixture[]): void { commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) .resolves(marketplaceListResult(marketplaces)); From bd01fce067fb13799811c55b0de1e6ab468bb32f Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Fri, 8 May 2026 09:47:52 -0600 Subject: [PATCH 4/4] refactor(cli): address PR #52 review feedback - drop the `ExitPlanMode` keyword from the Claude plugin manifest - export CLAUDE_PLUGIN_ID / CLAUDE_LEGACY_PLUGIN_ID / CLAUDE_MARKETPLACE_* from ClaudeInstaller and use them in tests instead of literals - split refreshInstalledHarnesses into helpers so the status-failure path returns [] rather than a let/reassign + continue --- .../claude/.claude-plugin/plugin.json | 2 +- packages/cli/src/cli.test.ts | 15 +- packages/cli/src/commands/install.test.ts | 27 ++-- .../cli/src/commands/installStatus.test.ts | 17 +-- packages/cli/src/commands/uninstall.test.ts | 17 +-- packages/cli/src/commands/update.test.ts | 23 +-- packages/cli/src/commands/update.ts | 53 +++---- .../cli/src/harnesses/ClaudeInstaller.test.ts | 132 +++++++++--------- packages/cli/src/harnesses/ClaudeInstaller.ts | 60 ++++---- 9 files changed, 188 insertions(+), 158 deletions(-) diff --git a/harnessIntegrations/claude/.claude-plugin/plugin.json b/harnessIntegrations/claude/.claude-plugin/plugin.json index 0f58fe3..64c2983 100644 --- a/harnessIntegrations/claude/.claude-plugin/plugin.json +++ b/harnessIntegrations/claude/.claude-plugin/plugin.json @@ -8,5 +8,5 @@ "homepage": "https://plan.contextbridge.ai", "repository": "https://github.com/contextbridge/planbridge", "license": "MIT", - "keywords": ["contextbridge", "planbridge", "planning", "review", "hooks", "ExitPlanMode"] + "keywords": ["contextbridge", "planbridge", "planning", "review", "hooks"] } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 1bff0e9..607e56d 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'bun:test'; import { Command } from 'commander'; +import { CLAUDE_MARKETPLACE_NAME, CLAUDE_PLUGIN_ID } from '#src/harnesses/ClaudeInstaller.ts'; import { getDescriptor } from '#src/harnesses/registry.ts'; import { environment } from '#src/testFactories.ts'; import { createStubContext, marketplaceListResult, pluginListResult, readErrorLogs } from '#src/testHelpers/index.ts'; @@ -154,10 +155,10 @@ describe('runCli', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'project' }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); @@ -166,7 +167,7 @@ describe('runCli', () => { expect(exitCode).toBe(0); const uninstallCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall']); expect(uninstallCalls).toHaveLength(1); - expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'project']); + expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', CLAUDE_PLUGIN_ID, '--scope', 'project']); }); it('routes argv into the no-target install orchestrator with --yes', async () => { @@ -189,10 +190,10 @@ describe('runCli', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); const exitCode = await runCli(context, ['install', 'status', '--json']); @@ -205,10 +206,10 @@ describe('runCli', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); const exitCode = await runCli(context, ['install', 'status', '--json']); diff --git a/packages/cli/src/commands/install.test.ts b/packages/cli/src/commands/install.test.ts index fa8754e..2404126 100644 --- a/packages/cli/src/commands/install.test.ts +++ b/packages/cli/src/commands/install.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'bun:test'; import { CommanderError } from 'commander'; +import { + CLAUDE_MARKETPLACE_NAME, + CLAUDE_MARKETPLACE_SOURCE, + CLAUDE_PLUGIN_ID, +} from '#src/harnesses/ClaudeInstaller.ts'; import { getDescriptor } from '#src/harnesses/registry.ts'; import { environment } from '#src/testFactories.ts'; import { createStubContext, marketplaceListResult, pluginListResult } from '#src/testHelpers/index.ts'; @@ -29,12 +34,12 @@ describe('runInstall', () => { 'plugin', 'marketplace', 'add', - 'contextbridge/claude-plugin', + CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user', ]); expect(pluginInstall).toHaveLength(1); - expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user']); + expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', CLAUDE_PLUGIN_ID, '--scope', 'user']); expect(prompter.calls).toEqual([]); const stderr = io.stderr.text(); expect(stderr).toContain('Claude Code: not installed'); @@ -46,10 +51,10 @@ describe('runInstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); await runInstall(context, { yes: true }); @@ -68,10 +73,10 @@ describe('runInstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves(); @@ -90,10 +95,10 @@ describe('runInstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'project' }])); await runInstall(context, { yes: true }); @@ -111,7 +116,7 @@ describe('runInstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); @@ -154,12 +159,12 @@ describe('runInstall', () => { 'plugin', 'marketplace', 'add', - 'contextbridge/claude-plugin', + CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user', ]); expect(pluginInstall).toHaveLength(1); - expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user']); + expect(pluginInstall[0]?.args).toEqual(['plugin', 'install', CLAUDE_PLUGIN_ID, '--scope', 'user']); const stderr = io.stderr.text(); expect(stderr).toContain('Codex CLI: status unavailable (invalid Codex hooks.json'); expect(stderr).toContain('Installed 1 of 2 detected harnesses.'); diff --git a/packages/cli/src/commands/installStatus.test.ts b/packages/cli/src/commands/installStatus.test.ts index 8d998df..45e4921 100644 --- a/packages/cli/src/commands/installStatus.test.ts +++ b/packages/cli/src/commands/installStatus.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test'; +import { CLAUDE_MARKETPLACE_NAME, CLAUDE_PLUGIN_ID } from '#src/harnesses/ClaudeInstaller.ts'; import { getDescriptor } from '#src/harnesses/registry.ts'; import { createStubContext, marketplaceListResult, parseStdoutJson, pluginListResult } from '#src/testHelpers/index.ts'; import { runInstallStatus } from './installStatus.ts'; @@ -11,10 +12,10 @@ describe('runInstallStatus', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); await runInstallStatus(context); @@ -41,7 +42,7 @@ describe('runInstallStatus', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); await runInstallStatus(context); @@ -57,7 +58,7 @@ describe('runInstallStatus', () => { commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'project' }])); await runInstallStatus(context); @@ -79,10 +80,10 @@ describe('runInstallStatus', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); await runInstallStatus(context, { json: true }); @@ -94,8 +95,8 @@ describe('runInstallStatus', () => { detected: true, installed: true, managed: [ - { kind: 'marketplace', identifier: 'contextbridge' }, - { kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'user' }, + { kind: 'marketplace', identifier: CLAUDE_MARKETPLACE_NAME }, + { kind: 'plugin', identifier: CLAUDE_PLUGIN_ID, scope: 'user' }, ], }); expect(payload[1]).toMatchObject({ diff --git a/packages/cli/src/commands/uninstall.test.ts b/packages/cli/src/commands/uninstall.test.ts index 6d76bd8..071eba5 100644 --- a/packages/cli/src/commands/uninstall.test.ts +++ b/packages/cli/src/commands/uninstall.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { CommanderError } from 'commander'; +import { CLAUDE_MARKETPLACE_NAME, CLAUDE_PLUGIN_ID } from '#src/harnesses/ClaudeInstaller.ts'; import { getDescriptor } from '#src/harnesses/registry.ts'; import { createStubContext, marketplaceListResult, pluginListResult } from '#src/testHelpers/index.ts'; import { runUninstall } from './uninstall.ts'; @@ -12,10 +13,10 @@ describe('runUninstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); @@ -24,9 +25,9 @@ describe('runUninstall', () => { const uninstallCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall']); const marketplaceRemoveCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']); expect(uninstallCalls).toHaveLength(1); - expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user']); + expect(uninstallCalls[0]?.args).toEqual(['plugin', 'uninstall', CLAUDE_PLUGIN_ID, '--scope', 'user']); expect(marketplaceRemoveCalls).toHaveLength(1); - expect(marketplaceRemoveCalls[0]?.args).toEqual(['plugin', 'marketplace', 'remove', 'contextbridge']); + expect(marketplaceRemoveCalls[0]?.args).toEqual(['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME]); expect(prompter.calls).toEqual([]); expect(io.stderr.text()).toContain('Uninstalled 1 of 1 detected harness'); }); @@ -51,7 +52,7 @@ describe('runUninstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); @@ -60,7 +61,7 @@ describe('runUninstall', () => { expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])).toEqual([]); const marketplaceRemoveCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']); expect(marketplaceRemoveCalls).toHaveLength(1); - expect(marketplaceRemoveCalls[0]?.args).toEqual(['plugin', 'marketplace', 'remove', 'contextbridge']); + expect(marketplaceRemoveCalls[0]?.args).toEqual(['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME]); expect(prompter.calls).toEqual([]); const stderr = io.stderr.text(); expect(stderr).toContain('Claude Code: not installed (marketplace contextbridge)'); @@ -87,10 +88,10 @@ describe('runUninstall', () => { commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); prompter.setConfirm(false); await runUninstall(context); diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts index 18af292..64a40f5 100644 --- a/packages/cli/src/commands/update.test.ts +++ b/packages/cli/src/commands/update.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'bun:test'; import { CommanderError } from 'commander'; +import { CLAUDE_LEGACY_PLUGIN_ID, CLAUDE_MARKETPLACE_NAME, CLAUDE_PLUGIN_ID } from '#src/harnesses/ClaudeInstaller.ts'; import { getDescriptor } from '#src/harnesses/registry.ts'; import { createStubContext, @@ -214,10 +215,10 @@ describe('runUpdate', () => { }); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); commandRunner.on(process.execPath, ['install', 'claude']).resolves(); await runUpdate(context); @@ -238,10 +239,10 @@ describe('runUpdate', () => { }); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }])); commandRunner.on(process.execPath, ['install', 'claude']).resolves(); await runUpdate(context); @@ -262,10 +263,10 @@ describe('runUpdate', () => { }); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'project' }])); commandRunner.on(process.execPath, ['install', 'claude']).resolves(); await runUpdate(context); @@ -286,10 +287,10 @@ describe('runUpdate', () => { }); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'cli@contextbridge', scope: 'project' }])); + .resolves(pluginListResult([{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'project' }])); commandRunner.on(process.execPath, ['install', 'claude']).resolves(); await runUpdate(context); @@ -360,7 +361,7 @@ describe('runUpdate', () => { }); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); await runUpdate(context); @@ -380,10 +381,10 @@ describe('runUpdate', () => { }); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']) - .resolves(marketplaceListResult([{ name: 'contextbridge' }])); + .resolves(marketplaceListResult([{ name: CLAUDE_MARKETPLACE_NAME }])); commandRunner .on(CLAUDE_BINARY, ['plugin', 'list', '--json']) - .resolves(pluginListResult([{ id: 'planbridge@contextbridge', scope: 'user' }])); + .resolves(pluginListResult([{ id: CLAUDE_PLUGIN_ID, scope: 'user' }])); commandRunner.on(process.execPath, ['install', 'claude']).resolves({ exitCode: 1, stderr: 'install failed' }); await runUpdate(context); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 6ba3d75..0eaf0ea 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,7 +1,7 @@ import { GITHUB_REPO_URL } from '@contextbridge/shared/links'; import { type Command, CommanderError } from 'commander'; import type { CliContext } from '#src/context.ts'; -import type { ManagedEntry } from '#src/harnesses/HarnessInstaller.ts'; +import type { HarnessInstaller, ManagedEntry } from '#src/harnesses/HarnessInstaller.ts'; import { ALL_INSTALLERS } from '#src/harnesses/installers.ts'; export async function runUpdate(ctx: CliContext): Promise { @@ -65,34 +65,37 @@ export function registerUpdate(ctx: CliContext, program: Command): void { // sync with the binary across renames and hook-contract changes. Skips harnesses // the user never wired up; never blocks update success on a refresh failure. async function refreshInstalledHarnesses(ctx: CliContext): Promise { - const { commandRunner, logger } = ctx; for (const installer of ALL_INSTALLERS) { - let refreshScopes: string[]; - try { - const status = await installer.status(ctx); - refreshScopes = getRefreshScopes(status.managed); - } catch (err) { - logger.warn({ err, harness: installer.descriptor.id }, 'post-update harness refresh failed'); - continue; + for (const scope of await getInstallerRefreshScopes(ctx, installer)) { + await refreshInstallerScope(ctx, installer, scope); } + } +} - for (const scope of refreshScopes) { - try { - const result = await commandRunner.run( - process.execPath, - ['install', installer.descriptor.id, '--scope', scope], - { stdio: 'inherit' }, - ); - if (result.exitCode !== 0) { - logger.warn( - { exitCode: result.exitCode, harness: installer.descriptor.id, scope }, - 'post-update harness refresh failed', - ); - } - } catch (err) { - logger.warn({ err, harness: installer.descriptor.id, scope }, 'post-update harness refresh failed'); - } +async function getInstallerRefreshScopes(ctx: CliContext, installer: HarnessInstaller): Promise { + try { + const status = await installer.status(ctx); + return getRefreshScopes(status.managed); + } catch (err) { + ctx.logger.warn({ err, harness: installer.descriptor.id }, 'post-update harness refresh failed'); + return []; + } +} + +async function refreshInstallerScope(ctx: CliContext, installer: HarnessInstaller, scope: string): Promise { + const { commandRunner, logger } = ctx; + try { + const result = await commandRunner.run(process.execPath, ['install', installer.descriptor.id, '--scope', scope], { + stdio: 'inherit', + }); + if (result.exitCode !== 0) { + logger.warn( + { exitCode: result.exitCode, harness: installer.descriptor.id, scope }, + 'post-update harness refresh failed', + ); } + } catch (err) { + logger.warn({ err, harness: installer.descriptor.id, scope }, 'post-update harness refresh failed'); } } diff --git a/packages/cli/src/harnesses/ClaudeInstaller.test.ts b/packages/cli/src/harnesses/ClaudeInstaller.test.ts index 92d8452..3c883b8 100644 --- a/packages/cli/src/harnesses/ClaudeInstaller.test.ts +++ b/packages/cli/src/harnesses/ClaudeInstaller.test.ts @@ -8,7 +8,13 @@ import { readErrorLogs, readLogs, } from '#src/testHelpers/index.ts'; -import { ClaudeInstaller } from './ClaudeInstaller.ts'; +import { + CLAUDE_LEGACY_PLUGIN_ID, + CLAUDE_MARKETPLACE_NAME, + CLAUDE_MARKETPLACE_SOURCE, + CLAUDE_PLUGIN_ID, + ClaudeInstaller, +} from './ClaudeInstaller.ts'; import { getDescriptor } from './registry.ts'; const CLAUDE_BINARY = getDescriptor('claude').binaryName; @@ -30,10 +36,10 @@ describe('ClaudeInstaller.install', () => { { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, - args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + args: ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, ]); const stderr = io.stderr.text(); @@ -47,7 +53,7 @@ describe('ClaudeInstaller.install', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves({ stdout: "Adding marketplace…✔ Marketplace 'contextbridge' already on disk — declared in user settings", }); @@ -61,10 +67,10 @@ describe('ClaudeInstaller.install', () => { { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, - args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + args: ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'update', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'update', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, ]); expect(io.stderr.text()).toContain('installed for Claude Code'); }); @@ -73,7 +79,7 @@ describe('ClaudeInstaller.install', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); @@ -84,11 +90,11 @@ describe('ClaudeInstaller.install', () => { { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, - args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + args: ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', CLAUDE_LEGACY_PLUGIN_ID, '--scope', 'user'], opts: {} }, ]); const stderr = io.stderr.text(); @@ -102,7 +108,7 @@ describe('ClaudeInstaller.install', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'project' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'project' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); @@ -112,10 +118,10 @@ describe('ClaudeInstaller.install', () => { { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, - args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + args: ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, ]); expect(io.stderr.text()).not.toContain('renamed from cli@contextbridge'); }); @@ -162,7 +168,7 @@ describe('ClaudeInstaller.install', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner .on(CLAUDE_BINARY, ['plugin', 'install']) @@ -173,10 +179,10 @@ describe('ClaudeInstaller.install', () => { { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, - args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + args: ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'install', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'install', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, ]); expect(readErrorLogs(logs).some((r) => r.msg.includes('plugin not found in marketplace'))).toBe(true); }); @@ -186,8 +192,8 @@ describe('ClaudeInstaller.install', () => { const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); stubPluginList(commandRunner, [ - { id: 'planbridge@contextbridge', scope: 'user' }, - { id: 'cli@contextbridge', scope: 'user' }, + { id: CLAUDE_PLUGIN_ID, scope: 'user' }, + { id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }, ]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves({ exitCode: 1, stderr: 'update failed' }); @@ -197,10 +203,10 @@ describe('ClaudeInstaller.install', () => { { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, - args: ['plugin', 'marketplace', 'add', 'contextbridge/claude-plugin', '--scope', 'user'], + args: ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE_SOURCE, '--scope', 'user'], opts: {}, }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'update', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'update', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, ]); expect(readErrorLogs(logs).some((r) => r.msg.includes('update failed'))).toBe(true); }); @@ -222,18 +228,18 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); await installer.uninstall(context, { yes: true }); expect(commandRunner.calls).toEqual([ { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME], opts: {} }, ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed from Claude Code (scope: user)'); expect(io.stdout.text()).toBe(''); @@ -243,8 +249,8 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'project' }]); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'project' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); await installer.uninstall(context, { yes: true }); @@ -252,7 +258,7 @@ describe('ClaudeInstaller.uninstall', () => { expect(commandRunner.calls).toEqual([ { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME], opts: {} }, ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed'); expect(readLogs(logs).some((r) => r.msg.includes('not installed at scope user'))).toBe(true); @@ -262,7 +268,7 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); stubMarketplaceList(commandRunner, []); @@ -270,7 +276,7 @@ describe('ClaudeInstaller.uninstall', () => { expect(commandRunner.calls).toEqual([ { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed'); @@ -281,18 +287,18 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, io, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); await installer.uninstall(context, { yes: true }); expect(commandRunner.calls).toEqual([ { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', CLAUDE_LEGACY_PLUGIN_ID, '--scope', 'user'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME], opts: {} }, ]); expect(io.stderr.text()).toContain('PlanBridge plugin removed'); expect(readLogs(logs).some((r) => r.msg.includes('not installed at scope user'))).toBe(true); @@ -303,21 +309,21 @@ describe('ClaudeInstaller.uninstall', () => { const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); stubPluginList(commandRunner, [ - { id: 'planbridge@contextbridge', scope: 'user' }, - { id: 'cli@contextbridge', scope: 'user' }, + { id: CLAUDE_PLUGIN_ID, scope: 'user' }, + { id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }, ]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); await installer.uninstall(context, { yes: true }); expect(commandRunner.calls).toEqual([ { cmd: CLAUDE_BINARY, args: ['plugin', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'planbridge@contextbridge', '--scope', 'user'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', 'cli@contextbridge', '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'uninstall', CLAUDE_LEGACY_PLUGIN_ID, '--scope', 'user'], opts: {} }, { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'list', '--json'], opts: {} }, - { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', 'contextbridge'], opts: {} }, + { cmd: CLAUDE_BINARY, args: ['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME], opts: {} }, ]); }); @@ -363,7 +369,7 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves({ exitCode: 1, stderr: 'disk full' }); expect(installer.uninstall(context, { yes: true })).rejects.toBeInstanceOf(CommanderError); @@ -375,9 +381,9 @@ describe('ClaudeInstaller.uninstall', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, logs } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); commandRunner .on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']) .resolves({ exitCode: 1, stderr: 'some other marketplace error' }); @@ -424,9 +430,9 @@ describe('ClaudeInstaller scope prompt', () => { const installer = new ClaudeInstaller(); const { context, commandRunner, prompter } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'project' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'project' }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); prompter.setSelect('project'); @@ -437,7 +443,7 @@ describe('ClaudeInstaller scope prompt', () => { expect(commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'uninstall'])[0]?.args).toEqual([ 'plugin', 'uninstall', - 'planbridge@contextbridge', + CLAUDE_PLUGIN_ID, '--scope', 'project', ]); @@ -462,16 +468,16 @@ describe('ClaudeInstaller.status', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'user' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); const status = await installer.status(context); expect(status.detected).toBe(true); expect(status.installed).toBe(true); expect(status.managed).toEqual([ - { kind: 'marketplace', identifier: 'contextbridge' }, - { kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'user' }, + { kind: 'marketplace', identifier: CLAUDE_MARKETPLACE_NAME }, + { kind: 'plugin', identifier: CLAUDE_PLUGIN_ID, scope: 'user' }, ]); }); @@ -480,29 +486,29 @@ describe('ClaudeInstaller.status', () => { const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); stubMarketplaceList(commandRunner, []); - stubPluginList(commandRunner, [{ id: 'planbridge@contextbridge', scope: 'project' }]); + stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'project' }]); const status = await installer.status(context); expect(status.detected).toBe(true); expect(status.installed).toBe(true); - expect(status.managed).toEqual([{ kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'project' }]); + expect(status.managed).toEqual([{ kind: 'plugin', identifier: CLAUDE_PLUGIN_ID, scope: 'project' }]); }); it('reports a legacy cli@contextbridge install as managed but not installed', async () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); - stubPluginList(commandRunner, [{ id: 'cli@contextbridge', scope: 'user' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); + stubPluginList(commandRunner, [{ id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }]); const status = await installer.status(context); expect(status.detected).toBe(true); expect(status.installed).toBe(false); expect(status.managed).toEqual([ - { kind: 'marketplace', identifier: 'contextbridge' }, - { kind: 'plugin', identifier: 'cli@contextbridge', scope: 'user' }, + { kind: 'marketplace', identifier: CLAUDE_MARKETPLACE_NAME }, + { kind: 'plugin', identifier: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }, ]); }); @@ -510,10 +516,10 @@ describe('ClaudeInstaller.status', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); stubPluginList(commandRunner, [ - { id: 'planbridge@contextbridge', scope: 'user' }, - { id: 'cli@contextbridge', scope: 'user' }, + { id: CLAUDE_PLUGIN_ID, scope: 'user' }, + { id: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }, ]); const status = await installer.status(context); @@ -521,9 +527,9 @@ describe('ClaudeInstaller.status', () => { expect(status.detected).toBe(true); expect(status.installed).toBe(true); expect(status.managed).toEqual([ - { kind: 'marketplace', identifier: 'contextbridge' }, - { kind: 'plugin', identifier: 'planbridge@contextbridge', scope: 'user' }, - { kind: 'plugin', identifier: 'cli@contextbridge', scope: 'user' }, + { kind: 'marketplace', identifier: CLAUDE_MARKETPLACE_NAME }, + { kind: 'plugin', identifier: CLAUDE_PLUGIN_ID, scope: 'user' }, + { kind: 'plugin', identifier: CLAUDE_LEGACY_PLUGIN_ID, scope: 'user' }, ]); }); @@ -531,14 +537,14 @@ describe('ClaudeInstaller.status', () => { const installer = new ClaudeInstaller(); const { context, commandRunner } = createStubContext(); commandRunner.setWhich(CLAUDE_BINARY, '/usr/local/bin/claude'); - stubMarketplaceList(commandRunner, [{ name: 'contextbridge' }]); + stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); stubPluginList(commandRunner, []); const status = await installer.status(context); expect(status.detected).toBe(true); expect(status.installed).toBe(false); - expect(status.managed).toEqual([{ kind: 'marketplace', identifier: 'contextbridge' }]); + expect(status.managed).toEqual([{ kind: 'marketplace', identifier: CLAUDE_MARKETPLACE_NAME }]); }); it('reports detected: true with no managed entries when claude is on PATH but PlanBridge is not installed', async () => { diff --git a/packages/cli/src/harnesses/ClaudeInstaller.ts b/packages/cli/src/harnesses/ClaudeInstaller.ts index 9c21400..ccc5eae 100644 --- a/packages/cli/src/harnesses/ClaudeInstaller.ts +++ b/packages/cli/src/harnesses/ClaudeInstaller.ts @@ -7,10 +7,10 @@ import { getSupportedDescriptor } from './registry.ts'; import { INSTALL_SCOPES, type InstallScope, ScopedHarnessInstaller } from './ScopedHarnessInstaller.ts'; import type { SupportedHarnessDescriptor } from './types.ts'; -const PLUGIN_ID = 'planbridge@contextbridge'; -const LEGACY_PLUGIN_ID = 'cli@contextbridge'; -const MARKETPLACE_SOURCE = 'contextbridge/claude-plugin'; -const MARKETPLACE_NAME = 'contextbridge'; +export const CLAUDE_PLUGIN_ID = 'planbridge@contextbridge'; +export const CLAUDE_LEGACY_PLUGIN_ID = 'cli@contextbridge'; +export const CLAUDE_MARKETPLACE_SOURCE = 'contextbridge/claude-plugin'; +export const CLAUDE_MARKETPLACE_NAME = 'contextbridge'; const InstalledPluginSchema = z.object({ id: z.string().trim().nonempty(), @@ -41,16 +41,16 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { } const managed: ManagedEntry[] = []; if (await isMarketplaceConfigured(ctx, binaryName)) { - managed.push({ kind: 'marketplace', identifier: MARKETPLACE_NAME }); + managed.push({ kind: 'marketplace', identifier: CLAUDE_MARKETPLACE_NAME }); } const plugins = await listPlugins(ctx, binaryName); - const newScopes = filterPluginScopes(plugins, PLUGIN_ID, INSTALL_SCOPES); - const legacyScopes = filterPluginScopes(plugins, LEGACY_PLUGIN_ID, INSTALL_SCOPES); + const newScopes = filterPluginScopes(plugins, CLAUDE_PLUGIN_ID, INSTALL_SCOPES); + const legacyScopes = filterPluginScopes(plugins, CLAUDE_LEGACY_PLUGIN_ID, INSTALL_SCOPES); for (const scope of newScopes) { - managed.push({ kind: 'plugin', identifier: PLUGIN_ID, scope }); + managed.push({ kind: 'plugin', identifier: CLAUDE_PLUGIN_ID, scope }); } for (const scope of legacyScopes) { - managed.push({ kind: 'plugin', identifier: LEGACY_PLUGIN_ID, scope }); + managed.push({ kind: 'plugin', identifier: CLAUDE_LEGACY_PLUGIN_ID, scope }); } return { descriptor: this.descriptor, detected: true, installed: newScopes.length > 0, managed }; } @@ -60,25 +60,32 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { const { binaryName } = this.descriptor; const plugins = await listPlugins(ctx, binaryName); - const hasCurrentAtScope = hasPluginAtScope(plugins, PLUGIN_ID, scope); - const hasLegacyAtScope = hasPluginAtScope(plugins, LEGACY_PLUGIN_ID, scope); + const hasCurrentAtScope = hasPluginAtScope(plugins, CLAUDE_PLUGIN_ID, scope); + const hasLegacyAtScope = hasPluginAtScope(plugins, CLAUDE_LEGACY_PLUGIN_ID, scope); await runPluginCommand(ctx, binaryName, 'marketplace add', [ 'marketplace', 'add', - MARKETPLACE_SOURCE, + CLAUDE_MARKETPLACE_SOURCE, '--scope', scope, ]); if (hasCurrentAtScope) { - await runPluginCommand(ctx, binaryName, 'update', ['update', PLUGIN_ID, '--scope', scope]); + await runPluginCommand(ctx, binaryName, 'update', ['update', CLAUDE_PLUGIN_ID, '--scope', scope]); } else { - await runPluginCommand(ctx, binaryName, 'install', ['install', PLUGIN_ID, '--scope', scope]); + await runPluginCommand(ctx, binaryName, 'install', ['install', CLAUDE_PLUGIN_ID, '--scope', scope]); } if (hasLegacyAtScope) { - await runPluginCommand(ctx, binaryName, 'uninstall legacy', ['uninstall', LEGACY_PLUGIN_ID, '--scope', scope]); - io.stderr.write(`PlanBridge plugin renamed from ${LEGACY_PLUGIN_ID} to ${PLUGIN_ID} — migrated automatically.\n`); + await runPluginCommand(ctx, binaryName, 'uninstall legacy', [ + 'uninstall', + CLAUDE_LEGACY_PLUGIN_ID, + '--scope', + scope, + ]); + io.stderr.write( + `PlanBridge plugin renamed from ${CLAUDE_LEGACY_PLUGIN_ID} to ${CLAUDE_PLUGIN_ID} — migrated automatically.\n`, + ); } io.stderr.write(`✓ PlanBridge plugin installed for Claude Code (scope: ${scope}).\n`); @@ -91,20 +98,25 @@ export class ClaudeInstaller extends ScopedHarnessInstaller { const plugins = await listPlugins(ctx, binaryName); - if (hasPluginAtScope(plugins, PLUGIN_ID, scope)) { - await runPluginCommand(ctx, binaryName, 'uninstall', ['uninstall', PLUGIN_ID, '--scope', scope]); + if (hasPluginAtScope(plugins, CLAUDE_PLUGIN_ID, scope)) { + await runPluginCommand(ctx, binaryName, 'uninstall', ['uninstall', CLAUDE_PLUGIN_ID, '--scope', scope]); } else { - logger.info(`${PLUGIN_ID} is not installed at scope ${scope}; skipping plugin uninstall`); + logger.info(`${CLAUDE_PLUGIN_ID} is not installed at scope ${scope}; skipping plugin uninstall`); } - if (hasPluginAtScope(plugins, LEGACY_PLUGIN_ID, scope)) { - await runPluginCommand(ctx, binaryName, 'uninstall legacy', ['uninstall', LEGACY_PLUGIN_ID, '--scope', scope]); + if (hasPluginAtScope(plugins, CLAUDE_LEGACY_PLUGIN_ID, scope)) { + await runPluginCommand(ctx, binaryName, 'uninstall legacy', [ + 'uninstall', + CLAUDE_LEGACY_PLUGIN_ID, + '--scope', + scope, + ]); } if (await isMarketplaceConfigured(ctx, binaryName)) { - await runPluginCommand(ctx, binaryName, 'marketplace remove', ['marketplace', 'remove', MARKETPLACE_NAME]); + await runPluginCommand(ctx, binaryName, 'marketplace remove', ['marketplace', 'remove', CLAUDE_MARKETPLACE_NAME]); } else { - logger.info(`${MARKETPLACE_NAME} marketplace is not configured; skipping marketplace remove`); + logger.info(`${CLAUDE_MARKETPLACE_NAME} marketplace is not configured; skipping marketplace remove`); } io.stderr.write(`✓ PlanBridge plugin removed from Claude Code (scope: ${scope}).\n`); @@ -125,7 +137,7 @@ function filterPluginScopes( async function isMarketplaceConfigured(ctx: CliContext, binaryName: string): Promise { const marketplaces = await listMarketplaces(ctx, binaryName); - return marketplaces.some((m) => m.name === MARKETPLACE_NAME); + return marketplaces.some((m) => m.name === CLAUDE_MARKETPLACE_NAME); } async function listPlugins(ctx: CliContext, binaryName: string): Promise {