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..54f84d7f 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 @@ -79,13 +79,63 @@ 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'); @@ -93,13 +143,13 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { 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'); @@ -107,13 +157,32 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { 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'); @@ -121,10 +190,18 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { 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'), }), ); }); 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..b8cb08bb 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 @@ -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'; @@ -140,21 +145,41 @@ export class ClaudeCodeMCPClient } } - installPlugin(): Promise { - 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 { + 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), + }); + } } 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..9451b673 100644 --- a/src/steps/add-mcp-server-to-clients/clients/codex.ts +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -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'; @@ -90,7 +94,7 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { } } - installPlugin(): Promise { + installPlugin(_scope?: PluginScope): Promise { const binary = this.findCodexBinary(); if (!binary) return Promise.resolve({ success: false }); diff --git a/src/steps/add-mcp-server-to-clients/index.ts b/src/steps/add-mcp-server-to-clients/index.ts index 94a75018..aea47620 100644 --- a/src/steps/add-mcp-server-to-clients/index.ts +++ b/src/steps/add-mcp-server-to-clients/index.ts @@ -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 => { const allClients = [ @@ -162,11 +167,12 @@ export const getSupportedPluginClients = ( export const installPlugins = async ( clients: Array, + scope: PluginScope = DEFAULT_PLUGIN_SCOPE, ): Promise => { 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}`); 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..3f96dd32 100644 --- a/src/steps/add-mcp-server-to-clients/plugin-client.ts +++ b/src/steps/add-mcp-server-to-clients/plugin-client.ts @@ -1,3 +1,7 @@ +export type PluginScope = 'user' | 'project' | 'both'; + +export const DEFAULT_PLUGIN_SCOPE: PluginScope = 'user'; + export interface PluginInstallResult { success: boolean; alreadyInstalled?: boolean; @@ -6,7 +10,7 @@ export interface PluginInstallResult { export interface PluginCapable { supportsPlugin(): boolean; isPluginInstalled(): Promise; - installPlugin(): Promise; + installPlugin(scope?: PluginScope): Promise; } export function isPluginCapable(client: T): client is T & PluginCapable { diff --git a/src/ui/tui/__tests__/mcp-installer.test.ts b/src/ui/tui/__tests__/mcp-installer.test.ts index 50f218e8..7744986b 100644 --- a/src/ui/tui/__tests__/mcp-installer.test.ts +++ b/src/ui/tui/__tests__/mcp-installer.test.ts @@ -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', }, ); }); @@ -84,6 +102,7 @@ describe('createMcpInstaller — installPlugins', () => { { clients: [], attempted: [], + scope: 'user', }, ); }); diff --git a/src/ui/tui/screens/McpScreen.tsx b/src/ui/tui/screens/McpScreen.tsx index f536cf55..7a201401 100644 --- a/src/ui/tui/screens/McpScreen.tsx +++ b/src/ui/tui/screens/McpScreen.tsx @@ -14,6 +14,8 @@ import { Box, Text, useInput } from 'ink'; import { useState, useEffect } from 'react'; import { useSyncExternalStore } from 'react'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { type WizardStore, McpOutcome } from '../store.js'; import { ConfirmationInput, @@ -26,9 +28,21 @@ import { AVAILABLE_FEATURES, ALL_FEATURE_VALUES, } from '../../../steps/add-mcp-server-to-clients/defaults.js'; +import { + DEFAULT_PLUGIN_SCOPE, + PluginScope, +} from '../../../steps/add-mcp-server-to-clients/plugin-client.js'; export type McpMode = 'install' | 'remove'; +const hasGitDirectory = (): boolean => { + try { + return fs.existsSync(path.join(process.cwd(), '.git')); + } catch { + return false; + } +}; + interface McpScreenProps { store: WizardStore; installer: McpInstaller; @@ -40,6 +54,7 @@ enum Phase { Ask = 'ask', Pick = 'pick', FeatureSelect = 'feature-select', + PluginScopeSelect = 'plugin-scope-select', Working = 'working', Done = 'done', None = 'none', @@ -72,8 +87,12 @@ export const McpScreen = ({ const [phase, setPhase] = useState(Phase.Detecting); const [clients, setClients] = useState([]); const [selectedClientNames, setSelectedClientNames] = useState([]); + const [selectedFeatures, setSelectedFeatures] = useState< + string[] | undefined + >(undefined); const [resultClients, setResultClients] = useState([]); const [pluginClients, setPluginClients] = useState([]); + const gitRepoDetected = hasGitDirectory(); useEffect(() => { void (async () => { @@ -93,11 +112,27 @@ export const McpScreen = ({ })(); }, [installer]); // eslint-disable-line + const anyClientSupportsPlugin = (names: string[]): boolean => + clients.some((c) => names.includes(c.name) && c.supportsPlugin); + + const proceedAfterFeatures = (names: string[], features: string[]) => { + setSelectedClientNames(names); + setSelectedFeatures(features); + // Only ask about plugin scope when there is a real choice: + // at least one selected client supports the plugin AND we're in a git repo. + // Otherwise the only sensible option is the user-scope default. + if (anyClientSupportsPlugin(names) && gitRepoDetected) { + setPhase(Phase.PluginScopeSelect); + } else { + void doInstall(names, features, DEFAULT_PLUGIN_SCOPE); + } + }; + const proceedToFeatureSelectOrInstall = (clientNames: string[]) => { setSelectedClientNames(clientNames); // Skip feature picker if CLI already specified features if (store.session.mcpFeatures) { - void doInstall(clientNames, store.session.mcpFeatures); + proceedAfterFeatures(clientNames, store.session.mcpFeatures); } else { setPhase(Phase.FeatureSelect); } @@ -117,7 +152,11 @@ export const McpScreen = ({ markDone(store, McpOutcome.Skipped); }; - const doInstall = async (names: string[], features?: string[]) => { + const doInstall = async ( + names: string[], + features?: string[], + pluginScope: PluginScope = DEFAULT_PLUGIN_SCOPE, + ) => { setPhase(Phase.Working); let mcpResult: string[] = []; let pluginResult: string[] = []; @@ -131,7 +170,7 @@ export const McpScreen = ({ // mcpResult stays [] } try { - pluginResult = await installer.installPlugins(names); + pluginResult = await installer.installPlugins(names, pluginScope); } catch { // best-effort — plugin failure does not affect MCP outcome } @@ -218,7 +257,40 @@ export const McpScreen = ({ groups={AVAILABLE_FEATURES} initialSelected={[...ALL_FEATURE_VALUES]} onSelect={(features) => { - void doInstall(selectedClientNames, features); + proceedAfterFeatures(selectedClientNames, features); + }} + /> + )} + + {phase === Phase.PluginScopeSelect && ( + + message="Where should the PostHog plugin be installed?" + options={[ + { + label: 'User (global, default)', + value: 'user', + hint: 'Available across all your projects', + }, + { + label: 'Project (shared)', + value: 'project', + hint: 'Committed via .claude/settings.json so your team gets it', + }, + { + label: 'Both', + value: 'both', + hint: 'Install globally and share with the project', + }, + ]} + onSelect={(value) => { + const scope = (Array.isArray(value) ? value[0] : value) as + | PluginScope + | undefined; + void doInstall( + selectedClientNames, + selectedFeatures, + scope ?? DEFAULT_PLUGIN_SCOPE, + ); }} /> )} @@ -235,7 +307,9 @@ export const McpScreen = ({ <> {'\u2714'} MCP server - {!isRemove && pluginClients.length > 0 ? ' and plugin' : ''}{' '} + {!isRemove && pluginClients.length > 0 + ? ' and plugin' + : ''}{' '} {isRemove ? 'removed from' : 'installed for'}: {resultClients.map((name, i) => ( diff --git a/src/ui/tui/services/mcp-installer.ts b/src/ui/tui/services/mcp-installer.ts index 4fd39aa4..4a35b44c 100644 --- a/src/ui/tui/services/mcp-installer.ts +++ b/src/ui/tui/services/mcp-installer.ts @@ -13,7 +13,11 @@ import { installPlugins as runPluginInstall, } from '../../../steps/add-mcp-server-to-clients/index.js'; import { ALL_FEATURE_VALUES } from '../../../steps/add-mcp-server-to-clients/defaults.js'; -import { isPluginCapable } from '../../../steps/add-mcp-server-to-clients/plugin-client.js'; +import { + DEFAULT_PLUGIN_SCOPE, + isPluginCapable, + PluginScope, +} from '../../../steps/add-mcp-server-to-clients/plugin-client.js'; import { logToFile } from '../../../utils/debug.js'; import { analytics } from '../../../utils/analytics.js'; @@ -37,7 +41,7 @@ export interface McpInstaller { remove(): Promise; /** Install the PostHog AI plugin to supported clients. Best-effort: failures do not affect MCP outcome. */ - installPlugins(clientNames: string[]): Promise; + installPlugins(clientNames: string[], scope?: PluginScope): Promise; } /** @@ -110,18 +114,22 @@ export function createMcpInstaller(): McpInstaller { return installed.map((c) => c.name); }, - async installPlugins(clientNames: string[]): Promise { + async installPlugins( + clientNames: string[], + scope: PluginScope = DEFAULT_PLUGIN_SCOPE, + ): 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 installed = await runPluginInstall(pluginClients, scope); analytics.wizardCapture('mcp plugins installed', { clients: installed, attempted: pluginClients.map((c) => c.name), + scope, }); return installed;