diff --git a/packages/installer/src/__tests__/harnesses.test.ts b/packages/installer/src/__tests__/harnesses.test.ts index f80ab25c..4639bacf 100644 --- a/packages/installer/src/__tests__/harnesses.test.ts +++ b/packages/installer/src/__tests__/harnesses.test.ts @@ -141,6 +141,27 @@ describe("claude harness", () => { expect(system.run).not.toHaveBeenCalledWith(expect.stringContaining("marketplace remove")); }); + it("removes the copy installed from our own marketplace", async () => { + const system = fakeSystem({ + run: (cmd) => (isList(cmd) ? claudeList(["sentry@sentry-plugin-marketplace"]) : ok), + }); + const removed = await createClaude(system).cleanup!(); + + expect(system.run).toHaveBeenCalledWith( + "claude plugin uninstall sentry@sentry-plugin-marketplace", + ); + expect(removed).toContain("sentry@sentry-plugin-marketplace"); + }); + + it("leaves cleanup a no-op when only the official plugin is installed", async () => { + const system = fakeSystem({ + run: (cmd) => (isList(cmd) ? claudeList(["sentry@claude-plugins-official"]) : ok), + }); + + expect(await createClaude(system).cleanup!()).toBeNull(); + expect(system.run).not.toHaveBeenCalledWith(expect.stringContaining("uninstall")); + }); + it("surfaces stderr when install fails", async () => { const harness = createClaude( fakeSystem({ run: () => ({ ok: false, stderr: "boom", message: "exit 1" }) }), diff --git a/packages/installer/src/harnesses/claude.ts b/packages/installer/src/harnesses/claude.ts index 0bb01b9d..c50b9e9f 100644 --- a/packages/installer/src/harnesses/claude.ts +++ b/packages/installer/src/harnesses/claude.ts @@ -9,6 +9,13 @@ const INSTALL_COMMAND = `claude plugin install ${PLUGIN_ID}`; const UPDATE_COMMAND = `claude plugin update ${PLUGIN_ID}`; const UNINSTALL_COMMAND = `claude plugin uninstall ${PLUGIN_ID}`; +// Our plugin reaches Claude two ways: Anthropic's official catalog above, which is +// what the installer uses, and our own catalog under the marketplace name it +// declares. Both resolve to the same repository, so a machine carrying both runs +// two plugins serving the same skills. +const OUR_MARKETPLACE = "sentry-plugin-marketplace"; +const OUR_PLUGIN_ID = `sentry@${OUR_MARKETPLACE}`; + // `claude plugin list --json` emits an array of installed plugins. We only care // about the marketplace-qualified id of each entry. interface ClaudePlugin { @@ -21,9 +28,9 @@ interface ClaudeMarketplace { name?: string; } -async function isSentryInstalled(system: SystemDeps): Promise { +async function hasPlugin(system: SystemDeps, pluginId: string): Promise { const plugins = await runJson(system, "claude plugin list --json"); - return Array.isArray(plugins) && plugins.some((plugin) => plugin.id === PLUGIN_ID); + return Array.isArray(plugins) && plugins.some((plugin) => plugin.id === pluginId); } async function isMarketplaceRegistered(system: SystemDeps): Promise { @@ -52,10 +59,19 @@ export function createClaude(system: SystemDeps): Harness { detect: async () => detectOnPath(system, "claude"), - isInstalled: async () => isSentryInstalled(system), + isInstalled: async () => hasPlugin(system, PLUGIN_ID), canInstall: async () => ({ ok: true }), + cleanup: async (output) => { + if (!(await hasPlugin(system, OUR_PLUGIN_ID))) { + return null; + } + + await runCommand(system, `claude plugin uninstall ${OUR_PLUGIN_ID}`, output); + return `Removed conflicting plugin ${OUR_PLUGIN_ID}`; + }, + install: async (output): Promise => { await ensureMarketplace(system, output); await runCommand(system, INSTALL_COMMAND, output);