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 @@ -80,13 +80,17 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {

describe('installPlugin', () => {
it('returns success on exit 0', async () => {
execSyncMock.mockImplementation(() => Buffer.from(''));
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('--version')) return Buffer.from('1.0.99');
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
});

it('returns success with alreadyInstalled when stderr contains "already installed"', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('--version')) return Buffer.from('1.0.99');
if (String(cmd).includes('plugin install')) {
throw new Error('already installed');
}
Expand All @@ -101,6 +105,7 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {

it('returns success with alreadyInstalled when stderr contains "already exists"', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('--version')) return Buffer.from('1.0.99');
if (String(cmd).includes('plugin install')) {
throw new Error('already exists');
}
Expand All @@ -115,6 +120,7 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {

it('returns failure and captures exception on unexpected error', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('--version')) return Buffer.from('1.0.99');
if (String(cmd).includes('plugin install')) {
throw new Error('network timeout');
}
Expand All @@ -136,5 +142,55 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
});

it('returns outdatedClient and skips install when CLI version is below the minimum', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'command -v claude') return Buffer.from('');
if (String(cmd).includes('--version')) return Buffer.from('1.0.56');
if (String(cmd).includes('plugin install')) {
throw new Error('should not be called');
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
outdatedClient: true,
});
expect(analytics.captureException).not.toHaveBeenCalled();
// Ensure we never shelled out to `plugin install`.
const calls = execSyncMock.mock.calls.map((c: unknown[]) => String(c[0]));
expect(calls.some((c) => c.includes('plugin install'))).toBe(false);
});

it('returns outdatedClient without capturing exception when CLI reports "newer version" at install time', async () => {
// Version parsing fails -> version pre-check is a no-op,
// but the catch block must still recognize the CLI's own message.
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'command -v claude') return Buffer.from('');
if (String(cmd).includes('--version')) return Buffer.from('unknown');
if (String(cmd).includes('plugin install')) {
throw new Error(
'A newer version (1.0.88 or higher) is required to continue.',
);
}
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
outdatedClient: true,
});
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('accepts versions at the minimum', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('--version')) return Buffer.from('1.0.88');
return Buffer.from('');
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
});
});
});
67 changes: 67 additions & 0 deletions src/steps/add-mcp-server-to-clients/clients/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,65 @@ export const ClaudeCodeMCPConfig = DefaultMCPClientConfig;

export type ClaudeCodeMCPConfig = z.infer<typeof DefaultMCPClientConfig>;

// Minimum Claude Code CLI version that supports `plugin install`.
export const MIN_PLUGIN_VERSION = '1.0.88';

// Parse a semver-ish string ("1.0.88", "1.0.88 (Claude Code)") into [major, minor, patch].
// Returns null if no version-like token is found.
const parseVersion = (raw: string): [number, number, number] | null => {
const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
};

const compareVersions = (
a: [number, number, number],
b: [number, number, number],
): number => {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
};

export class ClaudeCodeMCPClient
extends DefaultMCPClient
implements PluginCapable
{
name = 'Claude Code';
private claudeBinaryPath: string | null = null;
private cliVersion: string | null = null;

constructor() {
super();
}

// Returns the installed CLI version (cached), or null if it can't be read.
private getCliVersion(): string | null {
if (this.cliVersion) return this.cliVersion;
const binary = this.findClaudeBinary();
if (!binary) return null;
try {
const output = execSync(`${binary} --version`, { stdio: 'pipe' });
this.cliVersion = output.toString().trim();
return this.cliVersion;
} catch {
return null;
}
}

// True iff the detected CLI version is below MIN_PLUGIN_VERSION.
// If the version can't be parsed, assume it's recent enough — the actual
// install will still surface any error.
private isCliOutdatedForPlugins(): boolean {
const version = this.getCliVersion();
if (!version) return false;
const parsed = parseVersion(version);
const min = parseVersion(MIN_PLUGIN_VERSION);
if (!parsed || !min) return false;
return compareVersions(parsed, min) < 0;
}

private findClaudeBinary(): string | null {
if (this.claudeBinaryPath) {
return this.claudeBinaryPath;
Expand Down Expand Up @@ -73,6 +121,7 @@ export class ClaudeCodeMCPClient

const output = execSync(`${claudeBinary} --version`, { stdio: 'pipe' });
const version = output.toString().trim();
this.cliVersion = version;
debug(` Claude Code detected: ${version}`);
return Promise.resolve(true);
} catch (error) {
Expand Down Expand Up @@ -143,6 +192,14 @@ export class ClaudeCodeMCPClient
installPlugin(): Promise<PluginInstallResult> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve({ success: false });
if (this.isCliOutdatedForPlugins()) {
debug(
` Claude Code CLI ${
this.cliVersion ?? '(unknown)'
} is below required ${MIN_PLUGIN_VERSION} for plugin install`,
);
return Promise.resolve({ success: false, outdatedClient: true });
}
try {
execSync(`${binary} plugin install posthog`, { stdio: 'pipe' });
return Promise.resolve({ success: true });
Expand All @@ -151,6 +208,16 @@ export class ClaudeCodeMCPClient
if (msg.includes('already installed') || msg.includes('already exists')) {
return Promise.resolve({ success: true, alreadyInstalled: true });
}
// Fallback: if the CLI itself reports it needs an update, treat as outdated
// rather than a generic failure. Covers cases where the version pre-check
// didn't trigger (e.g. unparseable version string).
if (
msg.includes('newer version') ||
msg.includes('needs an update') ||
msg.includes('is required to continue')
) {
return Promise.resolve({ success: false, outdatedClient: true });
}
analytics.captureException(
new Error(`Claude Code plugin install failed: ${msg}`),
);
Expand Down
16 changes: 13 additions & 3 deletions src/steps/add-mcp-server-to-clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,29 @@ export const getSupportedPluginClients = (
return clients.filter(isPluginCapable).filter((c) => c.supportsPlugin());
};

export interface PluginInstallSummary {
installed: string[];
outdated: string[];
}

export const installPlugins = async (
clients: Array<MCPClient & PluginCapable>,
): Promise<string[]> => {
): Promise<PluginInstallSummary> => {
const installed: string[] = [];
const outdated: string[] = [];
for (const client of clients) {
try {
const result = await client.installPlugin();
if (result.success) installed.push(client.name);
if (result.success) {
installed.push(client.name);
} else if (result.outdatedClient) {
outdated.push(client.name);
}
} catch (err) {
debug(`[installPlugins] installPlugin threw for ${client.name}: ${err}`);
}
}
return installed;
return { installed, outdated };
};

export const removeMCPServer = async (
Expand Down
1 change: 1 addition & 0 deletions src/steps/add-mcp-server-to-clients/plugin-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface PluginInstallResult {
success: boolean;
alreadyInstalled?: boolean;
outdatedClient?: boolean;
}

export interface PluginCapable {
Expand Down
59 changes: 49 additions & 10 deletions src/ui/tui/__tests__/mcp-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ describe('createMcpInstaller — installPlugins', () => {

it('calls installPlugins on plugin-capable clients and returns installed names', async () => {
mcpModule.getSupportedPluginClients.mockReturnValue([mockClaudeClient]);
mcpModule.installPlugins.mockResolvedValue(['Claude Code']);
mcpModule.installPlugins.mockResolvedValue({
installed: ['Claude Code'],
outdated: [],
});

const installer = createMcpInstaller();
await installer.detectClients();
Expand All @@ -50,12 +53,15 @@ describe('createMcpInstaller — installPlugins', () => {
mockCursorClient,
]);
expect(mcpModule.installPlugins).toHaveBeenCalledWith([mockClaudeClient]);
expect(result).toEqual(['Claude Code']);
expect(result).toEqual({ installed: ['Claude Code'], outdated: [] });
});

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

const installer = createMcpInstaller();
await installer.detectClients();
Expand All @@ -66,31 +72,61 @@ describe('createMcpInstaller — installPlugins', () => {
{
clients: ['Claude Code'],
attempted: ['Claude Code'],
outdated: [],
},
);
});

it('returns empty array and still emits analytics when no clients support plugins', async () => {
it('surfaces outdated clients in the return value and analytics', async () => {
mcpModule.getSupportedPluginClients.mockReturnValue([mockClaudeClient]);
mcpModule.installPlugins.mockResolvedValue({
installed: [],
outdated: ['Claude Code'],
});

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

expect(result).toEqual({ installed: [], outdated: ['Claude Code'] });
expect(analytics.wizardCapture).toHaveBeenCalledWith(
'mcp plugins installed',
{
clients: [],
attempted: ['Claude Code'],
outdated: ['Claude Code'],
},
);
});

it('returns empty arrays and still emits analytics when no clients support plugins', async () => {
mcpModule.getSupportedPluginClients.mockReturnValue([]);
mcpModule.installPlugins.mockResolvedValue([]);
mcpModule.installPlugins.mockResolvedValue({
installed: [],
outdated: [],
});

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

expect(result).toEqual([]);
expect(result).toEqual({ installed: [], outdated: [] });
expect(analytics.wizardCapture).toHaveBeenCalledWith(
'mcp plugins installed',
{
clients: [],
attempted: [],
outdated: [],
},
);
});

it('only passes clients matching the requested names to getSupportedPluginClients', async () => {
mcpModule.getSupportedPluginClients.mockReturnValue([]);
mcpModule.installPlugins.mockResolvedValue([]);
mcpModule.installPlugins.mockResolvedValue({
installed: [],
outdated: [],
});

const installer = createMcpInstaller();
await installer.detectClients();
Expand All @@ -106,12 +142,15 @@ describe('createMcpInstaller — installPlugins', () => {
mockClaudeClient,
mockCursorClient,
]);
mcpModule.installPlugins.mockResolvedValue(['Claude Code']); // Cursor failed
mcpModule.installPlugins.mockResolvedValue({
installed: ['Claude Code'],
outdated: [],
}); // Cursor failed

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

expect(result).toEqual(['Claude Code']);
expect(result).toEqual({ installed: ['Claude Code'], outdated: [] });
});
});
3 changes: 2 additions & 1 deletion src/ui/tui/playground/demos/McpDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ function createMockInstaller(): McpInstaller {
},
async installPlugins(clientNames) {
await new Promise((r) => setTimeout(r, 800));
return clientNames.filter(
const installed = clientNames.filter(
(name) => MOCK_CLIENTS.find((c) => c.name === name)?.supportsPlugin,
);
return { installed, outdated: [] };
},
async remove() {
await new Promise((r) => setTimeout(r, 1000));
Expand Down
Loading
Loading