Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
});
});
});
});
50 changes: 27 additions & 23 deletions src/steps/add-mcp-server-to-clients/clients/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}
}
}
46 changes: 30 additions & 16 deletions src/steps/add-mcp-server-to-clients/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
4 changes: 3 additions & 1 deletion src/steps/add-mcp-server-to-clients/plugin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ export interface PluginCapable {
supportsPlugin(): boolean;
isPluginInstalled(): Promise<boolean>;
installPlugin(): Promise<PluginInstallResult>;
uninstallPlugin(): Promise<{ success: boolean }>;
}

export function isPluginCapable<T>(client: T): client is T & PluginCapable {
return (
typeof client === 'object' &&
client !== null &&
'supportsPlugin' in client &&
'installPlugin' in client
'installPlugin' in client &&
'uninstallPlugin' in client
);
}
Loading