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..8f05fc42 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 @@ -137,4 +137,110 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { await expect(client.installPlugin()).resolves.toEqual({ success: false }); }); }); + + describe('uninstallPlugin', () => { + it('returns success on exit 0 and shells out to plugin uninstall', async () => { + const calls: string[] = []; + execSyncMock.mockImplementation((cmd: string) => { + calls.push(String(cmd)); + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: true, + }); + expect(calls.some((c) => c.includes('plugin uninstall posthog'))).toBe( + true, + ); + }); + + it('treats "not installed" stderr as success', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('plugin uninstall')) { + throw new Error('Error: posthog is not installed'); + } + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: true, + }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + + it('treats "not found" stderr as success', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('plugin uninstall')) { + throw new Error('plugin posthog not found'); + } + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: true, + }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + + it('treats "does not exist" stderr as success', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('plugin uninstall')) { + throw new Error('plugin "posthog" does not exist'); + } + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: true, + }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + + it('returns failure and captures exception on unexpected error', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('plugin uninstall')) { + throw new Error('network timeout'); + } + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: false, + }); + expect(analytics.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('network timeout'), + }), + ); + }); + + it('returns failure when no binary is found', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('not found'); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: false, + }); + }); + }); + + describe('removeServer', () => { + it('delegates to uninstallPlugin — returns success on exit 0', async () => { + execSyncMock.mockImplementation(() => Buffer.from('')); + const client = new ClaudeCodeMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: true }); + }); + + it('delegates to uninstallPlugin — returns failure on unexpected error', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('plugin uninstall')) { + throw new Error('network timeout'); + } + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: false }); + }); + }); }); diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts index 543bfc2f..49f18d88 100644 --- a/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts @@ -101,23 +101,33 @@ describe('CodexMCPClient', () => { }); describe('removeServer', () => { - it('invokes the resolved binary with mcp remove and returns success', async () => { - spawnSyncMock.mockReturnValue({ status: 0 }); + it('delegates to uninstallPlugin — invokes plugin marketplace remove and returns success', async () => { + spawnSyncMock.mockReturnValue({ status: 0, stderr: '' }); const client = new CodexMCPClient(); await expect(client.removeServer()).resolves.toEqual({ success: true }); expect(spawnSyncMock).toHaveBeenCalledWith( CODEX_PATH, - ['mcp', 'remove', 'posthog'], - { stdio: 'ignore' }, + ['plugin', 'marketplace', 'remove', 'PostHog/ai-plugin'], + { encoding: 'utf-8' }, ); }); - it('returns false and captures exception on failure', async () => { - spawnSyncMock.mockReturnValue({ status: 1 }); + it('returns false and captures exception on unexpected failure', async () => { + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'network timeout' }); const client = new CodexMCPClient(); await expect(client.removeServer()).resolves.toEqual({ success: false }); expect(analytics.captureException).toHaveBeenCalled(); }); + + it('treats "not installed" stderr as success', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stderr: "Error: marketplace 'posthog' is not installed", + }); + const client = new CodexMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: true }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); }); describe('supportsPlugin', () => { @@ -176,4 +186,56 @@ describe('CodexMCPClient', () => { ); }); }); + + describe('uninstallPlugin', () => { + it('returns success on exit 0 using resolved binary path', async () => { + spawnSyncMock.mockReturnValue({ status: 0, stderr: '' }); + const client = new CodexMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: true, + }); + expect(spawnSyncMock).toHaveBeenCalledWith( + CODEX_PATH, + ['plugin', 'marketplace', 'remove', 'PostHog/ai-plugin'], + { encoding: 'utf-8' }, + ); + }); + + it.each([ + ['not installed', "marketplace 'posthog' is not installed"], + ['not found', 'marketplace not found'], + ['does not exist', "marketplace 'posthog' does not exist"], + ['no such marketplace', 'no such marketplace: posthog'], + ])('treats "%s" stderr as success', async (_label, stderr) => { + spawnSyncMock.mockReturnValue({ status: 1, stderr }); + const client = new CodexMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: true, + }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + + it('returns failure and captures exception on unexpected stderr', async () => { + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'network timeout' }); + const client = new CodexMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: false, + }); + expect(analytics.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('network timeout'), + }), + ); + }); + + it('returns failure when no binary is found', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('not found'); + }); + const client = new CodexMCPClient(); + await expect(client.uninstallPlugin()).resolves.toEqual({ + success: false, + }); + }); + }); }); 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..7fd4d29f 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 @@ -98,29 +98,8 @@ export class ClaudeCodeMCPClient return { success: result.success }; } - removeServer(local?: boolean): Promise<{ success: boolean }> { - const claudeBinary = this.findClaudeBinary(); - if (!claudeBinary) { - return Promise.resolve({ success: false }); - } - - const serverName = local ? 'posthog-local' : 'posthog'; - const command = `${claudeBinary} mcp remove --scope user ${serverName}`; - - try { - execSync(command); - } catch (error) { - analytics.captureException( - new Error( - `Failed to remove server from Claude Code: ${ - error instanceof Error ? error.message : String(error) - }`, - ), - ); - return Promise.resolve({ success: false }); - } - - return Promise.resolve({ success: true }); + async removeServer(_local?: boolean): Promise<{ success: boolean }> { + return this.uninstallPlugin(); } supportsPlugin(): boolean { @@ -157,4 +136,29 @@ export class ClaudeCodeMCPClient return Promise.resolve({ success: false }); } } + + uninstallPlugin(): Promise<{ success: boolean }> { + const binary = this.findClaudeBinary(); + if (!binary) return Promise.resolve({ success: false }); + try { + execSync(`${binary} plugin uninstall posthog`, { stdio: 'pipe' }); + return Promise.resolve({ success: true }); + } catch (error) { + const msg = ( + error instanceof Error ? error.message : String(error) + ).toLowerCase(); + if ( + msg.includes('not installed') || + msg.includes('not found') || + msg.includes('does not exist') || + msg.includes('no such plugin') + ) { + return Promise.resolve({ success: true }); + } + analytics.captureException( + new Error(`Claude Code plugin uninstall failed: ${msg}`), + ); + return Promise.resolve({ success: false }); + } + } } diff --git a/src/steps/add-mcp-server-to-clients/clients/codex.ts b/src/steps/add-mcp-server-to-clients/clients/codex.ts index 937e75b1..46e75e54 100644 --- a/src/steps/add-mcp-server-to-clients/clients/codex.ts +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -55,22 +55,8 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { return { success: result.success }; } - removeServer(): Promise<{ success: boolean }> { - const binary = this.findCodexBinary(); - if (!binary) return Promise.resolve({ success: false }); - - const result = spawnSync(binary, ['mcp', 'remove', 'posthog'], { - stdio: 'ignore', - }); - - if (result.error || result.status !== 0) { - analytics.captureException( - new Error('Failed to remove server from Codex CLI.'), - ); - return Promise.resolve({ success: false }); - } - - return Promise.resolve({ success: true }); + async removeServer(): Promise<{ success: boolean }> { + return this.uninstallPlugin(); } supportsPlugin(): boolean { @@ -130,6 +116,34 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { return Promise.resolve({ success: true }); } + + uninstallPlugin(): Promise<{ success: boolean }> { + const binary = this.findCodexBinary(); + if (!binary) return Promise.resolve({ success: false }); + + const result = spawnSync( + binary, + ['plugin', 'marketplace', 'remove', 'PostHog/ai-plugin'], + { encoding: 'utf-8' }, + ); + + if (result.status === 0) return Promise.resolve({ success: true }); + + const stderr = (result.stderr ?? '').toLowerCase(); + if ( + stderr.includes('not installed') || + stderr.includes('not found') || + stderr.includes('does not exist') || + stderr.includes('no such marketplace') + ) { + return Promise.resolve({ success: true }); + } + + analytics.captureException( + new Error(`Codex plugin uninstall failed: ${result.stderr ?? ''}`), + ); + return Promise.resolve({ success: false }); + } } export default CodexMCPClient; diff --git a/src/steps/add-mcp-server-to-clients/plugin-client.ts b/src/steps/add-mcp-server-to-clients/plugin-client.ts index 3d05bf7a..81b3995a 100644 --- a/src/steps/add-mcp-server-to-clients/plugin-client.ts +++ b/src/steps/add-mcp-server-to-clients/plugin-client.ts @@ -7,6 +7,7 @@ export interface PluginCapable { supportsPlugin(): boolean; isPluginInstalled(): Promise; installPlugin(): Promise; + uninstallPlugin(): Promise<{ success: boolean }>; } export function isPluginCapable(client: T): client is T & PluginCapable { @@ -14,6 +15,7 @@ export function isPluginCapable(client: T): client is T & PluginCapable { typeof client === 'object' && client !== null && 'supportsPlugin' in client && - 'installPlugin' in client + 'installPlugin' in client && + 'uninstallPlugin' in client ); }