fix: refresh Claude marketplace cache during install#62
Conversation
…s with targeted assertions Most install/uninstall tests previously asserted the full `commandRunner.calls` chain via `.toEqual([...])` even when the test only cared about one or two specific calls (e.g., "did the legacy uninstall fire?"). That made every shellout addition to runInstall/runUninstall require fixture updates in 13 unrelated tests. Convert the over-specified tests to targeted `callsTo(...)` checks against the specific behavior each test is exercising. Keep one canonical full-sequence `.toEqual` per scenario family — clean install, migration, clean uninstall — to lock down ordering invariants in one place per family. No source change; the existing 269 tests still pass.
…llouts + setupTest Extract three reusable test helpers in `testHelpers/claudeInstallerFakes.ts`: - `primeClaudeShellouts(commandRunner)` — pre-stubs the canonical happy path for every `claude plugin` subcommand the installer can fire (claude on PATH, empty plugin/marketplace lists, all write commands resolve cleanly). - `stubPluginList` / `stubMarketplaceList` — promoted out of `ClaudeInstaller.test.ts` so `install.test.ts` and `cli.test.ts` can reuse them. Each test file now wraps these with a local `setupTest` that returns a primed installer + context in one line. Tests then specify only what's *different* from the happy path, instead of repeating the full setup boilerplate. Adjacent infrastructure change: flip `FakeCommandRunner` matcher resolution from first-match-wins to last-match-wins so that overrides registered after `primeClaudeShellouts` actually take effect — matches how Jest/vitest/sinon handle override semantics. Added a regression test covering the override case; renamed the existing matcher-order test. No source changes; all 270 tests still pass.
`claude plugin marketplace add` short-circuits when the marketplace is already configured locally and skips refreshing the cached catalog. For existing users upgrading across the `cli@contextbridge` → `planbridge@contextbridge` rename, the cached catalog still listed the old plugin id, so the migration in `runInstall` failed with `Plugin "planbridge" not found in marketplace "contextbridge"`. Run `claude plugin marketplace update contextbridge` after `marketplace add` on every install. On a fresh install it's a near-no-op `git pull`; on the bug case it does the actual refresh that unblocks the install. The post-update refresh path in `runUpdate` benefits automatically by re-entering `runInstall`. Strengthen the existing "already on disk" test in `ClaudeInstaller.test.ts` with an assertion that `marketplace update` fired — that's the regression test for this issue. Closes #61.
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'list', '--json']).resolves(marketplaceListResult([])); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'list', '--json']).resolves(pluginListResult([])); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); |
There was a problem hiding this comment.
The amount of boilerplate / churn on these commands was too much. I split out some common helpers to make it cleaner. What's nice is that you can easily see in each test "what deviates from the boilerplate baseline". As noted in the PR description, I recommend reviewing commit-by-commit.
| '--scope', | ||
| scope, | ||
| ]); | ||
| await runPluginCommand(ctx, binaryName, 'marketplace update', ['marketplace', 'update', CLAUDE_MARKETPLACE_NAME]); |
There was a problem hiding this comment.
This is the real fix to the bug.
| }, | ||
| { cmd: CLAUDE_BINARY, args: ['plugin', 'update', CLAUDE_PLUGIN_ID, '--scope', 'user'], opts: {} }, | ||
| ]); | ||
| const updateCalls = commandRunner.callsTo(CLAUDE_BINARY, ['plugin', 'update']); |
| stubMarketplaceList(commandRunner, [{ name: CLAUDE_MARKETPLACE_NAME }]); | ||
| stubPluginList(commandRunner, [{ id: CLAUDE_PLUGIN_ID, scope: 'user' }]); |
There was a problem hiding this comment.
Since plugins conceptually belong in a marketplace, it seems like we'd want a single test helper that allows us to say which plugins live in which marketplace ?
There was a problem hiding this comment.
Done in 526ea4a — stubPluginList and stubMarketplaceList are gone, replaced by stubClaudeState({ marketplaces: [{ name, plugins: [...] }] }) which encodes which plugins live in which marketplace.
| export function primeClaudeShellouts(commandRunner: FakeCommandRunner): void { | ||
| 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([])); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'add']).resolves(); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'install']).resolves(); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'update']).resolves(); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'uninstall']).resolves(); | ||
| commandRunner.on(CLAUDE_BINARY, ['plugin', 'marketplace', 'remove']).resolves(); | ||
| } |
There was a problem hiding this comment.
Consider maybe doing FakeCommandRunner.default() or something similar.
There was a problem hiding this comment.
Going to leave this one. FakeCommandRunner is a generic fake — primeClaudeShellouts is Claude-specific (uses getDescriptor('claude').binaryName and primes the seven claude plugin shellouts).
Folding it into the runner as .default() / .primedForClaude() either couples a generic class to a specific consumer — and we'd want .primedForCodex() etc. as harnesses grow — or it's a no-value rename of new FakeCommandRunner(). The standalone helper next to its Claude-specific siblings in claudeInstallerFakes.ts reads better at the call sites IMO. Happy to revisit if you had a different shape in mind.
Replace stubPluginList + stubMarketplaceList with a single stubClaudeState helper whose fixture nests plugins under their marketplace, addressing PR #62 review feedback. Tests that deliberately exercise plugin-list-only code paths use the unmanagedPlugins escape hatch since `claude plugin list --json` returns id+scope only — no marketplace association on the wire.
🤖 I have created a release *beep* *boop* --- ## [0.3.0](v0.2.0...v0.3.0) (2026-05-08) ### ⚠ BREAKING CHANGES * rename Claude plugin to planbridge@contextbridge; refresh plugins on update ([#52](#52)) ### Features * add automatic release changelog with release-please ([#21](#21)) ([45a1bf1](45a1bf1)) * rename Claude plugin to planbridge@contextbridge; refresh plugins on update ([#52](#52)) ([2794ae6](2794ae6)) * **ui:** add GitHub link to header help menu ([#19](#19)) ([c36289b](c36289b)) ### Bug Fixes * emit plan review analytics from shared runner ([#48](#48)) ([5c769ff](5c769ff)) * **plan:** pre-scan src for transitive deps to stop vitest reload flake ([3b3cc0c](3b3cc0c)), closes [#12](#12) * **plan:** stop vitest reload flake from lazy zod optimization ([#15](#15)) ([3b3cc0c](3b3cc0c)) * refresh Claude marketplace cache during install ([#62](#62)) ([58be09a](58be09a)) * resolve contextbridge via PATH for post-update refresh ([#64](#64)) ([f658af4](f658af4)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: contextbridge-pr-automation[bot] <259134118+contextbridge-pr-automation[bot]@users.noreply.github.com> Co-authored-by: Ben Limmer <ben@benlimmer.com>
Summary
contextbridge install claudefailed for users upgrading across thecli@contextbridge→planbridge@contextbridgerename because Claude's locally cached marketplace catalog still listed the old plugin id. Runclaude plugin marketplace update contextbridgeaftermarketplace addso the cached catalog always reflects upstream — closes #61.Review focus
Recommend reviewing commit-by-commit. The first two commits are pure test-side refactors (no source change); the third is the one-line fix. Splitting them keeps the fix obvious to read and the refactors independently revertable.
commandRunner.callschain via.toEqualeven when they only cared about one or two specific calls. That made every shellout addition require fixture updates in 13 unrelated tests. Converted to targetedcallsTo(...)checks; kept one canonical full-sequence test per scenario family (clean install, migration, clean uninstall) so ordering invariants stay locked down in one place.primeClaudeShellouts+ promotedstubPluginList/stubMarketplaceListto shared helpers. Each test file now wraps these with a localsetupTest. Adjacent infrastructure change: flippedFakeCommandRunnerfrom first-match-wins to last-match-wins so overrides registered afterprimeClaudeShelloutsactually take effect — matches Jest/vitest/sinon override semantics. Added a regression test for the override path; renamed the existing matcher-order test.runInstall, 1 default stub inprimeClaudeShellouts, 3 fixture line additions. Run unconditionally — on a fresh install the second shellout is a near-no-opgit pull; on the bug case it does the actual refresh. The post-update refresh path inrunUpdatebenefits automatically by re-enteringrunInstall.The fix-only commit is what reviewers should weigh-in on most carefully; the refactors are mechanical and have green tests with no source change.
Commits
ec22228— replace over-specifiedcommandRunner.calls.toEqual([...])chains with targetedcallsTo(...)assertions acrossClaudeInstaller.test.ts. Net -33 lines, no source change.cf12c48— centralize Claude test setup withprimeClaudeShelloutsand per-filesetupTesthelpers; flipFakeCommandRunnerto last-match-wins for clean override semantics. Net -92 lines, no source change.7abf4f7— addclaude plugin marketplace update contextbridgeaftermarketplace addinClaudeInstaller.runInstall; strengthen the existing "already on disk" test as the regression test for bug: contextbridge install claude fails on stale marketplace cache after plugin rename #61.