diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts index 1aa2e3b2..ce2ee4c9 100644 --- a/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts @@ -2,10 +2,12 @@ import { ClaudeCodeMCPClient } from '../claude-code'; jest.mock('child_process', () => ({ execSync: jest.fn(), + spawnSync: jest.fn(), })); jest.mock('fs', () => ({ existsSync: jest.fn().mockReturnValue(false), + rmSync: jest.fn(), })); jest.mock('../../../../utils/analytics', () => ({ @@ -17,9 +19,11 @@ jest.mock('../../../../utils/debug', () => ({ })); describe('ClaudeCodeMCPClient — plugin methods', () => { - const { execSync } = require('child_process'); + const { execSync, spawnSync } = require('child_process'); + const fs = require('fs'); const { analytics } = require('../../../../utils/analytics'); const execSyncMock = execSync as jest.Mock; + const spawnSyncMock = spawnSync as jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -28,6 +32,8 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { if (cmd === 'command -v claude') return Buffer.from(''); return Buffer.from(''); }); + // Default: marketplace add succeeds + spawnSyncMock.mockReturnValue({ status: 0, stderr: '' }); }); describe('supportsPlugin', () => { @@ -79,12 +85,96 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { }); describe('installPlugin', () => { + it('registers the PostHog marketplace before installing the plugin', async () => { + execSyncMock.mockImplementation(() => Buffer.from('')); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'claude', + ['plugin', 'marketplace', 'add', 'PostHog/ai-plugin'], + { encoding: 'utf-8' }, + ); + expect(execSyncMock).toHaveBeenCalledWith( + 'claude plugin install posthog', + { stdio: 'pipe' }, + ); + }); + it('returns success on exit 0', async () => { execSyncMock.mockImplementation(() => Buffer.from('')); const client = new ClaudeCodeMCPClient(); await expect(client.installPlugin()).resolves.toEqual({ success: true }); }); + it('continues to install when marketplace add reports already added', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stderr: "marketplace 'posthog' already added", + }); + execSyncMock.mockImplementation(() => Buffer.from('')); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + expect(execSyncMock).toHaveBeenCalledWith( + 'claude plugin install posthog', + { stdio: 'pipe' }, + ); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + + it('clears stale cache and retries when marketplace was already added from a different source', async () => { + spawnSyncMock + .mockReturnValueOnce({ + status: 1, + stderr: + "Error: marketplace 'posthog' is already added from a different source", + }) + .mockReturnValueOnce({ status: 0, stderr: '' }); + execSyncMock.mockImplementation(() => Buffer.from('')); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('marketplaces/posthog'), + { recursive: true, force: true }, + ); + expect(spawnSyncMock).toHaveBeenCalledTimes(2); + }); + + it('surfaces a clearer message when the marketplace config file is corrupted', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stderr: + 'Marketplace configuration file is corrupted: local.source.source: Invalid input', + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: false }); + expect(analytics.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('marketplace config is corrupted'), + }), + ); + // Install must not run if the marketplace could not be registered + expect(execSyncMock).not.toHaveBeenCalledWith( + expect.stringContaining('plugin install'), + expect.anything(), + ); + }); + + it('captures a marketplace add failure when stderr is some other error', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stderr: 'network unreachable', + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: false }); + expect(analytics.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'Claude Code marketplace add failed', + ), + }), + ); + }); + it('returns success with alreadyInstalled when stderr contains "already installed"', async () => { execSyncMock.mockImplementation((cmd: string) => { if (String(cmd).includes('plugin install')) { diff --git a/src/steps/add-mcp-server-to-clients/clients/claude-code.ts b/src/steps/add-mcp-server-to-clients/clients/claude-code.ts index 19c88b92..f2be6027 100644 --- a/src/steps/add-mcp-server-to-clients/clients/claude-code.ts +++ b/src/steps/add-mcp-server-to-clients/clients/claude-code.ts @@ -2,7 +2,7 @@ import { DefaultMCPClient } from '../MCPClient'; import { DefaultMCPClientConfig } from '../defaults'; import { PluginCapable, PluginInstallResult } from '../plugin-client'; import { z } from 'zod'; -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; import { analytics } from '../../../utils/analytics'; import { debug } from '../../../utils/debug'; import * as os from 'os'; @@ -143,6 +143,59 @@ export class ClaudeCodeMCPClient installPlugin(): Promise { const binary = this.findClaudeBinary(); if (!binary) return Promise.resolve({ success: false }); + + // `plugin install posthog` fails with `not found in any configured + // marketplace` unless the PostHog marketplace is registered first. + const runMarketplaceAdd = () => + spawnSync(binary, ['plugin', 'marketplace', 'add', 'PostHog/ai-plugin'], { + encoding: 'utf-8', + }); + + let marketplaceResult = runMarketplaceAdd(); + + // Stale cache directory with no marketplace entry — clear it and retry + if ( + marketplaceResult.status !== 0 && + (marketplaceResult.stderr ?? '').includes( + 'already added from a different source', + ) + ) { + const staleDir = path.join( + os.homedir(), + '.claude', + 'plugins', + 'marketplaces', + 'posthog', + ); + try { + fs.rmSync(staleDir, { recursive: true, force: true }); + } catch { + // ignore — retry anyway + } + marketplaceResult = runMarketplaceAdd(); + } + + if (marketplaceResult.status !== 0) { + const stderr = marketplaceResult.stderr ?? ''; + const alreadyAdded = + stderr.includes('already added') || stderr.includes('already exists'); + + if (!alreadyAdded) { + if (stderr.includes('Marketplace configuration file is corrupted')) { + analytics.captureException( + new Error( + `Claude Code marketplace config is corrupted — user must repair ~/.claude/plugins/ config before retrying: ${stderr}`, + ), + ); + } else { + analytics.captureException( + new Error(`Claude Code marketplace add failed: ${stderr}`), + ); + } + return Promise.resolve({ success: false }); + } + } + try { execSync(`${binary} plugin install posthog`, { stdio: 'pipe' }); return Promise.resolve({ success: true });