Skip to content
Draft
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 @@ -79,52 +79,129 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {
});

describe('installPlugin', () => {
it('returns success on exit 0', async () => {
execSyncMock.mockImplementation(() => Buffer.from(''));
it('defaults to user scope and returns success', async () => {
const installCalls: string[] = [];
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
installCalls.push(String(cmd));
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: true,
alreadyInstalled: false,
});
expect(installCalls).toHaveLength(1);
expect(installCalls[0]).toContain('--scope user');
});

it('installs in project scope only when scope=project', async () => {
const installCalls: string[] = [];
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
installCalls.push(String(cmd));
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
await expect(client.installPlugin('project')).resolves.toEqual({
success: true,
alreadyInstalled: false,
});
expect(installCalls).toHaveLength(1);
expect(installCalls[0]).toContain('--scope project');
});

it('returns success with alreadyInstalled when stderr contains "already installed"', async () => {
it('installs in both scopes when scope=both', async () => {
const installCalls: string[] = [];
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
installCalls.push(String(cmd));
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin('both')).resolves.toEqual({
success: true,
alreadyInstalled: false,
});
expect(installCalls).toEqual(
expect.arrayContaining([
expect.stringContaining('--scope user'),
expect.stringContaining('--scope project'),
]),
);
expect(installCalls).toHaveLength(2);
});

it('returns alreadyInstalled when scope=both and both scopes already have the plugin', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
throw new Error('already installed');
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
await expect(client.installPlugin('both')).resolves.toEqual({
success: true,
alreadyInstalled: true,
});
});

it('returns success with alreadyInstalled when stderr contains "already exists"', async () => {
it('returns alreadyInstalled when single scope reports already exists', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
throw new Error('already exists');
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
await expect(client.installPlugin('user')).resolves.toEqual({
success: true,
alreadyInstalled: true,
});
});

it('returns failure and captures exception on unexpected error', async () => {
it('scope=both still succeeds when only one scope fails', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('--scope project')) {
throw new Error('network timeout');
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin('both')).resolves.toEqual({
success: true,
alreadyInstalled: false,
});
expect(analytics.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('scope=project'),
}),
);
});

it('scope=both fails when both scopes fail with unexpected errors', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
throw new Error('network timeout');
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
await expect(client.installPlugin('both')).resolves.toEqual({
success: false,
alreadyInstalled: false,
});
expect(analytics.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('scope=user'),
}),
);
expect(analytics.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('network timeout'),
message: expect.stringContaining('scope=project'),
}),
);
});
Expand Down
43 changes: 34 additions & 9 deletions src/steps/add-mcp-server-to-clients/clients/claude-code.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { DefaultMCPClient } from '../MCPClient';
import { DefaultMCPClientConfig } from '../defaults';
import { PluginCapable, PluginInstallResult } from '../plugin-client';
import {
DEFAULT_PLUGIN_SCOPE,
PluginCapable,
PluginInstallResult,
PluginScope,
} from '../plugin-client';
import { z } from 'zod';
import { execSync } from 'child_process';
import { analytics } from '../../../utils/analytics';
Expand Down Expand Up @@ -140,21 +145,41 @@ export class ClaudeCodeMCPClient
}
}

installPlugin(): Promise<PluginInstallResult> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve({ success: false });
private installPluginInScope(
binary: string,
scope: 'user' | 'project',
): PluginInstallResult {
try {
execSync(`${binary} plugin install posthog`, { stdio: 'pipe' });
return Promise.resolve({ success: true });
execSync(`${binary} plugin install posthog --scope ${scope}`, {
stdio: 'pipe',
});
return { success: true };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('already installed') || msg.includes('already exists')) {
return Promise.resolve({ success: true, alreadyInstalled: true });
return { success: true, alreadyInstalled: true };
}
analytics.captureException(
new Error(`Claude Code plugin install failed: ${msg}`),
new Error(`Claude Code plugin install failed (scope=${scope}): ${msg}`),
);
return Promise.resolve({ success: false });
return { success: false };
}
}

installPlugin(
scope: PluginScope = DEFAULT_PLUGIN_SCOPE,
): Promise<PluginInstallResult> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve({ success: false });

const scopes: Array<'user' | 'project'> =
scope === 'both' ? ['user', 'project'] : [scope];

const results = scopes.map((s) => this.installPluginInScope(binary, s));

return Promise.resolve({
success: results.some((r) => r.success),
alreadyInstalled: results.every((r) => r.alreadyInstalled === true),
});
}
}
8 changes: 6 additions & 2 deletions src/steps/add-mcp-server-to-clients/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import * as path from 'node:path';

import { DefaultMCPClient } from '../MCPClient';
import { DefaultMCPClientConfig } from '../defaults';
import { PluginCapable, PluginInstallResult } from '../plugin-client';
import {
PluginCapable,
PluginInstallResult,
PluginScope,
} from '../plugin-client';

import { analytics } from '../../../utils/analytics';

Expand Down Expand Up @@ -90,7 +94,7 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable {
}
}

installPlugin(): Promise<PluginInstallResult> {
installPlugin(_scope?: PluginScope): Promise<PluginInstallResult> {
const binary = this.findCodexBinary();
if (!binary) return Promise.resolve({ success: false });

Expand Down
10 changes: 8 additions & 2 deletions src/steps/add-mcp-server-to-clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { ZedClient } from './clients/zed';
import { CodexMCPClient } from './clients/codex';
import { ALL_FEATURE_VALUES } from './defaults';
import { debug } from '../../utils/debug';
import { isPluginCapable, PluginCapable } from './plugin-client';
import {
DEFAULT_PLUGIN_SCOPE,
isPluginCapable,
PluginCapable,
PluginScope,
} from './plugin-client';

export const getSupportedClients = async (): Promise<MCPClient[]> => {
const allClients = [
Expand Down Expand Up @@ -162,11 +167,12 @@ export const getSupportedPluginClients = (

export const installPlugins = async (
clients: Array<MCPClient & PluginCapable>,
scope: PluginScope = DEFAULT_PLUGIN_SCOPE,
): Promise<string[]> => {
const installed: string[] = [];
for (const client of clients) {
try {
const result = await client.installPlugin();
const result = await client.installPlugin(scope);
if (result.success) installed.push(client.name);
} catch (err) {
debug(`[installPlugins] installPlugin threw for ${client.name}: ${err}`);
Expand Down
6 changes: 5 additions & 1 deletion src/steps/add-mcp-server-to-clients/plugin-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export type PluginScope = 'user' | 'project' | 'both';

export const DEFAULT_PLUGIN_SCOPE: PluginScope = 'user';

export interface PluginInstallResult {
success: boolean;
alreadyInstalled?: boolean;
Expand All @@ -6,7 +10,7 @@ export interface PluginInstallResult {
export interface PluginCapable {
supportsPlugin(): boolean;
isPluginInstalled(): Promise<boolean>;
installPlugin(): Promise<PluginInstallResult>;
installPlugin(scope?: PluginScope): Promise<PluginInstallResult>;
}

export function isPluginCapable<T>(client: T): client is T & PluginCapable {
Expand Down
25 changes: 22 additions & 3 deletions src/ui/tui/__tests__/mcp-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,41 @@ describe('createMcpInstaller — installPlugins', () => {
mockClaudeClient,
mockCursorClient,
]);
expect(mcpModule.installPlugins).toHaveBeenCalledWith([mockClaudeClient]);
expect(mcpModule.installPlugins).toHaveBeenCalledWith(
[mockClaudeClient],
'user',
);
expect(result).toEqual(['Claude Code']);
});

it('emits mcp plugins installed analytics with clients and attempted', async () => {
it('forwards the chosen plugin scope to the orchestrator', async () => {
mcpModule.getSupportedPluginClients.mockReturnValue([mockClaudeClient]);
mcpModule.installPlugins.mockResolvedValue(['Claude Code']);

const installer = createMcpInstaller();
await installer.detectClients();
await installer.installPlugins(['Claude Code'], 'both');

expect(mcpModule.installPlugins).toHaveBeenCalledWith(
[mockClaudeClient],
'both',
);
});

it('emits mcp plugins installed analytics with clients, attempted, and scope', async () => {
mcpModule.getSupportedPluginClients.mockReturnValue([mockClaudeClient]);
mcpModule.installPlugins.mockResolvedValue(['Claude Code']);

const installer = createMcpInstaller();
await installer.detectClients();
await installer.installPlugins(['Claude Code']);
await installer.installPlugins(['Claude Code'], 'project');

expect(analytics.wizardCapture).toHaveBeenCalledWith(
'mcp plugins installed',
{
clients: ['Claude Code'],
attempted: ['Claude Code'],
scope: 'project',
},
);
});
Expand All @@ -84,6 +102,7 @@ describe('createMcpInstaller — installPlugins', () => {
{
clients: [],
attempted: [],
scope: 'user',
},
);
});
Expand Down
Loading
Loading