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 432b28d33..54d51220f 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 @@ -1,5 +1,6 @@ import { ClaudeCodeMCPClient } from '@steps/add-mcp-server-to-clients/clients/claude-code'; import { execSync } from 'child_process'; +import * as os from 'os'; import { analytics } from '@utils/analytics'; vi.mock('child_process', () => ({ @@ -79,54 +80,152 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { }); describe('installPlugin', () => { - it('returns success on exit 0', async () => { + /** Every `claude` invocation made during the call, in order. */ + const claudeCalls = () => + execSyncMock.mock.calls + .map(([cmd]) => String(cmd)) + .filter((cmd) => cmd !== 'command -v claude'); + + it('registers the marketplace before installing when it is not configured', async () => { execSyncMock.mockImplementation(() => Buffer.from('')); const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + + expect(claudeCalls()).toEqual([ + 'claude "plugin" "marketplace" "list"', + 'claude "plugin" "marketplace" "add" "anthropics/claude-plugins-official"', + 'claude "plugin" "install" "posthog@claude-plugins-official"', + ]); }); - it('returns success with alreadyInstalled when stderr contains "already installed"', async () => { + it('skips the marketplace add when it is already configured', async () => { execSyncMock.mockImplementation((cmd: string) => { - if (String(cmd).includes('plugin install')) { - throw new Error('already installed'); - } + if (String(cmd).includes('marketplace" "list')) + return Buffer.from('claude-plugins-official github\n'); return Buffer.from(''); }); const client = new ClaudeCodeMCPClient(); - await expect(client.installPlugin()).resolves.toEqual({ - success: true, - alreadyInstalled: true, - }); + + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + + expect(claudeCalls()).toEqual([ + 'claude "plugin" "marketplace" "list"', + 'claude "plugin" "install" "posthog@claude-plugins-official"', + ]); }); - it('returns success with alreadyInstalled when stderr contains "already exists"', async () => { + it('refreshes a stale marketplace and retries when the plugin is not found', async () => { + let installAttempts = 0; execSyncMock.mockImplementation((cmd: string) => { - if (String(cmd).includes('plugin install')) { - throw new Error('already exists'); + if (String(cmd).includes('"install"')) { + installAttempts += 1; + if (installAttempts === 1) { + throw new Error( + 'Failed to install plugin "posthog": Plugin "posthog" not found in any configured marketplace', + ); + } } return Buffer.from(''); }); const client = new ClaudeCodeMCPClient(); - await expect(client.installPlugin()).resolves.toEqual({ - success: true, - alreadyInstalled: true, + + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + + expect(claudeCalls()).toContain( + 'claude "plugin" "marketplace" "update" "claude-plugins-official"', + ); + expect(installAttempts).toBe(2); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + + it('falls back to the bare plugin name when the qualified one stays unresolvable', async () => { + const attempts: string[] = []; + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('"install"')) { + attempts.push(String(cmd)); + if (String(cmd).includes('posthog@')) { + throw new Error( + 'Plugin "posthog" not found in any configured marketplace', + ); + } + } + return Buffer.from(''); }); + const client = new ClaudeCodeMCPClient(); + + await expect(client.installPlugin()).resolves.toEqual({ success: true }); + + expect(attempts.at(-1)).toBe('claude "plugin" "install" "posthog"'); + expect(analytics.captureException).not.toHaveBeenCalled(); }); - it('returns failure and captures exception on unexpected error', async () => { + it.each(['already installed', 'already exists'])( + 'returns alreadyInstalled when the CLI reports "%s"', + async (message) => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('"install"')) throw new Error(message); + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ + success: true, + alreadyInstalled: true, + }); + }, + ); + + it.each([ + ["error: unknown command 'install'", 'too old'], + ['Invalid schema: plugins.0.source: Invalid input', "couldn't read"], + ['spawnSync /bin/sh ENOBUFS', 'ran out of room'], + ['Failed to clone repository: No ED25519 host key is known', 'GitHub'], + ['EACCES: permission denied', 'permissions'], + ])( + 'treats %j as an expected local failure and hints instead of reporting', + async (stderr, hintFragment) => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('"install"')) throw new Error(stderr); + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + + const result = await client.installPlugin(); + + expect(result.success).toBe(false); + expect(result.hint).toContain(hintFragment); + expect(analytics.captureException).not.toHaveBeenCalled(); + }, + ); + + it('reports unexpected failures under a stable message with the detail in properties', async () => { execSyncMock.mockImplementation((cmd: string) => { - if (String(cmd).includes('plugin install')) { - throw new Error('network timeout'); + if (String(cmd).includes('"install"')) { + const err = new Error( + 'Command failed: claude plugin install', + ) as Error & { stderr: string }; + err.stderr = `✘ Failed to install plugin: something odd at ${os.homedir()}/.claude`; + throw err; } return Buffer.from(''); }); const client = new ClaudeCodeMCPClient(); - await expect(client.installPlugin()).resolves.toEqual({ success: false }); - expect(analytics.captureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('network timeout'), - }), - ); + + const result = await client.installPlugin(); + + expect(result.success).toBe(false); + expect(result.hint).toBeTruthy(); + expect(analytics.captureException).toHaveBeenCalledTimes(1); + const [error, properties] = (analytics.captureException as Mock).mock + .calls[0] as [Error, Record]; + // Stable title — no stderr, no binary path, so one root cause is one issue. + expect(error.message).toBe('Claude Code plugin install failed (install)'); + expect(properties.stage).toBe('install'); + expect(properties.binary).toBe('claude'); + expect(properties.details).toContain('something odd'); + // Home directory normalised away. + expect(properties.details).not.toContain(os.homedir()); + expect(properties.details).toContain('~/.claude'); }); it('returns failure when no binary is found', async () => { 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 a435249a3..c2fc82f0d 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 @@ -19,6 +19,83 @@ export const ClaudeCodeMCPConfig = DefaultMCPClientConfig; export type ClaudeCodeMCPConfig = z.infer; +/** + * `claude plugin install ` only resolves against marketplaces the user + * already has registered. The posthog plugin ships in Anthropic's official + * marketplace, which Claude Code registers when it *starts interactively* — the + * wizard shells straight into the non-interactive `plugin` subcommand, so on a + * machine that never opened Claude Code the catalog isn't there and the install + * dies with `Plugin "posthog" not found in any configured marketplace`. Register + * the marketplace first, the same way the Codex client does. + */ +const PLUGIN_MARKETPLACE = 'claude-plugins-official'; +const PLUGIN_MARKETPLACE_SOURCE = 'anthropics/claude-plugins-official'; +const PLUGIN_REF = `posthog@${PLUGIN_MARKETPLACE}`; +const RETRY_COMMAND = `claude plugin install ${PLUGIN_REF}`; + +/** + * Failures that live entirely in the user's environment. We can't fix these + * from the wizard and reporting them only creates unactionable issues, so we + * hand the user a hint and move on. + */ +const EXPECTED_INSTALL_FAILURES: Array<{ match: RegExp; hint: string }> = [ + { + match: /unknown command|unknown option|unknown argument|error: unknown/i, + hint: `your Claude Code CLI is too old for plugins — update it, then run \`${RETRY_COMMAND}\``, + }, + { + match: + /invalid schema|invalid json|not valid json|unexpected token|failed to parse|parse error|SyntaxError/i, + hint: `Claude Code couldn't read its own config — fix the JSON it reports, then run \`${RETRY_COMMAND}\``, + }, + { + match: /ENOBUFS|ENOMEM|EAGAIN|EMFILE|maxBuffer/i, + hint: `Claude Code ran out of room to report its output — run \`${RETRY_COMMAND}\` yourself`, + }, + { + match: + /ENOTFOUND|ETIMEDOUT|ECONNREFUSED|ECONNRESET|EAI_AGAIN|EHOSTUNREACH|timed out|timeout|could not resolve host|failed to clone|host key|publickey|proxy|certificate/i, + hint: `Claude Code couldn't reach GitHub to download the plugin — check your network, then run \`${RETRY_COMMAND}\``, + }, + { + match: + /EACCES|EPERM|permission denied|read-only file system|not permitted/i, + hint: `Claude Code couldn't write to its plugin directory — fix the permissions, then run \`${RETRY_COMMAND}\``, + }, + { + match: /not allowed|blocked by|managed settings|disallowed|restricted/i, + hint: 'your organization blocks Claude Code plugin marketplaces — the MCP server is set up either way', + }, +]; + +const FALLBACK_HINT = `the PostHog plugin didn't install — run \`${RETRY_COMMAND}\` to retry`; + +/** + * Replace home directories with `~` so one root cause groups as one issue + * instead of one per user, and so usernames never reach error tracking. + */ +const scrubPaths = (text: string): string => { + const home = os.homedir(); + const withoutHome = home ? text.split(home).join('~') : text; + return withoutHome + .replace(/\/(?:Users|home)\/[^/\s'"]+/g, '~') + .replace(/[A-Za-z]:\\Users\\[^\\\s'"]+/gi, '~'); +}; + +/** execSync throws an error whose stderr/stdout carry the useful detail. */ +const describeExecError = (error: unknown): string => { + const parts = [ + error instanceof Error ? error.message : String(error), + ...(['stderr', 'stdout'] as const).map((key) => { + const value = (error as Record | null)?.[key]; + return value ? String(value) : ''; + }), + ]; + return parts.filter(Boolean).join('\n'); +}; + +type ClaudeRun = { ok: boolean; output: string }; + export class ClaudeCodeMCPClient extends DefaultMCPClient implements PluginCapable @@ -192,21 +269,90 @@ export class ClaudeCodeMCPClient } } + private runClaude(binary: string, args: string[]): ClaudeRun { + const command = `${binary} ${args.map((a) => JSON.stringify(a)).join(' ')}`; + try { + const output = execSync(command, { stdio: 'pipe' }); + return { ok: true, output: output?.toString() ?? '' }; + } catch (error) { + return { ok: false, output: describeExecError(error) }; + } + } + + /** + * Register the marketplace the posthog plugin is published in, unless it's + * already there. Best-effort: a failure here is only worth reporting if the + * install that follows also fails. + */ + private ensurePluginMarketplace(binary: string): ClaudeRun | null { + const listed = this.runClaude(binary, ['plugin', 'marketplace', 'list']); + if (listed.ok && listed.output.includes(PLUGIN_MARKETPLACE)) { + debug(` Marketplace ${PLUGIN_MARKETPLACE} already registered`); + return null; + } + + const added = this.runClaude(binary, [ + 'plugin', + 'marketplace', + 'add', + PLUGIN_MARKETPLACE_SOURCE, + ]); + if (added.ok || /already/i.test(added.output)) return null; + + debug(` Marketplace add failed: ${added.output}`); + return added; + } + installPlugin(): Promise { const binary = this.findClaudeBinary(); if (!binary) return Promise.resolve({ success: false }); - try { - execSync(`${binary} plugin install posthog`, { stdio: 'pipe' }); - return Promise.resolve({ 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 }); - } - analytics.captureException( - new Error(`Claude Code plugin install failed: ${msg}`), - ); - return Promise.resolve({ success: false }); + + const marketplaceFailure = this.ensurePluginMarketplace(binary); + + let result = this.runClaude(binary, ['plugin', 'install', PLUGIN_REF]); + + // A registered-but-stale catalog still reports the plugin as missing. + if (!result.ok && /not found in/i.test(result.output)) { + this.runClaude(binary, [ + 'plugin', + 'marketplace', + 'update', + PLUGIN_MARKETPLACE, + ]); + result = this.runClaude(binary, ['plugin', 'install', PLUGIN_REF]); } + + // Last resort: let Claude Code resolve the bare name against whichever + // marketplaces the user does have, the way the wizard used to. + if (!result.ok && /not found in/i.test(result.output)) { + result = this.runClaude(binary, ['plugin', 'install', 'posthog']); + } + + if (result.ok) return Promise.resolve({ success: true }); + + if (/already installed|already exists/i.test(result.output)) { + return Promise.resolve({ success: true, alreadyInstalled: true }); + } + + const stage = marketplaceFailure ? 'marketplace-add' : 'install'; + const details = scrubPaths( + [marketplaceFailure?.output, result.output].filter(Boolean).join('\n'), + ); + + const expected = EXPECTED_INSTALL_FAILURES.find((f) => + f.match.test(details), + ); + if (expected) { + debug(` Claude Code plugin install failed (expected): ${details}`); + return Promise.resolve({ success: false, hint: expected.hint }); + } + + // Keep the message constant so one root cause is one issue — the resolved + // binary path and the raw stderr go in properties, not the title. + analytics.captureException( + new Error(`Claude Code plugin install failed (${stage})`), + { stage, binary: path.basename(binary), details }, + ); + return Promise.resolve({ success: false, hint: FALLBACK_HINT }); } } diff --git a/src/steps/add-mcp-server-to-clients/index.ts b/src/steps/add-mcp-server-to-clients/index.ts index a1df5a06b..a1ce7708a 100644 --- a/src/steps/add-mcp-server-to-clients/index.ts +++ b/src/steps/add-mcp-server-to-clients/index.ts @@ -164,19 +164,35 @@ export const getSupportedPluginClients = ( return clients.filter(isPluginCapable).filter((c) => c.supportsPlugin()); }; +export interface PluginInstallReport { + /** Clients the plugin installed for. */ + installed: string[]; + /** Short, user-facing notes for the clients it didn't install for. */ + hints: Array<{ client: string; message: string }>; +} + export const installPlugins = async ( clients: Array, -): Promise => { +): Promise => { const installed: string[] = []; + const hints: PluginInstallReport['hints'] = []; 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.hint) { + hints.push({ client: client.name, message: result.hint }); + } } catch (err) { debug(`[installPlugins] installPlugin threw for ${client.name}: ${err}`); + hints.push({ + client: client.name, + message: "the plugin install couldn't run — install it from the editor", + }); } } - return installed; + return { installed, hints }; }; export const removeMCPServer = async ( 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 3d05bf7a4..f34c47d41 100644 --- a/src/steps/add-mcp-server-to-clients/plugin-client.ts +++ b/src/steps/add-mcp-server-to-clients/plugin-client.ts @@ -1,6 +1,11 @@ export interface PluginInstallResult { success: boolean; alreadyInstalled?: boolean; + /** + * Short, user-facing explanation of why the install didn't happen. Set on + * failure so the caller can tell the user instead of failing silently. + */ + hint?: string; } export interface PluginCapable { diff --git a/src/ui/tui/__tests__/mcp-installer.test.ts b/src/ui/tui/__tests__/mcp-installer.test.ts index f0bf94125..675e04ae9 100644 --- a/src/ui/tui/__tests__/mcp-installer.test.ts +++ b/src/ui/tui/__tests__/mcp-installer.test.ts @@ -41,7 +41,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'], + hints: [], + }); const installer = createMcpInstaller(); await installer.detectClients(); @@ -52,12 +55,15 @@ describe('createMcpInstaller — installPlugins', () => { mockCursorClient, ]); expect(mcpModule.installPlugins).toHaveBeenCalledWith([mockClaudeClient]); - expect(result).toEqual(['Claude Code']); + expect(result).toEqual({ installed: ['Claude Code'], hints: [] }); }); it('emits mcp plugins installed analytics with clients and attempted', async () => { mcpModule.getSupportedPluginClients.mockReturnValue([mockClaudeClient]); - mcpModule.installPlugins.mockResolvedValue(['Claude Code']); + mcpModule.installPlugins.mockResolvedValue({ + installed: ['Claude Code'], + hints: [], + }); const installer = createMcpInstaller(); await installer.detectClients(); @@ -68,31 +74,33 @@ describe('createMcpInstaller — installPlugins', () => { { clients: ['Claude Code'], attempted: ['Claude Code'], + not_installed: [], }, ); }); it('returns empty array and still emits analytics when no clients support plugins', async () => { mcpModule.getSupportedPluginClients.mockReturnValue([]); - mcpModule.installPlugins.mockResolvedValue([]); + mcpModule.installPlugins.mockResolvedValue({ installed: [], hints: [] }); const installer = createMcpInstaller(); await installer.detectClients(); const result = await installer.installPlugins(['Claude Code']); - expect(result).toEqual([]); + expect(result).toEqual({ installed: [], hints: [] }); expect(analytics.wizardCapture).toHaveBeenCalledWith( 'mcp plugins installed', { clients: [], attempted: [], + not_installed: [], }, ); }); it('only passes clients matching the requested names to getSupportedPluginClients', async () => { mcpModule.getSupportedPluginClients.mockReturnValue([]); - mcpModule.installPlugins.mockResolvedValue([]); + mcpModule.installPlugins.mockResolvedValue({ installed: [], hints: [] }); const installer = createMcpInstaller(); await installer.detectClients(); @@ -103,18 +111,32 @@ describe('createMcpInstaller — installPlugins', () => { ]); }); - it('returns partial success when plugin install fails for some clients', async () => { + it('returns partial success and surfaces a hint when plugin install fails for some clients', async () => { mcpModule.getSupportedPluginClients.mockReturnValue([ mockClaudeClient, mockCursorClient, ]); - mcpModule.installPlugins.mockResolvedValue(['Claude Code']); // Cursor failed + mcpModule.installPlugins.mockResolvedValue({ + installed: ['Claude Code'], + hints: [{ client: 'Cursor', message: 'update the CLI and retry' }], + }); 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'], + hints: [{ client: 'Cursor', message: 'update the CLI and retry' }], + }); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'mcp plugins installed', + { + clients: ['Claude Code'], + attempted: ['Claude Code', 'Cursor'], + not_installed: ['Cursor'], + }, + ); }); }); diff --git a/src/ui/tui/playground/demos/McpDemo.tsx b/src/ui/tui/playground/demos/McpDemo.tsx index 2f01472b6..bead09c85 100644 --- a/src/ui/tui/playground/demos/McpDemo.tsx +++ b/src/ui/tui/playground/demos/McpDemo.tsx @@ -38,9 +38,12 @@ function createMockInstaller(): McpInstaller { }, async installPlugins(clientNames) { await new Promise((r) => setTimeout(r, 800)); - return clientNames.filter( - (name) => MOCK_CLIENTS.find((c) => c.name === name)?.supportsPlugin, - ); + return { + installed: clientNames.filter( + (name) => MOCK_CLIENTS.find((c) => c.name === name)?.supportsPlugin, + ), + hints: [], + }; }, async remove() { await new Promise((r) => setTimeout(r, 1000)); diff --git a/src/ui/tui/screens/McpScreen.tsx b/src/ui/tui/screens/McpScreen.tsx index 45470411a..b01b39963 100644 --- a/src/ui/tui/screens/McpScreen.tsx +++ b/src/ui/tui/screens/McpScreen.tsx @@ -100,6 +100,9 @@ export const McpScreen = ({ const [selectedClientNames, setSelectedClientNames] = useState([]); const [resultClients, setResultClients] = useState([]); const [pluginClients, setPluginClients] = useState([]); + const [pluginHints, setPluginHints] = useState< + Array<{ client: string; message: string }> + >([]); const [installMode, setInstallMode] = useState<'all' | 'custom'>('custom'); useEffect(() => { @@ -130,11 +133,11 @@ export const McpScreen = ({ // (e.g. Claude Desktop/Web) just open their connector page here, same as // before — no extra screen. if (chosenMode === 'all') { - void doInstall(clientNames, [...ALL_FEATURE_VALUES]); + void doInstall(clientNames, [...ALL_FEATURE_VALUES], chosenMode); return; } if (store.session.mcpFeatures) { - void doInstall(clientNames, store.session.mcpFeatures); + void doInstall(clientNames, store.session.mcpFeatures, chosenMode); return; } @@ -179,10 +182,21 @@ export const McpScreen = ({ markDone(store, McpOutcome.Skipped); }; - const doInstall = async (names: string[], features?: string[]) => { + /** + * `chosenMode` is passed in rather than read from `installMode`: with a single + * detected client the picker's choice and this call happen in one event + * handler, so the state setter hasn't landed yet and the stale 'custom' + * default would skip the plugin install entirely. + */ + const doInstall = async ( + names: string[], + features: string[] | undefined, + chosenMode: 'all' | 'custom', + ) => { setPhase(Phase.Working); let mcpResult: string[] = []; let pluginResult: string[] = []; + let hints: Array<{ client: string; message: string }> = []; const pluginCapableSet = new Set( clients.filter((c) => c.supportsPlugin).map((c) => c.name), @@ -190,7 +204,7 @@ export const McpScreen = ({ const pluginCapableNames = names.filter((n) => pluginCapableSet.has(n)); const directNames = names.filter((n) => !pluginCapableSet.has(n)); - if (installMode === 'all') { + if (chosenMode === 'all') { // Plugin-capable clients get the plugin (which bundles MCP). // Non-plugin-capable clients get a direct MCP config write. try { @@ -203,7 +217,9 @@ export const McpScreen = ({ // mcpResult stays [] } try { - pluginResult = await installer.installPlugins(pluginCapableNames); + const report = await installer.installPlugins(pluginCapableNames); + pluginResult = report.installed; + hints = report.hints; } catch { // best-effort } @@ -223,6 +239,7 @@ export const McpScreen = ({ setResultClients(mcpResult); setPluginClients(pluginResult); + setPluginHints(hints); setPhase(Phase.Done); const succeeded = mcpResult.length + pluginResult.length > 0; const outcome = succeeded ? McpOutcome.Installed : McpOutcome.Failed; @@ -235,7 +252,8 @@ export const McpScreen = ({ [...mcpResult, ...pluginResult], featuresReport, ), - 2000, + // Give the reader time to take in a plugin hint before we move on. + hints.length > 0 ? 5000 : 2000, ); }; @@ -386,7 +404,7 @@ export const McpScreen = ({ groups={AVAILABLE_FEATURES} initialSelected={[]} onSelect={(features) => { - void doInstall(selectedClientNames, features); + void doInstall(selectedClientNames, features, 'custom'); }} /> )} @@ -400,7 +418,9 @@ export const McpScreen = ({ void doInstall(selectedClientNames, [])} + onContinue={() => + void doInstall(selectedClientNames, [], 'custom') + } /> )} @@ -415,9 +435,11 @@ export const McpScreen = ({ {installedNow.length + pluginClients.length + finishNotes.length === 0 ? ( - - {isRemove ? 'Removal' : 'Installation'} skipped. - + pluginHints.length === 0 && ( + + {isRemove ? 'Removal' : 'Installation'} skipped. + + ) ) : ( <> {pluginClients.length > 0 && ( @@ -467,6 +489,17 @@ export const McpScreen = ({ ))} )} + {pluginHints.map((hint) => ( + + + {Icons.warning} {hint.client} plugin not installed + + + {' '} + {hint.message} + + + ))} )} diff --git a/src/ui/tui/services/mcp-installer.ts b/src/ui/tui/services/mcp-installer.ts index 3179cf9f2..ae77f58f2 100644 --- a/src/ui/tui/services/mcp-installer.ts +++ b/src/ui/tui/services/mcp-installer.ts @@ -11,6 +11,7 @@ import { getInstalledClients, getSupportedPluginClients, installPlugins as runPluginInstall, + type PluginInstallReport, } from '@steps/add-mcp-server-to-clients/index'; import { ALL_FEATURE_VALUES } from '@steps/add-mcp-server-to-clients/defaults'; import { isPluginCapable } from '@steps/add-mcp-server-to-clients/plugin-client'; @@ -42,8 +43,12 @@ export interface McpInstaller { /** Remove the PostHog MCP server from all installed clients. Returns names of removed clients. */ remove(): Promise; - /** Install the PostHog AI plugin to supported clients. Best-effort: failures do not affect MCP outcome. */ - installPlugins(clientNames: string[]): Promise; + /** + * Install the PostHog AI plugin to supported clients. Best-effort: failures + * do not affect the MCP outcome, but they come back as hints so the screen + * can tell the user rather than swallowing them. + */ + installPlugins(clientNames: string[]): Promise; } /** @@ -119,21 +124,22 @@ export function createMcpInstaller(): McpInstaller { return installed.map((c) => c.name); }, - async installPlugins(clientNames: string[]): Promise { + async installPlugins(clientNames: string[]): Promise { const rawClients = cachedClients .filter((c) => clientNames.includes(c.name)) // eslint-disable-next-line @typescript-eslint/no-explicit-any .map((c) => c.raw as any); const pluginClients = getSupportedPluginClients(rawClients); - const installed = await runPluginInstall(pluginClients); + const report = await runPluginInstall(pluginClients); analytics.wizardCapture('mcp plugins installed', { - clients: installed, + clients: report.installed, attempted: pluginClients.map((c) => c.name), + not_installed: report.hints.map((h) => h.client), }); - return installed; + return report; }, }; }