From 2ac74119d0b156ae8c9d864ddb81d6cc5b1daab3 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 11:56:03 +0200 Subject: [PATCH 01/22] refactor: encapsulate representation as stdout write --- packages/cli/src/commands/config.ts | 35 ++++------------ packages/cli/src/commands/whoami.ts | 46 +++++++------------- packages/cli/src/output/index.ts | 1 + packages/cli/src/output/print.ts | 65 +++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/output/print.ts diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index fab5874..71d13f4 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,5 +1,4 @@ import type { Argv } from 'yargs'; -import type { GlobalFlags } from './types.js'; import { readConfig, getConfigValue, @@ -7,9 +6,7 @@ import { resetConfig, validKeys, } from '@features/config/index.js'; -import { resolveFormat } from '@output/detect.js'; -import { formatJson } from '@output/json.js'; -import { formatTable } from '@output/table.js'; +import { print, extractFlags } from '@output/print.js'; export const configCommand = { command: 'config ', @@ -57,30 +54,14 @@ export const configCommand = { () => {}, (argv) => { const config = readConfig(); - const format = resolveFormat({ - json: argv.json as boolean | undefined, - output: argv.output as GlobalFlags['output'], + print({ + data: config, + columns: [ + { key: 'key', header: 'Key', width: 14 }, + { key: 'value', header: 'Value' }, + ], + flags: extractFlags(argv), }); - - if (format === 'json') { - console.log(formatJson(config)); - } else { - const entries = Object.entries(config); - if (entries.length === 0) { - console.log('No configuration set.'); - return; - } - const rows = entries.map(([key, value]) => ({ - key, - value: String(value), - })); - console.log( - formatTable(rows, [ - { key: 'key', header: 'Key', width: 14 }, - { key: 'value', header: 'Value' }, - ]), - ); - } }, ) .command( diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index f3d3117..63bfd4d 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,15 +1,11 @@ import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core'; -import { resolveFormat } from '@output/detect.js'; -import { formatJson } from '@output/json.js'; -import { formatTable } from '@output/table.js'; +import { print, extractFlags } from '@output/print.js'; export const whoamiCommand = { command: 'whoami', describe: 'Show current user, org, region, and token expiry', handler(argv: Record) { const profile = argv.profile as string | undefined; - const json = argv.json as boolean | undefined; - const output = argv.output as 'json' | 'table' | 'plain' | undefined; const token = loadPersistedToken(profile); if (!token) { @@ -34,30 +30,20 @@ export const whoamiCommand = { const exp = payload.exp as number | undefined; const expiresAt = exp ? new Date(exp * 1000).toISOString() : 'unknown'; - const info = { - email: email ?? 'unknown', - organization: org ?? 'unknown', - region: validation.region ?? 'unknown', - workspace: validation.workspace ?? 'unknown', - expires: expiresAt, - profile: profile ?? 'default', - }; - - const format = resolveFormat({ json, output }); - - if (format === 'json') { - console.log(formatJson(info)); - } else { - const rows = Object.entries(info).map(([key, value]) => ({ - key, - value: String(value), - })); - console.log( - formatTable(rows, [ - { key: 'key', header: 'Field', width: 14 }, - { key: 'value', header: 'Value' }, - ]), - ); - } + print({ + data: { + email: email ?? 'unknown', + organization: org ?? 'unknown', + region: validation.region ?? 'unknown', + workspace: validation.workspace ?? 'unknown', + expires: expiresAt, + profile: profile ?? 'default', + }, + columns: [ + { key: 'key', header: 'Field', width: 14 }, + { key: 'value', header: 'Value' }, + ], + flags: extractFlags(argv), + }); }, }; diff --git a/packages/cli/src/output/index.ts b/packages/cli/src/output/index.ts index 6983be3..4216bc8 100644 --- a/packages/cli/src/output/index.ts +++ b/packages/cli/src/output/index.ts @@ -1,3 +1,4 @@ export { resolveFormat, type OutputFormat } from './detect.js'; export { formatJson } from './json.js'; export { formatTable } from './table.js'; +export { print, extractFlags } from './print.js'; diff --git a/packages/cli/src/output/print.ts b/packages/cli/src/output/print.ts new file mode 100644 index 0000000..bda1a4d --- /dev/null +++ b/packages/cli/src/output/print.ts @@ -0,0 +1,65 @@ +import type { OutputFormat } from './detect.js'; +import { resolveFormat } from './detect.js'; +import { formatJson } from './json.js'; +import { formatTable } from './table.js'; + +type Column = { + key: keyof T & string; + header: string; + width?: number; +}; + +type FormatFlags = { + json?: boolean; + output?: OutputFormat; +}; + +type PrintTableOpts> = { + data: T[]; + columns: Column[]; + flags: FormatFlags; +}; + +type PrintKeyValueOpts = { + data: Record; + columns: [Column<{ key: string; value: string }>, Column<{ key: string; value: string }>]; + flags: FormatFlags; +}; + +function write(text: string): void { + process.stdout.write(text + '\n'); +} + +export function print>(opts: PrintTableOpts): void; +export function print(opts: PrintKeyValueOpts): void; +export function print>( + opts: PrintTableOpts | PrintKeyValueOpts, +): void { + const format = resolveFormat(opts.flags); + + if (format === 'json') { + write(formatJson(opts.data)); + return; + } + + if (Array.isArray(opts.data)) { + write(formatTable(opts.data, opts.columns as Column[])); + return; + } + + const entries = Object.entries(opts.data); + if (entries.length === 0) { + write('No results.'); + return; + } + + const rows = entries.map(([key, value]) => ({ key, value: String(value ?? '') })); + write(formatTable(rows, opts.columns as Column<{ key: string; value: string }>[])); +} + +export function extractFlags(argv: Record): FormatFlags { + return { + json: argv.json as boolean | undefined, + output: argv.output as OutputFormat | undefined, + }; +} From 96cf91798314693c33605b377663ce99557fc54e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 12:54:10 +0200 Subject: [PATCH 02/22] feat: extend integrations with MCP disconnectors --- .../__tests__/integrations/plugins.test.ts | 1 + .../core/src/integrations/claude/index.ts | 3 +- packages/core/src/integrations/claude/mcp.ts | 37 ++++++++++++++-- packages/core/src/integrations/codex/index.ts | 3 +- packages/core/src/integrations/codex/mcp.ts | 12 ++++-- .../core/src/integrations/cursor/index.ts | 3 +- packages/core/src/integrations/cursor/mcp.ts | 42 +++++++++++++++++-- packages/core/src/integrations/index.ts | 1 + packages/core/src/integrations/types.ts | 6 +++ 9 files changed, 95 insertions(+), 13 deletions(-) diff --git a/packages/core/__tests__/integrations/plugins.test.ts b/packages/core/__tests__/integrations/plugins.test.ts index 64072e9..56b24fb 100644 --- a/packages/core/__tests__/integrations/plugins.test.ts +++ b/packages/core/__tests__/integrations/plugins.test.ts @@ -18,6 +18,7 @@ const mockIntegration: IdeIntegration = { updatePlugin: vi.fn().mockResolvedValue(undefined), detectMcpStatuses: vi.fn().mockResolvedValue({}), connectMcpServer: vi.fn().mockResolvedValue(undefined), + disconnectMcpServer: vi.fn().mockResolvedValue(undefined), }; vi.mock('../../src/integrations/registry.js', () => ({ diff --git a/packages/core/src/integrations/claude/index.ts b/packages/core/src/integrations/claude/index.ts index a5ae41e..5ca741a 100644 --- a/packages/core/src/integrations/claude/index.ts +++ b/packages/core/src/integrations/claude/index.ts @@ -2,7 +2,7 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; import { detectPlugin, installPlugin, updatePlugin } from './plugins.js'; import { skillsDir } from './paths.js'; -import { detectMcpStatuses, connectMcpServer } from './mcp.js'; +import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js'; import { runOnboarding } from './onboarding.js'; import { prepare } from './prepare.js'; @@ -19,4 +19,5 @@ export const claudeIntegration: IdeIntegration = { updatePlugin, detectMcpStatuses, connectMcpServer, + disconnectMcpServer, }; diff --git a/packages/core/src/integrations/claude/mcp.ts b/packages/core/src/integrations/claude/mcp.ts index 7312774..cb7ad8a 100644 --- a/packages/core/src/integrations/claude/mcp.ts +++ b/packages/core/src/integrations/claude/mcp.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { execFile } from '../../exec/exec.js'; -import type { McpConnectOpts } from '../types.js'; +import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; import { type McpServerName, type McpServerStatus, @@ -26,7 +26,7 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { cwd: opts.projectDir, }); } catch { - // Not registered yet — that's fine + // Server may not be registered yet; the subsequent `mcp add` is idempotent } const headers: Record = { ...opts.serverHeaders }; @@ -53,6 +53,37 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { allowMcpToolsInSettings(opts.serverName, opts.projectDir); } +export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + await execFile('claude', ['mcp', 'remove', '--scope', 'project', opts.serverName], { + cwd: opts.projectDir, + }); + + removeMcpToolsFromSettings(opts.serverName, opts.projectDir); +} + +function removeMcpToolsFromSettings(serverName: string, projectDir: string): void { + const settingsPath = join(projectDir, '.claude', 'settings.local.json'); + if (!existsSync(settingsPath)) return; + + let settings: ClaudeSettings; + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; + } catch { + return; + } + + const toolPattern = `mcp__${serverName}__*`; + if (settings.permissions?.allow) { + settings.permissions.allow = settings.permissions.allow.filter((p) => p !== toolPattern); + } + + if (settings.enabledMcpjsonServers) { + settings.enabledMcpjsonServers = settings.enabledMcpjsonServers.filter((s) => s !== serverName); + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); +} + type ClaudeSettings = { permissions?: { allow?: string[]; @@ -75,7 +106,7 @@ function allowMcpToolsInSettings(serverName: string, projectDir: string): void { try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; } catch { - // overwrite + // Corrupt JSON — fall through to overwrite with valid settings } } diff --git a/packages/core/src/integrations/codex/index.ts b/packages/core/src/integrations/codex/index.ts index 2aac0a1..4dc64de 100644 --- a/packages/core/src/integrations/codex/index.ts +++ b/packages/core/src/integrations/codex/index.ts @@ -2,7 +2,7 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; import { detectPlugin, installPlugin, updatePlugin } from './plugins.js'; import { skillsDir } from './paths.js'; -import { detectMcpStatuses, connectMcpServer } from './mcp.js'; +import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js'; import { runOnboarding } from './onboarding.js'; import { prepare } from './prepare.js'; @@ -19,4 +19,5 @@ export const codexIntegration: IdeIntegration = { updatePlugin, detectMcpStatuses, connectMcpServer, + disconnectMcpServer, }; diff --git a/packages/core/src/integrations/codex/mcp.ts b/packages/core/src/integrations/codex/mcp.ts index 99b249a..ac40236 100644 --- a/packages/core/src/integrations/codex/mcp.ts +++ b/packages/core/src/integrations/codex/mcp.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { execFile } from '../../exec/exec.js'; -import type { McpConnectOpts } from '../types.js'; +import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; import { MCP_SERVERS, type McpServerName, @@ -22,7 +22,7 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { try { await execFile('codex', ['mcp', 'remove', opts.serverName]); } catch { - // Not registered yet — that's fine + // Server may not be registered yet; the subsequent `mcp add` is idempotent } await execFile('codex', ['mcp', 'add', opts.serverName, '--url', opts.serverUrl]); @@ -35,6 +35,10 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { patchHttpHeaders(opts.serverName, headers); } +export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + await execFile('codex', ['mcp', 'remove', opts.serverName]); +} + function getRegisteredMcpNames(projectDir: string): McpServerName[] { const names = Object.keys(MCP_SERVERS) as McpServerName[]; const paths = [globalConfigPath(), projectConfigPath(projectDir)]; @@ -45,6 +49,7 @@ function getRegisteredMcpNames(projectDir: string): McpServerName[] { const content = readFileSync(configPath, 'utf-8'); return content.includes(`[mcp_servers.${name}]`) || content.includes(`"${name}"`); } catch { + // Config file doesn't exist — server is not registered in this scope return false; } }), @@ -63,6 +68,7 @@ function getStoredAuthToken(serverName: McpServerName): string | null { const match = section.match(/"Authorization"\s*=\s*"Bearer\s+([^"]+)"/); return match?.[1] ?? null; } catch { + // Config file missing or unreadable — treat as no stored token return null; } } @@ -105,6 +111,6 @@ export function patchHttpHeaders( writeFileSync(configPath, content, 'utf-8'); } catch { - // Best-effort — MCP still works without the header + // Config may not exist yet if `codex mcp add` failed; MCP still works without custom headers } } diff --git a/packages/core/src/integrations/cursor/index.ts b/packages/core/src/integrations/cursor/index.ts index 23e002d..342caca 100644 --- a/packages/core/src/integrations/cursor/index.ts +++ b/packages/core/src/integrations/cursor/index.ts @@ -1,6 +1,6 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; -import { detectMcpStatuses, connectMcpServer } from './mcp.js'; +import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js'; import { runOnboarding } from './onboarding.js'; import { detectPlugin, installPlugin, updatePlugin } from './plugins.js'; import { skillsDir } from './paths.js'; @@ -19,4 +19,5 @@ export const cursorIntegration: IdeIntegration = { updatePlugin, detectMcpStatuses, connectMcpServer, + disconnectMcpServer, }; diff --git a/packages/core/src/integrations/cursor/mcp.ts b/packages/core/src/integrations/cursor/mcp.ts index 8d0f69c..1b54154 100644 --- a/packages/core/src/integrations/cursor/mcp.ts +++ b/packages/core/src/integrations/cursor/mcp.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { execFile } from '../../exec/exec.js'; -import type { McpConnectOpts } from '../types.js'; +import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; import { type McpServerName, type McpServerStatus, @@ -35,7 +35,41 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { try { await execFile('cursor', ['agent', 'mcp', 'enable', opts.serverName]); } catch { - // cursor agent CLI may not be available + // `cursor` CLI is not always installed; config files were already written above + } +} + +export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeMcpEntry(mcpConfigPath(opts.projectDir), opts.serverName); + removeMcpEntry(globalConfigPath(), opts.serverName); + removeCliPermission(cliConfigPath(opts.projectDir), opts.serverName); +} + +function removeMcpEntry(configPath: string, serverName: string): void { + if (!existsSync(configPath)) return; + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + const mcpServers = (config.mcpServers ?? {}) as Record; + delete mcpServers[serverName]; + config.mcpServers = mcpServers; + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } catch { + // Corrupt or unreadable config — server is effectively unregistered already + } +} + +function removeCliPermission(configPath: string, serverName: string): void { + if (!existsSync(configPath)) return; + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + const permissions = (config.permissions ?? {}) as Record; + const allow = (permissions.allow ?? []) as string[]; + const rule = `Mcp(${serverName}:*)`; + permissions.allow = allow.filter((r) => r !== rule); + config.permissions = permissions; + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } catch { + // Corrupt or unreadable config — stale permission rules are harmless } } @@ -45,7 +79,7 @@ function writeMcpEntry(configPath: string, serverName: string, entry: unknown): try { config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; } catch { - // overwrite if corrupt + // Corrupt JSON — fall through to overwrite with valid config } } else { mkdirSync(join(configPath, '..'), { recursive: true }); @@ -64,7 +98,7 @@ function writeCliPermission(configPath: string, serverName: string): void { try { config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; } catch { - // overwrite if corrupt + // Corrupt JSON — fall through to overwrite with valid config } } else { mkdirSync(join(configPath, '..'), { recursive: true }); diff --git a/packages/core/src/integrations/index.ts b/packages/core/src/integrations/index.ts index e12a4fb..087ccac 100644 --- a/packages/core/src/integrations/index.ts +++ b/packages/core/src/integrations/index.ts @@ -1,6 +1,7 @@ export type { IdeIntegration, McpConnectOpts, + McpDisconnectOpts, OnboardingOpts, OnboardingCallbacks, InstalledPlugin, diff --git a/packages/core/src/integrations/types.ts b/packages/core/src/integrations/types.ts index 84ee861..1649a7d 100644 --- a/packages/core/src/integrations/types.ts +++ b/packages/core/src/integrations/types.ts @@ -16,6 +16,11 @@ export type McpConnectOpts = { accessToken?: string; }; +export type McpDisconnectOpts = { + serverName: string; + projectDir: string; +}; + export type OnboardingCallbacks = { onStatus: (text: string) => void; onStdout: (line: string) => void; @@ -49,6 +54,7 @@ export type IdeIntegration = { detectMcpStatuses: (projectDir: string) => Promise>; connectMcpServer: (opts: McpConnectOpts) => Promise; + disconnectMcpServer: (opts: McpDisconnectOpts) => Promise; runOnboarding: (opts: OnboardingOpts, callbacks: OnboardingCallbacks) => ChildProcess | null; From e5e3e9542ec4ff395bd86e1a45bb3159b2f61a83 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 12:58:00 +0200 Subject: [PATCH 03/22] chore: extend output with message and error --- packages/cli/src/output/index.ts | 2 +- packages/cli/src/output/print.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/output/index.ts b/packages/cli/src/output/index.ts index 4216bc8..b735efa 100644 --- a/packages/cli/src/output/index.ts +++ b/packages/cli/src/output/index.ts @@ -1,4 +1,4 @@ export { resolveFormat, type OutputFormat } from './detect.js'; export { formatJson } from './json.js'; export { formatTable } from './table.js'; -export { print, extractFlags } from './print.js'; +export { print, message, error, extractFlags } from './print.js'; diff --git a/packages/cli/src/output/print.ts b/packages/cli/src/output/print.ts index bda1a4d..716befe 100644 --- a/packages/cli/src/output/print.ts +++ b/packages/cli/src/output/print.ts @@ -26,10 +26,14 @@ type PrintKeyValueOpts = { flags: FormatFlags; }; -function write(text: string): void { +export function message(text: string): void { process.stdout.write(text + '\n'); } +export function error(text: string): void { + process.stderr.write(text + '\n'); +} + export function print>(opts: PrintTableOpts): void; export function print(opts: PrintKeyValueOpts): void; export function print>( @@ -38,23 +42,23 @@ export function print>( const format = resolveFormat(opts.flags); if (format === 'json') { - write(formatJson(opts.data)); + message(formatJson(opts.data)); return; } if (Array.isArray(opts.data)) { - write(formatTable(opts.data, opts.columns as Column[])); + message(formatTable(opts.data, opts.columns as Column[])); return; } const entries = Object.entries(opts.data); if (entries.length === 0) { - write('No results.'); + message('No results.'); return; } const rows = entries.map(([key, value]) => ({ key, value: String(value ?? '') })); - write(formatTable(rows, opts.columns as Column<{ key: string; value: string }>[])); + message(formatTable(rows, opts.columns as Column<{ key: string; value: string }>[])); } export function extractFlags(argv: Record): FormatFlags { From 5a7eedfde6c1607f8c7201aaf2a9d1a9da16b2d3 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 13:01:35 +0200 Subject: [PATCH 04/22] chore: encapsulate exit code --- packages/cli/src/output/index.ts | 2 +- packages/cli/src/output/print.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/output/index.ts b/packages/cli/src/output/index.ts index b735efa..5264659 100644 --- a/packages/cli/src/output/index.ts +++ b/packages/cli/src/output/index.ts @@ -1,4 +1,4 @@ export { resolveFormat, type OutputFormat } from './detect.js'; export { formatJson } from './json.js'; export { formatTable } from './table.js'; -export { print, message, error, extractFlags } from './print.js'; +export { print, message, error, fail, extractFlags } from './print.js'; diff --git a/packages/cli/src/output/print.ts b/packages/cli/src/output/print.ts index 716befe..8312797 100644 --- a/packages/cli/src/output/print.ts +++ b/packages/cli/src/output/print.ts @@ -34,6 +34,11 @@ export function error(text: string): void { process.stderr.write(text + '\n'); } +export function fail(text: string): void { + error(text); + process.exitCode = 1; +} + export function print>(opts: PrintTableOpts): void; export function print(opts: PrintKeyValueOpts): void; export function print>( From 4cb4d0b5db5f55b0943fbdcda1c97ab859f1d04f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 13:01:54 +0200 Subject: [PATCH 05/22] chore: refactor existing commands --- packages/cli/src/commands/config.ts | 16 +++++++--------- packages/cli/src/commands/login.ts | 10 +++++----- packages/cli/src/commands/logout.ts | 3 ++- packages/cli/src/commands/whoami.ts | 8 +++----- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 71d13f4..308aacc 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -6,7 +6,7 @@ import { resetConfig, validKeys, } from '@features/config/index.js'; -import { print, extractFlags } from '@output/print.js'; +import { print, message, fail, extractFlags } from '@output/print.js'; export const configCommand = { command: 'config ', @@ -23,10 +23,9 @@ export const configCommand = { (argv) => { try { setConfigValue(argv.key as string, argv.value as string); - console.log(`Set ${argv.key} = ${argv.value}`); + message(`Set ${argv.key} = ${argv.value}`); } catch (err) { - console.error((err as Error).message); - process.exitCode = 1; + fail((err as Error).message); } }, ) @@ -38,13 +37,12 @@ export const configCommand = { try { const value = getConfigValue(argv.key as string); if (value === undefined) { - console.log(`${argv.key} is not set`); + message(`${argv.key} is not set`); } else { - console.log(value); + message(value); } } catch (err) { - console.error((err as Error).message); - process.exitCode = 1; + fail((err as Error).message); } }, ) @@ -70,7 +68,7 @@ export const configCommand = { () => {}, () => { resetConfig(); - console.log('Configuration reset.'); + message('Configuration reset.'); }, ) .demandCommand( diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 9a63720..bb868b4 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -1,4 +1,5 @@ import { authenticate } from '@spotify-confidence/core'; +import { message, fail } from '@output/print.js'; export const loginCommand = { command: 'login', @@ -7,15 +8,14 @@ export const loginCommand = { const profile = argv.profile as string | undefined; try { - console.log('Opening browser for authentication...'); + message('Opening browser for authentication...'); const result = await authenticate('login', undefined, profile, (url) => { - console.log(`If the browser did not open, visit:\n${url}`); + message(`If the browser did not open, visit:\n${url}`); }); - console.log(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); + message(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); } catch (err) { const msg = err instanceof Error ? err.message : 'Authentication failed'; - console.error(`Login failed: ${msg}`); - process.exitCode = 1; + fail(`Login failed: ${msg}`); } }, }; diff --git a/packages/cli/src/commands/logout.ts b/packages/cli/src/commands/logout.ts index 3450f33..c04ee4c 100644 --- a/packages/cli/src/commands/logout.ts +++ b/packages/cli/src/commands/logout.ts @@ -1,4 +1,5 @@ import { clearTokens } from '@spotify-confidence/core'; +import { message } from '@output/print.js'; export const logoutCommand = { command: 'logout', @@ -6,6 +7,6 @@ export const logoutCommand = { handler(argv: Record) { const profile = argv.profile as string | undefined; clearTokens(profile); - console.log('Logged out.'); + message('Logged out.'); }, }; diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 63bfd4d..93ef0be 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,5 +1,5 @@ import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core'; -import { print, extractFlags } from '@output/print.js'; +import { print, fail, extractFlags } from '@output/print.js'; export const whoamiCommand = { command: 'whoami', @@ -9,15 +9,13 @@ export const whoamiCommand = { const token = loadPersistedToken(profile); if (!token) { - console.error('Not logged in. Run "confidence login" first.'); - process.exitCode = 1; + fail('Not logged in. Run "confidence login" first.'); return; } const validation = validateToken(token); if (!validation.valid) { - console.error('Token is expired. Run "confidence login" to re-authenticate.'); - process.exitCode = 1; + fail('Token is expired. Run "confidence login" to re-authenticate.'); return; } From c4b1c5c0305d85c44430f6b9ce19be4641985b17 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 13:06:08 +0200 Subject: [PATCH 06/22] feat: implement MCP commands --- packages/cli/bin/cli.ts | 2 + packages/cli/package.json | 2 + packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/mcp.ts | 112 +++++++++++++++ packages/cli/src/features/mcp/actions.ts | 124 ++++++++++++++++ packages/cli/src/features/mcp/index.ts | 7 + packages/cli/src/features/mcp/resolve-ide.ts | 18 +++ pnpm-lock.yaml | 143 +++++++++++++++++++ 8 files changed, 409 insertions(+) create mode 100644 packages/cli/src/commands/mcp.ts create mode 100644 packages/cli/src/features/mcp/actions.ts create mode 100644 packages/cli/src/features/mcp/index.ts create mode 100644 packages/cli/src/features/mcp/resolve-ide.ts diff --git a/packages/cli/bin/cli.ts b/packages/cli/bin/cli.ts index a3048a5..43d5ba6 100644 --- a/packages/cli/bin/cli.ts +++ b/packages/cli/bin/cli.ts @@ -11,6 +11,7 @@ import { flagsCommand, recordingsCommand, quickstartCommand, + mcpCommand, } from '../src/commands/index.js'; const APP_NAME = 'confidence'; @@ -68,6 +69,7 @@ const cli = yargs(hideBin(process.argv)) .command(flagsCommand) .command(recordingsCommand) .command(quickstartCommand) + .command(mcpCommand) .demandCommand(1, 'Run "confidence --help" to see available commands.') .strict() .help() diff --git a/packages/cli/package.json b/packages/cli/package.json index 9816f22..19fd3b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,9 @@ "qa": "pnpm typecheck && pnpm lint && pnpm test" }, "dependencies": { + "@inquirer/select": "^5.2.5", "@spotify-confidence/quickstart": "workspace:*", + "ora": "^9.4.1", "yargs": "^18.2.0" }, "devDependencies": { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index f111af3..8d19c69 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -6,4 +6,5 @@ export { eventsCommand } from './events.js'; export { flagsCommand } from './flags.js'; export { recordingsCommand } from './recordings.js'; export { quickstartCommand } from './quickstart.js'; +export { mcpCommand } from './mcp.js'; export type { GlobalFlags } from './types.js'; diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000..ac69ec6 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,112 @@ +import type { Argv } from 'yargs'; +import { print, fail, extractFlags } from '@output/print.js'; +import { getAvailableMcpServers } from '@spotify-confidence/core'; +import { + resolveIde, + installMcpServers, + uninstallMcpServers, + getMcpStatuses, + refreshMcpAuth, +} from '@features/mcp/index.js'; + +const STATUS_LABELS: Record = { + connected: 'Connected', + installed: 'Installed (unreachable)', + 'auth-expired': 'Auth expired', + 'not-installed': 'Not installed', +}; + +function resolveProjectDir(argv: Record): string { + return (argv.project as string | undefined) ?? process.cwd(); +} + +export const mcpCommand = { + command: 'mcp ', + describe: 'Manage Confidence MCP server connections', + builder(yargs: Argv) { + return yargs + .command( + 'install', + 'Install Confidence MCP servers for your AI coding agent', + () => {}, + async (argv) => { + try { + const ideId = await resolveIde(); + await installMcpServers(ideId, resolveProjectDir(argv)); + } catch (err) { + fail((err as Error).message); + } + }, + ) + .command( + 'uninstall', + 'Remove Confidence MCP servers from your AI coding agent', + () => {}, + async (argv) => { + try { + const ideId = await resolveIde(); + await uninstallMcpServers(ideId, resolveProjectDir(argv)); + } catch (err) { + fail((err as Error).message); + } + }, + ) + .command( + 'status', + 'Show MCP server connection status', + () => {}, + async (argv) => { + try { + const ideId = await resolveIde(); + const statuses = await getMcpStatuses(ideId, resolveProjectDir(argv)); + const rows = Object.entries(statuses).map(([server, status]) => ({ + server, + status: STATUS_LABELS[status] ?? status, + })); + print({ + data: rows, + columns: [ + { key: 'server', header: 'Server', width: 20 }, + { key: 'status', header: 'Status' }, + ], + flags: extractFlags(argv), + }); + } catch (err) { + fail((err as Error).message); + } + }, + ) + .command( + 'list', + 'List available Confidence MCP servers', + () => {}, + (argv) => { + const servers = getAvailableMcpServers(); + print({ + data: servers.map((s) => ({ name: s.name, url: s.url })), + columns: [ + { key: 'name', header: 'Server', width: 20 }, + { key: 'url', header: 'URL' }, + ], + flags: extractFlags(argv), + }); + }, + ) + .command( + 'auth', + 'Re-authenticate MCP servers with a fresh token', + () => {}, + async (argv) => { + try { + const ideId = await resolveIde(); + await refreshMcpAuth(ideId, resolveProjectDir(argv)); + } catch (err) { + fail((err as Error).message); + } + }, + ) + .demandCommand(1, 'Available actions: install, uninstall, status, list, auth') + .strict(); + }, + handler() {}, +}; diff --git a/packages/cli/src/features/mcp/actions.ts b/packages/cli/src/features/mcp/actions.ts new file mode 100644 index 0000000..3128b4f --- /dev/null +++ b/packages/cli/src/features/mcp/actions.ts @@ -0,0 +1,124 @@ +import ora from 'ora'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { message, error } from '@output/print.js'; +import { + getIntegration, + getAvailableMcpServers, + MCP_SERVERS, + loadPersistedToken, + authenticate, + validateToken, + type McpServerName, + type McpServerStatus, +} from '@spotify-confidence/core'; + +export async function installMcpServers(ideId: IdeId, projectDir: string): Promise { + const integration = getIntegration(ideId); + const servers = getAvailableMcpServers(); + + const token = await resolveAuthToken(); + if (!token) return; + + const spinner = ora(`Installing MCP servers for ${integration.name}...`).start(); + + for (const server of servers) { + const serverDef = MCP_SERVERS[server.name]; + spinner.text = `Connecting ${server.name}...`; + + try { + await integration.connectMcpServer({ + serverName: server.name, + serverUrl: server.url, + serverType: serverDef.type, + serverHeaders: serverDef.headers, + projectDir, + accessToken: token, + }); + } catch (err) { + spinner.fail(`Failed to connect ${server.name}: ${(err as Error).message}`); + return; + } + } + + spinner.succeed(`MCP servers installed for ${integration.name}`); +} + +export async function uninstallMcpServers(ideId: IdeId, projectDir: string): Promise { + const integration = getIntegration(ideId); + const servers = getAvailableMcpServers(); + const spinner = ora(`Removing MCP servers from ${integration.name}...`).start(); + + for (const server of servers) { + spinner.text = `Removing ${server.name}...`; + try { + await integration.disconnectMcpServer({ + serverName: server.name, + projectDir, + }); + } catch { + // best-effort — server may not have been installed + } + } + + spinner.succeed(`MCP servers removed from ${integration.name}`); +} + +export async function getMcpStatuses( + ideId: IdeId, + projectDir: string, +): Promise> { + const integration = getIntegration(ideId); + return integration.detectMcpStatuses(projectDir); +} + +export async function refreshMcpAuth(ideId: IdeId, projectDir: string): Promise { + const integration = getIntegration(ideId); + const servers = getAvailableMcpServers(); + + const token = await resolveAuthToken({ forceNew: true }); + if (!token) return; + + const spinner = ora('Updating MCP server credentials...').start(); + + for (const server of servers) { + const serverDef = MCP_SERVERS[server.name]; + spinner.text = `Updating ${server.name}...`; + + try { + await integration.connectMcpServer({ + serverName: server.name, + serverUrl: server.url, + serverType: serverDef.type, + serverHeaders: serverDef.headers, + projectDir, + accessToken: token, + }); + } catch (err) { + spinner.fail(`Failed to update ${server.name}: ${(err as Error).message}`); + return; + } + } + + spinner.succeed('MCP server credentials updated'); +} + +async function resolveAuthToken(opts?: { forceNew?: boolean }): Promise { + if (!opts?.forceNew) { + const existing = loadPersistedToken(); + if (existing) { + const { valid } = validateToken(existing); + if (valid) return existing; + } + } + + try { + message('Authentication required. Opening browser...'); + const result = await authenticate('login', undefined, undefined, (url) => { + message(`If the browser did not open, visit:\n${url}`); + }); + return result.accessToken; + } catch (err) { + error(`Authentication failed: ${(err as Error).message}`); + return null; + } +} diff --git a/packages/cli/src/features/mcp/index.ts b/packages/cli/src/features/mcp/index.ts new file mode 100644 index 0000000..0289770 --- /dev/null +++ b/packages/cli/src/features/mcp/index.ts @@ -0,0 +1,7 @@ +export { resolveIde } from './resolve-ide.js'; +export { + installMcpServers, + uninstallMcpServers, + getMcpStatuses, + refreshMcpAuth, +} from './actions.js'; diff --git a/packages/cli/src/features/mcp/resolve-ide.ts b/packages/cli/src/features/mcp/resolve-ide.ts new file mode 100644 index 0000000..414c70b --- /dev/null +++ b/packages/cli/src/features/mcp/resolve-ide.ts @@ -0,0 +1,18 @@ +import select from '@inquirer/select'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { getConfigValue, setConfigValue, getIntegrations } from '@spotify-confidence/core'; + +export async function resolveIde(): Promise { + const saved = getConfigValue('ide') as IdeId | undefined; + if (saved) return saved; + + const integrations = getIntegrations(); + const chosen = await select({ + message: 'Which AI coding agent are you using?', + choices: integrations.map((i) => ({ name: i.name, value: i.id })), + }); + + setConfigValue('ide', chosen); + + return chosen; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1629c0..6124987 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,9 +46,15 @@ importers: packages/cli: dependencies: + '@inquirer/select': + specifier: ^5.2.5 + version: 5.2.5(@types/node@26.6.2) '@spotify-confidence/quickstart': specifier: workspace:* version: link:../quickstart + ora: + specifier: ^9.4.1 + version: 9.4.1 yargs: specifier: ^18.2.0 version: 18.2.0 @@ -632,6 +638,10 @@ packages: resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/confirm@6.3.0': resolution: {integrity: sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -650,10 +660,32 @@ packages: '@types/node': optional: true + '@inquirer/core@12.0.3': + resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@2.0.8': resolution: {integrity: sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/select@5.2.5': + resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/type@4.1.0': resolution: {integrity: sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -663,6 +695,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.1.1': + resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1328,6 +1369,10 @@ packages: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@3.4.0: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} @@ -1734,6 +1779,10 @@ packages: engines: {node: '>=20'} hasBin: true + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} @@ -1804,6 +1853,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1818,6 +1871,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1869,10 +1926,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@9.4.1: + resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} + engines: {node: '>=20'} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -1986,6 +2051,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + rettime@0.11.11: resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} @@ -2073,6 +2142,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -2384,6 +2457,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -2783,6 +2860,8 @@ snapshots: '@inquirer/ansi@2.0.7': {} + '@inquirer/ansi@2.0.8': {} + '@inquirer/confirm@6.3.0(@types/node@26.6.2)': dependencies: '@inquirer/core': 12.0.1(@types/node@26.6.2) @@ -2802,12 +2881,39 @@ snapshots: optionalDependencies: '@types/node': 26.6.2 + '@inquirer/core@12.0.3(@types/node@26.6.2)': + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.6.2) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 26.6.2 + '@inquirer/figures@2.0.8': {} + '@inquirer/figures@2.0.9': {} + + '@inquirer/select@5.2.5(@types/node@26.6.2)': + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@26.6.2) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.6.2) + optionalDependencies: + '@types/node': 26.6.2 + '@inquirer/type@4.1.0(@types/node@26.6.2)': optionalDependencies: '@types/node': 26.6.2 + '@inquirer/type@4.1.1(@types/node@26.6.2)': + optionalDependencies: + '@types/node': 26.6.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3351,6 +3457,10 @@ snapshots: dependencies: restore-cursor: 4.0.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@3.4.0: {} cli-truncate@6.1.1: @@ -3745,6 +3855,8 @@ snapshots: is-in-ci@2.0.0: {} + is-interactive@2.0.0: {} + is-node-process@1.2.0: {} is-plain-obj@4.1.0: {} @@ -3799,6 +3911,11 @@ snapshots: dependencies: p-locate: 5.0.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.2.0 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3811,6 +3928,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.12 @@ -3864,6 +3983,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3873,6 +3996,17 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@9.4.1: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.2 + outvariant@1.4.3: {} p-limit@3.1.0: @@ -3961,6 +4095,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + rettime@0.11.11: {} rolldown-plugin-dts@0.27.14(rolldown@1.2.6)(typescript@6.0.3): @@ -4067,6 +4206,8 @@ snapshots: std-env@4.2.0: {} + stdin-discarder@0.3.2: {} + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -4311,6 +4452,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.2.0: {} + yoga-layout@3.2.1: {} yuku-ast@0.8.7: From d5a5f1c69cf0628f5c14b89c8217b11d1a026531 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 13:24:40 +0200 Subject: [PATCH 07/22] test: cover feature with tests --- packages/cli/__tests__/commands/mcp.test.ts | 139 ++++++++++++++++++ .../features/mcp/resolve-ide.test.ts | 52 +++++++ packages/cli/__tests__/helpers/capture.ts | 30 ++++ .../cli/__tests__/helpers/mock-integration.ts | 19 +++ packages/cli/__tests__/output/print.test.ts | 120 +++++++++++++++ .../integrations/mcp-disconnect.test.ts | 128 ++++++++++++++++ packages/testing/src/scaffold/ide-scaffold.ts | 33 +++++ packages/testing/src/scaffold/index.ts | 5 + 8 files changed, 526 insertions(+) create mode 100644 packages/cli/__tests__/commands/mcp.test.ts create mode 100644 packages/cli/__tests__/features/mcp/resolve-ide.test.ts create mode 100644 packages/cli/__tests__/helpers/capture.ts create mode 100644 packages/cli/__tests__/helpers/mock-integration.ts create mode 100644 packages/cli/__tests__/output/print.test.ts create mode 100644 packages/core/__tests__/integrations/mcp-disconnect.test.ts create mode 100644 packages/testing/src/scaffold/ide-scaffold.ts diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts new file mode 100644 index 0000000..220302b --- /dev/null +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -0,0 +1,139 @@ +import yargs from 'yargs'; +import { buildTestJwt } from '@spotify-confidence/testing/auth'; +import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { mcpCommand } from '@commands/mcp.js'; +import { captureOutput } from '../helpers/capture.js'; +import { createMockIntegration } from '../helpers/mock-integration.js'; + +const mockIntegration = createMockIntegration({ + detectMcpStatuses: vi.fn().mockResolvedValue({ + 'confidence-flags': 'connected', + 'confidence-docs': 'connected', + }), +}); + +const mockAuthenticate = vi.fn().mockResolvedValue({ + accessToken: buildTestJwt({ email: 'test@example.com' }), + region: 'EU', +}); + +vi.mock('@spotify-confidence/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getIntegration: () => mockIntegration, + getIntegrations: () => [mockIntegration], + authenticate: (...args: unknown[]) => mockAuthenticate(...args), + }; +}); + +vi.mock('@inquirer/select', () => ({ + default: vi.fn().mockResolvedValue('claude'), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function run(args: string[]) { + return yargs(args) + .option('json', { type: 'boolean', default: false }) + .option('output', { type: 'string' }) + .command(mcpCommand) + .parse(); +} + +describe('mcp list', () => { + it('outputs available servers as JSON', async () => { + using output = captureOutput(); + + await run(['mcp', 'list', '--json']); + + const parsed = JSON.parse(output.stdout); + expect(parsed.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'confidence-flags' }), + expect.objectContaining({ name: 'confidence-docs' }), + ]), + ); + }); + + it('outputs available servers as a table', async () => { + using output = captureOutput(); + + await run(['mcp', 'list', '--output', 'table']); + + expect(output.stdout).toContain('confidence-flags'); + expect(output.stdout).toContain('confidence-docs'); + expect(output.stdout).toContain('Server'); + }); +}); + +describe('mcp status', () => { + it('outputs server statuses as JSON', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + + await run(['mcp', 'status', '--json']); + + const parsed = JSON.parse(output.stdout); + expect(parsed.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ server: 'confidence-flags', status: 'Connected' }), + expect.objectContaining({ server: 'confidence-docs', status: 'Connected' }), + ]), + ); + }); +}); + +describe('mcp install', () => { + it('connects all MCP servers', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'install']); + + expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); + expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ serverName: 'confidence-flags' }), + ); + expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ serverName: 'confidence-docs' }), + ); + }); + + it('reports failure to stderr when a connection fails', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + vi.mocked(mockIntegration.connectMcpServer).mockRejectedValueOnce( + new Error('connection refused'), + ); + + await run(['mcp', 'install']); + + expect(output.stderr).toContain('connection refused'); + }); +}); + +describe('mcp uninstall', () => { + it('disconnects all MCP servers', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'uninstall']); + + expect(mockIntegration.disconnectMcpServer).toHaveBeenCalledTimes(2); + }); +}); + +describe('mcp auth', () => { + it('re-authenticates and reconnects all servers', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'auth']); + + expect(mockAuthenticate).toHaveBeenCalled(); + expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/__tests__/features/mcp/resolve-ide.test.ts b/packages/cli/__tests__/features/mcp/resolve-ide.test.ts new file mode 100644 index 0000000..33d23d0 --- /dev/null +++ b/packages/cli/__tests__/features/mcp/resolve-ide.test.ts @@ -0,0 +1,52 @@ +import { setConfigValue } from '@spotify-confidence/core'; +import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; + +vi.mock('@inquirer/select', () => ({ + default: vi.fn(), +})); + +import select from '@inquirer/select'; + +describe('resolveIde', () => { + async function loadResolveIde() { + const mod = await import('@features/mcp/resolve-ide.js'); + return mod.resolveIde; + } + + it('returns the saved IDE from config without prompting', async () => { + using _config = prepareAuthTokens('none'); + setConfigValue('ide', 'cursor'); + const sut = await loadResolveIde(); + + const result = await sut(); + + expect(result).toBe('cursor'); + expect(select).not.toHaveBeenCalled(); + }); + + it('prompts when no IDE is saved and returns the selection', async () => { + using _config = prepareAuthTokens('none'); + vi.mocked(select).mockResolvedValueOnce('codex'); + const sut = await loadResolveIde(); + + const result = await sut(); + + expect(result).toBe('codex'); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('AI coding agent'), + }), + ); + }); + + it('persists the selected IDE to config', async () => { + using _config = prepareAuthTokens('none'); + vi.mocked(select).mockResolvedValueOnce('claude'); + const sut = await loadResolveIde(); + + await sut(); + + const { getConfigValue } = await import('@spotify-confidence/core'); + expect(getConfigValue('ide')).toBe('claude'); + }); +}); diff --git a/packages/cli/__tests__/helpers/capture.ts b/packages/cli/__tests__/helpers/capture.ts new file mode 100644 index 0000000..9a032bc --- /dev/null +++ b/packages/cli/__tests__/helpers/capture.ts @@ -0,0 +1,30 @@ +export function captureOutput() { + let stdout = ''; + let stderr = ''; + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + + const prevExitCode = process.exitCode; + + return { + get stdout() { + return stdout; + }, + get stderr() { + return stderr; + }, + [Symbol.dispose]() { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + process.exitCode = prevExitCode; + }, + }; +} diff --git a/packages/cli/__tests__/helpers/mock-integration.ts b/packages/cli/__tests__/helpers/mock-integration.ts new file mode 100644 index 0000000..cd83e1b --- /dev/null +++ b/packages/cli/__tests__/helpers/mock-integration.ts @@ -0,0 +1,19 @@ +import type { IdeIntegration } from '@spotify-confidence/core'; + +export function createMockIntegration(overrides?: Partial): IdeIntegration { + return { + id: 'claude', + name: 'Claude Code', + launchChat: vi.fn(), + runOnboarding: vi.fn().mockReturnValue(null), + prepare: vi.fn().mockResolvedValue(undefined), + skillsDir: vi.fn().mockReturnValue('/project/.claude/skills'), + detectPlugin: vi.fn().mockResolvedValue(null), + installPlugin: vi.fn().mockResolvedValue(undefined), + updatePlugin: vi.fn().mockResolvedValue(undefined), + detectMcpStatuses: vi.fn().mockResolvedValue({}), + connectMcpServer: vi.fn().mockResolvedValue(undefined), + disconnectMcpServer: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} diff --git a/packages/cli/__tests__/output/print.test.ts b/packages/cli/__tests__/output/print.test.ts new file mode 100644 index 0000000..55f25d4 --- /dev/null +++ b/packages/cli/__tests__/output/print.test.ts @@ -0,0 +1,120 @@ +import { message, error, fail, extractFlags, print } from '@output/print.js'; + +describe('message', () => { + it('writes text to stdout with a trailing newline', () => { + const spy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + message('hello world'); + + expect(spy).toHaveBeenCalledWith('hello world\n'); + }); +}); + +describe('error', () => { + it('writes text to stderr with a trailing newline', () => { + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + error('something went wrong'); + + expect(spy).toHaveBeenCalledWith('something went wrong\n'); + }); +}); + +describe('fail', () => { + afterEach(() => { + process.exitCode = 0; + }); + + it('writes to stderr and sets exitCode to 1', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + fail('fatal error'); + + expect(process.exitCode).toBe(1); + }); +}); + +describe('extractFlags', () => { + it('extracts json and output from argv', () => { + const sut = extractFlags({ json: true, output: 'table', other: 'ignored' }); + + expect(sut).toEqual({ json: true, output: 'table' }); + }); + + it('returns undefined for missing flags', () => { + expect(extractFlags({})).toEqual({ json: undefined, output: undefined }); + }); +}); + +describe('print', () => { + let spy: ReturnType; + + beforeEach(() => { + spy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + }); + + it('renders an array as a table', () => { + print({ + data: [ + { name: 'confidence-flags', url: 'https://example.com/flags' }, + { name: 'confidence-docs', url: 'https://example.com/docs' }, + ], + columns: [ + { key: 'name', header: 'Server', width: 20 }, + { key: 'url', header: 'URL' }, + ], + flags: { output: 'table' }, + }); + + const output = (spy.mock.calls[0] as string[])[0]; + expect(output).toContain('Server'); + expect(output).toContain('confidence-flags'); + expect(output).toContain('confidence-docs'); + }); + + it('renders an array as JSON', () => { + const data = [{ name: 'test-server', url: 'https://example.com' }]; + + print({ + data, + columns: [ + { key: 'name', header: 'Server' }, + { key: 'url', header: 'URL' }, + ], + flags: { json: true }, + }); + + const output = (spy.mock.calls[0] as string[])[0]; + const parsed = JSON.parse(output); + expect(parsed.data).toEqual(data); + }); + + it('renders a key-value object as a table', () => { + print({ + data: { email: 'user@example.com', region: 'EU' }, + columns: [ + { key: 'key', header: 'Field', width: 10 }, + { key: 'value', header: 'Value' }, + ], + flags: { output: 'table' }, + }); + + const output = (spy.mock.calls[0] as string[])[0]; + expect(output).toContain('email'); + expect(output).toContain('user@example.com'); + }); + + it('prints "No results." for an empty object', () => { + print({ + data: {}, + columns: [ + { key: 'key', header: 'Key' }, + { key: 'value', header: 'Value' }, + ], + flags: { output: 'table' }, + }); + + const output = (spy.mock.calls[0] as string[])[0]; + expect(output).toContain('No results.'); + }); +}); diff --git a/packages/core/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts new file mode 100644 index 0000000..c21543b --- /dev/null +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createProjectDir } from '@spotify-confidence/testing/scaffold'; +import { + writeClaudeSettings, + writeCursorMcpConfig, + writeCursorCliConfig, +} from '@spotify-confidence/testing/scaffold'; + +const execFile = vi.fn().mockResolvedValue({ stdout: '' }); + +vi.mock('../../src/exec/exec.js', () => ({ + execFile: (cmd: string, args: string[], opts?: Record) => + execFile(cmd, args, opts) as Promise<{ stdout: string }>, +})); + +beforeEach(() => { + execFile.mockReset().mockResolvedValue({ stdout: '' }); +}); + +describe('claude disconnectMcpServer', () => { + async function loadDisconnect() { + const mod = await import('@integrations/claude/mcp.js'); + return mod.disconnectMcpServer; + } + + it('calls claude mcp remove with the server name', async () => { + const sut = await loadDisconnect(); + + await sut({ serverName: 'confidence-flags', projectDir: '/project' }); + + expect(execFile).toHaveBeenCalledWith( + 'claude', + ['mcp', 'remove', '--scope', 'project', 'confidence-flags'], + { cwd: '/project' }, + ); + }); + + it('removes the tool permission from settings.local.json', async () => { + using project = createProjectDir('empty'); + writeClaudeSettings(project.path, { + permissions: { allow: ['mcp__confidence-flags__*', 'mcp__other__*'] }, + enabledMcpjsonServers: ['confidence-flags', 'other-server'], + }); + const sut = await loadDisconnect(); + + await sut({ serverName: 'confidence-flags', projectDir: project.path }); + + const settings = JSON.parse( + readFileSync(join(project.path, '.claude', 'settings.local.json'), 'utf-8'), + ); + expect(settings.permissions.allow).toEqual(['mcp__other__*']); + expect(settings.enabledMcpjsonServers).toEqual(['other-server']); + }); + + it('does not fail when settings.local.json does not exist', async () => { + using project = createProjectDir('empty'); + const sut = await loadDisconnect(); + + await expect( + sut({ serverName: 'confidence-flags', projectDir: project.path }), + ).resolves.toBeUndefined(); + }); +}); + +describe('cursor disconnectMcpServer', () => { + async function loadDisconnect() { + const mod = await import('@integrations/cursor/mcp.js'); + return mod.disconnectMcpServer; + } + + it('removes the server entry from project and global MCP configs', async () => { + using project = createProjectDir('empty'); + writeCursorMcpConfig(project.path, { + mcpServers: { + 'confidence-flags': { type: 'http', url: 'https://example.com' }, + 'other-server': { type: 'http', url: 'https://other.com' }, + }, + }); + writeCursorCliConfig(project.path, { + permissions: { allow: ['Mcp(confidence-flags:*)', 'Mcp(other:*)'], deny: [] }, + }); + + const sut = await loadDisconnect(); + vi.stubEnv('HOME', project.path); + + await sut({ serverName: 'confidence-flags', projectDir: project.path }); + + const updatedMcp = JSON.parse( + readFileSync(join(project.path, '.cursor', 'mcp.json'), 'utf-8'), + ); + expect(updatedMcp.mcpServers).not.toHaveProperty('confidence-flags'); + expect(updatedMcp.mcpServers).toHaveProperty('other-server'); + + const updatedCli = JSON.parse( + readFileSync(join(project.path, '.cursor', 'cli.json'), 'utf-8'), + ); + expect(updatedCli.permissions.allow).toEqual(['Mcp(other:*)']); + }); + + it('does not fail when config files do not exist', async () => { + using project = createProjectDir('empty'); + const sut = await loadDisconnect(); + + await expect( + sut({ serverName: 'confidence-flags', projectDir: project.path }), + ).resolves.toBeUndefined(); + }); +}); + +describe('codex disconnectMcpServer', () => { + async function loadDisconnect() { + const mod = await import('@integrations/codex/mcp.js'); + return mod.disconnectMcpServer; + } + + it('calls codex mcp remove with the server name', async () => { + const sut = await loadDisconnect(); + + await sut({ serverName: 'confidence-flags', projectDir: '/project' }); + + expect(execFile).toHaveBeenCalledWith( + 'codex', + ['mcp', 'remove', 'confidence-flags'], + undefined, + ); + }); +}); diff --git a/packages/testing/src/scaffold/ide-scaffold.ts b/packages/testing/src/scaffold/ide-scaffold.ts new file mode 100644 index 0000000..fda8e18 --- /dev/null +++ b/packages/testing/src/scaffold/ide-scaffold.ts @@ -0,0 +1,33 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type ClaudeSettings = { + permissions?: { allow?: string[] }; + enabledMcpjsonServers?: string[]; +}; + +type CursorMcpConfig = { + mcpServers?: Record; +}; + +type CursorCliConfig = { + permissions?: { allow?: string[]; deny?: string[] }; +}; + +export function writeClaudeSettings(projectDir: string, settings: ClaudeSettings): void { + const dir = join(projectDir, '.claude'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'settings.local.json'), JSON.stringify(settings, null, 2)); +} + +export function writeCursorMcpConfig(projectDir: string, config: CursorMcpConfig): void { + const dir = join(projectDir, '.cursor'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'mcp.json'), JSON.stringify(config, null, 2)); +} + +export function writeCursorCliConfig(projectDir: string, config: CursorCliConfig): void { + const dir = join(projectDir, '.cursor'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'cli.json'), JSON.stringify(config, null, 2)); +} diff --git a/packages/testing/src/scaffold/index.ts b/packages/testing/src/scaffold/index.ts index 53eb232..36e2b5f 100644 --- a/packages/testing/src/scaffold/index.ts +++ b/packages/testing/src/scaffold/index.ts @@ -1,3 +1,8 @@ export { createConfigDir } from './config-scaffold.js'; export { createProjectDir } from './project-scaffold.js'; +export { + writeClaudeSettings, + writeCursorMcpConfig, + writeCursorCliConfig, +} from './ide-scaffold.js'; export type { ProjectType } from './types.js'; From 87cd02d5b316c8de830ee3c17e35b07fa68f0ae2 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 13:29:27 +0200 Subject: [PATCH 08/22] chore: prettier --- .../core/__tests__/integrations/mcp-disconnect.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/core/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts index c21543b..3e59182 100644 --- a/packages/core/__tests__/integrations/mcp-disconnect.test.ts +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -86,15 +86,11 @@ describe('cursor disconnectMcpServer', () => { await sut({ serverName: 'confidence-flags', projectDir: project.path }); - const updatedMcp = JSON.parse( - readFileSync(join(project.path, '.cursor', 'mcp.json'), 'utf-8'), - ); + const updatedMcp = JSON.parse(readFileSync(join(project.path, '.cursor', 'mcp.json'), 'utf-8')); expect(updatedMcp.mcpServers).not.toHaveProperty('confidence-flags'); expect(updatedMcp.mcpServers).toHaveProperty('other-server'); - const updatedCli = JSON.parse( - readFileSync(join(project.path, '.cursor', 'cli.json'), 'utf-8'), - ); + const updatedCli = JSON.parse(readFileSync(join(project.path, '.cursor', 'cli.json'), 'utf-8')); expect(updatedCli.permissions.allow).toEqual(['Mcp(other:*)']); }); From 377065a9535521c42b253b18ea7e454e714ea1b1 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:00:40 +0200 Subject: [PATCH 09/22] fix: use --dir instead of --project for MCP target directory --project is a global option for overriding the Confidence project name, not a filesystem path. Replace its misuse in MCP commands with an explicit --dir option that resolves to an absolute path. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/mcp.test.ts | 45 +++++++++++++++++++++ packages/cli/src/commands/mcp.ts | 8 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts index 220302b..0a091db 100644 --- a/packages/cli/__tests__/commands/mcp.test.ts +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import yargs from 'yargs'; import { buildTestJwt } from '@spotify-confidence/testing/auth'; import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; @@ -137,3 +138,47 @@ describe('mcp auth', () => { expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); }); }); + +describe('--dir', () => { + it('passes cwd when --dir is omitted', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'install']); + + expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ projectDir: process.cwd() }), + ); + }); + + it('resolves --dir to an absolute path', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'install', '--dir', 'some/relative/path']); + + expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ projectDir: resolve('some/relative/path') }), + ); + }); + + it('passes --dir through to status detection', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'status', '--dir', '/tmp/my-project', '--json']); + + expect(mockIntegration.detectMcpStatuses).toHaveBeenCalledWith('/tmp/my-project'); + }); + + it('passes --dir through to auth refresh', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'auth', '--dir', '/tmp/my-project']); + + expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ projectDir: '/tmp/my-project' }), + ); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index ac69ec6..c577e06 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import type { Argv } from 'yargs'; import { print, fail, extractFlags } from '@output/print.js'; import { getAvailableMcpServers } from '@spotify-confidence/core'; @@ -17,7 +18,8 @@ const STATUS_LABELS: Record = { }; function resolveProjectDir(argv: Record): string { - return (argv.project as string | undefined) ?? process.cwd(); + const dir = argv.dir as string | undefined; + return dir ? resolve(dir) : process.cwd(); } export const mcpCommand = { @@ -25,6 +27,10 @@ export const mcpCommand = { describe: 'Manage Confidence MCP server connections', builder(yargs: Argv) { return yargs + .option('dir', { + type: 'string', + describe: 'Target project directory', + }) .command( 'install', 'Install Confidence MCP servers for your AI coding agent', From 0c3f0333bfdf9a0a89c4dc8911f5c725aa5ab7b3 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:01:20 +0200 Subject: [PATCH 10/22] refactor: type STATUS_LABELS as Record Ensures new status variants are caught at typecheck time. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/mcp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index c577e06..612f1b4 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path'; import type { Argv } from 'yargs'; import { print, fail, extractFlags } from '@output/print.js'; -import { getAvailableMcpServers } from '@spotify-confidence/core'; +import { getAvailableMcpServers, type McpServerStatus } from '@spotify-confidence/core'; import { resolveIde, installMcpServers, @@ -10,7 +10,7 @@ import { refreshMcpAuth, } from '@features/mcp/index.js'; -const STATUS_LABELS: Record = { +const STATUS_LABELS: Record = { connected: 'Connected', installed: 'Installed (unreachable)', 'auth-expired': 'Auth expired', From 08382f82b5262283d303e3d7b557b4803935c9cf Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:07:36 +0200 Subject: [PATCH 11/22] refactor: change authenticate() to accept an options object Positional args with interleaved optionals were hard to read. All call sites now pass a single object. Also threads --profile through MCP install/auth commands and extracts argv helpers into argv.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/mcp.test.ts | 23 +++++++++++++++ packages/cli/src/commands/login.ts | 6 ++-- packages/cli/src/commands/mcp.ts | 12 +++----- packages/cli/src/features/mcp/actions.ts | 29 ++++++++++++++----- packages/cli/src/features/mcp/argv.ts | 10 +++++++ packages/cli/src/features/mcp/index.ts | 1 + .../__tests__/auth/authenticate/oauth.test.ts | 4 +-- packages/core/src/auth/authenticate/oauth.ts | 26 ++++++++--------- .../ui/screens/authenticate/useAuthFlow.ts | 2 +- 9 files changed, 79 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/features/mcp/argv.ts diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts index 0a091db..fa869cd 100644 --- a/packages/cli/__tests__/commands/mcp.test.ts +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -40,6 +40,7 @@ function run(args: string[]) { return yargs(args) .option('json', { type: 'boolean', default: false }) .option('output', { type: 'string' }) + .option('profile', { type: 'string' }) .command(mcpCommand) .parse(); } @@ -139,6 +140,28 @@ describe('mcp auth', () => { }); }); +describe('--profile', () => { + it('forwards profile to authenticate during install', async () => { + using _output = captureOutput(); + + await run(['mcp', 'install', '--profile', 'staging']); + + expect(mockAuthenticate).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'login', profile: 'staging' }), + ); + }); + + it('forwards profile to authenticate during auth refresh', async () => { + using _output = captureOutput(); + + await run(['mcp', 'auth', '--profile', 'staging']); + + expect(mockAuthenticate).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'login', profile: 'staging' }), + ); + }); +}); + describe('--dir', () => { it('passes cwd when --dir is omitted', async () => { using _auth = prepareAuthTokens('valid'); diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index bb868b4..68d0a70 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -9,8 +9,10 @@ export const loginCommand = { try { message('Opening browser for authentication...'); - const result = await authenticate('login', undefined, profile, (url) => { - message(`If the browser did not open, visit:\n${url}`); + const result = await authenticate({ + mode: 'login', + profile, + onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), }); message(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); } catch (err) { diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 612f1b4..c2da604 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -1,9 +1,10 @@ -import { resolve } from 'node:path'; import type { Argv } from 'yargs'; import { print, fail, extractFlags } from '@output/print.js'; import { getAvailableMcpServers, type McpServerStatus } from '@spotify-confidence/core'; import { resolveIde, + resolveProjectDir, + resolveProfile, installMcpServers, uninstallMcpServers, getMcpStatuses, @@ -17,11 +18,6 @@ const STATUS_LABELS: Record = { 'not-installed': 'Not installed', }; -function resolveProjectDir(argv: Record): string { - const dir = argv.dir as string | undefined; - return dir ? resolve(dir) : process.cwd(); -} - export const mcpCommand = { command: 'mcp ', describe: 'Manage Confidence MCP server connections', @@ -38,7 +34,7 @@ export const mcpCommand = { async (argv) => { try { const ideId = await resolveIde(); - await installMcpServers(ideId, resolveProjectDir(argv)); + await installMcpServers(ideId, resolveProjectDir(argv), resolveProfile(argv)); } catch (err) { fail((err as Error).message); } @@ -105,7 +101,7 @@ export const mcpCommand = { async (argv) => { try { const ideId = await resolveIde(); - await refreshMcpAuth(ideId, resolveProjectDir(argv)); + await refreshMcpAuth(ideId, resolveProjectDir(argv), resolveProfile(argv)); } catch (err) { fail((err as Error).message); } diff --git a/packages/cli/src/features/mcp/actions.ts b/packages/cli/src/features/mcp/actions.ts index 3128b4f..9d37185 100644 --- a/packages/cli/src/features/mcp/actions.ts +++ b/packages/cli/src/features/mcp/actions.ts @@ -12,11 +12,15 @@ import { type McpServerStatus, } from '@spotify-confidence/core'; -export async function installMcpServers(ideId: IdeId, projectDir: string): Promise { +export async function installMcpServers( + ideId: IdeId, + projectDir: string, + profile?: string, +): Promise { const integration = getIntegration(ideId); const servers = getAvailableMcpServers(); - const token = await resolveAuthToken(); + const token = await resolveAuthToken({ profile }); if (!token) return; const spinner = ora(`Installing MCP servers for ${integration.name}...`).start(); @@ -71,11 +75,15 @@ export async function getMcpStatuses( return integration.detectMcpStatuses(projectDir); } -export async function refreshMcpAuth(ideId: IdeId, projectDir: string): Promise { +export async function refreshMcpAuth( + ideId: IdeId, + projectDir: string, + profile?: string, +): Promise { const integration = getIntegration(ideId); const servers = getAvailableMcpServers(); - const token = await resolveAuthToken({ forceNew: true }); + const token = await resolveAuthToken({ forceNew: true, profile }); if (!token) return; const spinner = ora('Updating MCP server credentials...').start(); @@ -102,9 +110,12 @@ export async function refreshMcpAuth(ideId: IdeId, projectDir: string): Promise< spinner.succeed('MCP server credentials updated'); } -async function resolveAuthToken(opts?: { forceNew?: boolean }): Promise { +async function resolveAuthToken(opts?: { + forceNew?: boolean; + profile?: string; +}): Promise { if (!opts?.forceNew) { - const existing = loadPersistedToken(); + const existing = loadPersistedToken(opts?.profile); if (existing) { const { valid } = validateToken(existing); if (valid) return existing; @@ -113,8 +124,10 @@ async function resolveAuthToken(opts?: { forceNew?: boolean }): Promise { - message(`If the browser did not open, visit:\n${url}`); + const result = await authenticate({ + mode: 'login', + profile: opts?.profile, + onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), }); return result.accessToken; } catch (err) { diff --git a/packages/cli/src/features/mcp/argv.ts b/packages/cli/src/features/mcp/argv.ts new file mode 100644 index 0000000..d471842 --- /dev/null +++ b/packages/cli/src/features/mcp/argv.ts @@ -0,0 +1,10 @@ +import { resolve } from 'node:path'; + +export function resolveProjectDir(argv: Record): string { + const dir = argv.dir as string | undefined; + return dir ? resolve(dir) : process.cwd(); +} + +export function resolveProfile(argv: Record): string | undefined { + return argv.profile as string | undefined; +} diff --git a/packages/cli/src/features/mcp/index.ts b/packages/cli/src/features/mcp/index.ts index 0289770..025696a 100644 --- a/packages/cli/src/features/mcp/index.ts +++ b/packages/cli/src/features/mcp/index.ts @@ -1,4 +1,5 @@ export { resolveIde } from './resolve-ide.js'; +export { resolveProjectDir, resolveProfile } from './argv.js'; export { installMcpServers, uninstallMcpServers, diff --git a/packages/core/__tests__/auth/authenticate/oauth.test.ts b/packages/core/__tests__/auth/authenticate/oauth.test.ts index 57bd3dc..a021ce3 100644 --- a/packages/core/__tests__/auth/authenticate/oauth.test.ts +++ b/packages/core/__tests__/auth/authenticate/oauth.test.ts @@ -46,7 +46,7 @@ async function hitCallback(query: string, init?: RequestInit): Promise } async function completeAuth(mode: 'signup' | 'login'): Promise { - const result = authenticate(mode); + const result = authenticate({ mode }); const authUrl = await waitForOpenedUrl(); await hitCallback('?code=test-code'); await result; @@ -122,7 +122,7 @@ describe('authenticate', () => { await sut('login'); // Act - const result = authenticate('login'); + const result = authenticate({ mode: 'login' }); const authUrl = await waitForOpenedUrl(); const response = await hitCallback('?error=invalid_request', { redirect: 'manual' }); const retryUrl = response.headers.get('location')!; diff --git a/packages/core/src/auth/authenticate/oauth.ts b/packages/core/src/auth/authenticate/oauth.ts index a81544c..05a2774 100644 --- a/packages/core/src/auth/authenticate/oauth.ts +++ b/packages/core/src/auth/authenticate/oauth.ts @@ -68,19 +68,19 @@ export async function refreshAccessToken(profile?: string): Promise }; } -export function authenticate( - mode: 'signup' | 'login', - signal?: AbortSignal, - profile?: string, - onUrl?: (url: string) => void, -): Promise { - const clientId = mode === 'signup' ? AUTH_CLIENT_ID_SIGNUP : AUTH_CLIENT_ID_LOGIN; - const organization = mode === 'login' ? resolveOrganization(profile) : undefined; +export function authenticate(opts: { + mode: 'signup' | 'login'; + signal?: AbortSignal; + profile?: string; + onUrl?: (url: string) => void; +}): Promise { + const clientId = opts.mode === 'signup' ? AUTH_CLIENT_ID_SIGNUP : AUTH_CLIENT_ID_LOGIN; + const organization = opts.mode === 'login' ? resolveOrganization(opts.profile) : undefined; const { verifier, challenge } = generatePKCE(); const redirectUri = `http://localhost:${AUTH_CALLBACK_PORT}/callback`; return new Promise((resolve, reject) => { - if (signal?.aborted) { + if (opts.signal?.aborted) { reject(new Error('Authentication cancelled')); return; } @@ -117,7 +117,7 @@ export function authenticate( const tokenResponse = await exchangeCode({ code, clientId, verifier, redirectUri }); const { access_token, refresh_token } = tokenResponse; - persistTokens(access_token, refresh_token, profile); + persistTokens(access_token, refresh_token, opts.profile); const region = extractRegion(access_token); const { workspace } = validateToken(access_token); @@ -136,12 +136,12 @@ export function authenticate( server.listen(AUTH_CALLBACK_PORT, () => { const authUrl = buildAuthUrl({ clientId, challenge, redirectUri, organization }); - onUrl?.(authUrl); + opts.onUrl?.(authUrl); openBrowser(authUrl); }); - if (signal) { - signal.addEventListener( + if (opts.signal) { + opts.signal.addEventListener( 'abort', () => { server.close(); diff --git a/packages/quickstart/src/ui/screens/authenticate/useAuthFlow.ts b/packages/quickstart/src/ui/screens/authenticate/useAuthFlow.ts index 6e6d564..0b3569a 100644 --- a/packages/quickstart/src/ui/screens/authenticate/useAuthFlow.ts +++ b/packages/quickstart/src/ui/screens/authenticate/useAuthFlow.ts @@ -57,7 +57,7 @@ export function useAuthFlow(): AuthFlowState { const controller = new AbortController(); abortRef.current = controller; - authenticate(mode, controller.signal) + authenticate({ mode, signal: controller.signal }) .then((result) => { store.setAuthState({ status: 'authenticated', From a8a0eec6c14fecfd1578ea15c479ef774a5d6240 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:20:50 +0200 Subject: [PATCH 12/22] fix: set non-zero exit code on MCP action failures Auth failures and connection errors used error() which only prints to stderr, leaving exitCode at 0. Use fail() which also sets process.exitCode = 1. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/mcp/actions.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/features/mcp/actions.ts b/packages/cli/src/features/mcp/actions.ts index 9d37185..6dc1d44 100644 --- a/packages/cli/src/features/mcp/actions.ts +++ b/packages/cli/src/features/mcp/actions.ts @@ -1,6 +1,6 @@ import ora from 'ora'; import type { IdeId } from '@spotify-confidence/shared-kernel'; -import { message, error } from '@output/print.js'; +import { message, fail } from '@output/print.js'; import { getIntegration, getAvailableMcpServers, @@ -39,7 +39,8 @@ export async function installMcpServers( accessToken: token, }); } catch (err) { - spinner.fail(`Failed to connect ${server.name}: ${(err as Error).message}`); + spinner.stop(); + fail(`Failed to connect ${server.name}: ${(err as Error).message}`); return; } } @@ -102,7 +103,8 @@ export async function refreshMcpAuth( accessToken: token, }); } catch (err) { - spinner.fail(`Failed to update ${server.name}: ${(err as Error).message}`); + spinner.stop(); + fail(`Failed to update ${server.name}: ${(err as Error).message}`); return; } } @@ -131,7 +133,7 @@ async function resolveAuthToken(opts?: { }); return result.accessToken; } catch (err) { - error(`Authentication failed: ${(err as Error).message}`); + fail(`Authentication failed: ${(err as Error).message}`); return null; } } From ad4700ce879836d28cb5c9d7c636d070e8989794 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:33:36 +0200 Subject: [PATCH 13/22] fix: continue installing remaining MCP servers after a failure Instead of stopping at the first failing server (leaving partial state unreported), attempt all servers and report per-server results. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/mcp.test.ts | 12 ++++++ packages/cli/src/features/mcp/actions.ts | 46 +++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts index fa869cd..a892171 100644 --- a/packages/cli/__tests__/commands/mcp.test.ts +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -115,6 +115,18 @@ describe('mcp install', () => { expect(output.stderr).toContain('connection refused'); }); + + it('continues installing remaining servers after a failure', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + vi.mocked(mockIntegration.connectMcpServer).mockRejectedValueOnce( + new Error('connection refused'), + ); + + await run(['mcp', 'install']); + + expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); + }); }); describe('mcp uninstall', () => { diff --git a/packages/cli/src/features/mcp/actions.ts b/packages/cli/src/features/mcp/actions.ts index 6dc1d44..f442d13 100644 --- a/packages/cli/src/features/mcp/actions.ts +++ b/packages/cli/src/features/mcp/actions.ts @@ -1,4 +1,4 @@ -import ora from 'ora'; +import ora, { type Ora } from 'ora'; import type { IdeId } from '@spotify-confidence/shared-kernel'; import { message, fail } from '@output/print.js'; import { @@ -10,6 +10,7 @@ import { validateToken, type McpServerName, type McpServerStatus, + type McpServer, } from '@spotify-confidence/core'; export async function installMcpServers( @@ -24,6 +25,7 @@ export async function installMcpServers( if (!token) return; const spinner = ora(`Installing MCP servers for ${integration.name}...`).start(); + const failed: string[] = []; for (const server of servers) { const serverDef = MCP_SERVERS[server.name]; @@ -39,13 +41,13 @@ export async function installMcpServers( accessToken: token, }); } catch (err) { - spinner.stop(); - fail(`Failed to connect ${server.name}: ${(err as Error).message}`); - return; + failed.push(`${server.name}: ${(err as Error).message}`); } } - spinner.succeed(`MCP servers installed for ${integration.name}`); + const action = 'install'; + const success = `MCP servers installed for ${integration.name}`; + reportResults({ spinner, servers, failed, action, success }); } export async function uninstallMcpServers(ideId: IdeId, projectDir: string): Promise { @@ -88,6 +90,7 @@ export async function refreshMcpAuth( if (!token) return; const spinner = ora('Updating MCP server credentials...').start(); + const failed: string[] = []; for (const server of servers) { const serverDef = MCP_SERVERS[server.name]; @@ -103,13 +106,13 @@ export async function refreshMcpAuth( accessToken: token, }); } catch (err) { - spinner.stop(); - fail(`Failed to update ${server.name}: ${(err as Error).message}`); - return; + failed.push(`${server.name}: ${(err as Error).message}`); } } - spinner.succeed('MCP server credentials updated'); + const action = 'update'; + const success = 'MCP server credentials updated'; + reportResults({ spinner, servers, failed, action, success }); } async function resolveAuthToken(opts?: { @@ -137,3 +140,28 @@ async function resolveAuthToken(opts?: { return null; } } + +function reportResults(opts: { + spinner: Ora; + servers: McpServer[]; + action: 'install' | 'update'; + failed: string[]; + success: string; +}) { + const { spinner, action, success, failed, servers } = opts; + const errorReport = failed.join('\n'); + + if (failed.length === 0) { + spinner.succeed(success); + return; + } + + if (failed.length === servers.length) { + spinner.stop(); + fail(`Failed to ${action} MCP servers:\n${errorReport}`); + return; + } + + spinner.stop(); + fail(`Some MCP servers failed to ${action}:\n${errorReport}`); +} From b06d9ab4336269b26311b4f58416f1a2310a7125 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:35:14 +0200 Subject: [PATCH 14/22] fix: report uninstall failures instead of swallowing all errors Real failures (e.g. missing CLI binary) were silently ignored and reported as success. Now collects per-server errors and reports them. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/mcp/actions.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/features/mcp/actions.ts b/packages/cli/src/features/mcp/actions.ts index f442d13..4e84e6a 100644 --- a/packages/cli/src/features/mcp/actions.ts +++ b/packages/cli/src/features/mcp/actions.ts @@ -54,6 +54,7 @@ export async function uninstallMcpServers(ideId: IdeId, projectDir: string): Pro const integration = getIntegration(ideId); const servers = getAvailableMcpServers(); const spinner = ora(`Removing MCP servers from ${integration.name}...`).start(); + const failed: string[] = []; for (const server of servers) { spinner.text = `Removing ${server.name}...`; @@ -62,12 +63,14 @@ export async function uninstallMcpServers(ideId: IdeId, projectDir: string): Pro serverName: server.name, projectDir, }); - } catch { - // best-effort — server may not have been installed + } catch (err) { + failed.push(`${server.name}: ${(err as Error).message}`); } } - spinner.succeed(`MCP servers removed from ${integration.name}`); + const action = 'uninstall'; + const success = `MCP servers removed from ${integration.name}`; + reportResults({ spinner, servers, failed, action, success }); } export async function getMcpStatuses( @@ -144,7 +147,7 @@ async function resolveAuthToken(opts?: { function reportResults(opts: { spinner: Ora; servers: McpServer[]; - action: 'install' | 'update'; + action: 'install' | 'uninstall' | 'update'; failed: string[]; success: string; }) { From fcb81b4c1b0c06631c7cef9516ee6ea80e1ab719 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:38:07 +0200 Subject: [PATCH 15/22] refactor: split MCP actions into separate files Extract install, uninstall, status, resolve-token, and report-results into their own modules for readability. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/mcp/actions.ts | 170 ------------------ packages/cli/src/features/mcp/index.ts | 9 +- packages/cli/src/features/mcp/install.ts | 83 +++++++++ .../cli/src/features/mcp/report-results.ts | 28 +++ .../cli/src/features/mcp/resolve-token.ts | 28 +++ packages/cli/src/features/mcp/status.ts | 10 ++ packages/cli/src/features/mcp/uninstall.ts | 32 ++++ 7 files changed, 184 insertions(+), 176 deletions(-) delete mode 100644 packages/cli/src/features/mcp/actions.ts create mode 100644 packages/cli/src/features/mcp/install.ts create mode 100644 packages/cli/src/features/mcp/report-results.ts create mode 100644 packages/cli/src/features/mcp/resolve-token.ts create mode 100644 packages/cli/src/features/mcp/status.ts create mode 100644 packages/cli/src/features/mcp/uninstall.ts diff --git a/packages/cli/src/features/mcp/actions.ts b/packages/cli/src/features/mcp/actions.ts deleted file mode 100644 index 4e84e6a..0000000 --- a/packages/cli/src/features/mcp/actions.ts +++ /dev/null @@ -1,170 +0,0 @@ -import ora, { type Ora } from 'ora'; -import type { IdeId } from '@spotify-confidence/shared-kernel'; -import { message, fail } from '@output/print.js'; -import { - getIntegration, - getAvailableMcpServers, - MCP_SERVERS, - loadPersistedToken, - authenticate, - validateToken, - type McpServerName, - type McpServerStatus, - type McpServer, -} from '@spotify-confidence/core'; - -export async function installMcpServers( - ideId: IdeId, - projectDir: string, - profile?: string, -): Promise { - const integration = getIntegration(ideId); - const servers = getAvailableMcpServers(); - - const token = await resolveAuthToken({ profile }); - if (!token) return; - - const spinner = ora(`Installing MCP servers for ${integration.name}...`).start(); - const failed: string[] = []; - - for (const server of servers) { - const serverDef = MCP_SERVERS[server.name]; - spinner.text = `Connecting ${server.name}...`; - - try { - await integration.connectMcpServer({ - serverName: server.name, - serverUrl: server.url, - serverType: serverDef.type, - serverHeaders: serverDef.headers, - projectDir, - accessToken: token, - }); - } catch (err) { - failed.push(`${server.name}: ${(err as Error).message}`); - } - } - - const action = 'install'; - const success = `MCP servers installed for ${integration.name}`; - reportResults({ spinner, servers, failed, action, success }); -} - -export async function uninstallMcpServers(ideId: IdeId, projectDir: string): Promise { - const integration = getIntegration(ideId); - const servers = getAvailableMcpServers(); - const spinner = ora(`Removing MCP servers from ${integration.name}...`).start(); - const failed: string[] = []; - - for (const server of servers) { - spinner.text = `Removing ${server.name}...`; - try { - await integration.disconnectMcpServer({ - serverName: server.name, - projectDir, - }); - } catch (err) { - failed.push(`${server.name}: ${(err as Error).message}`); - } - } - - const action = 'uninstall'; - const success = `MCP servers removed from ${integration.name}`; - reportResults({ spinner, servers, failed, action, success }); -} - -export async function getMcpStatuses( - ideId: IdeId, - projectDir: string, -): Promise> { - const integration = getIntegration(ideId); - return integration.detectMcpStatuses(projectDir); -} - -export async function refreshMcpAuth( - ideId: IdeId, - projectDir: string, - profile?: string, -): Promise { - const integration = getIntegration(ideId); - const servers = getAvailableMcpServers(); - - const token = await resolveAuthToken({ forceNew: true, profile }); - if (!token) return; - - const spinner = ora('Updating MCP server credentials...').start(); - const failed: string[] = []; - - for (const server of servers) { - const serverDef = MCP_SERVERS[server.name]; - spinner.text = `Updating ${server.name}...`; - - try { - await integration.connectMcpServer({ - serverName: server.name, - serverUrl: server.url, - serverType: serverDef.type, - serverHeaders: serverDef.headers, - projectDir, - accessToken: token, - }); - } catch (err) { - failed.push(`${server.name}: ${(err as Error).message}`); - } - } - - const action = 'update'; - const success = 'MCP server credentials updated'; - reportResults({ spinner, servers, failed, action, success }); -} - -async function resolveAuthToken(opts?: { - forceNew?: boolean; - profile?: string; -}): Promise { - if (!opts?.forceNew) { - const existing = loadPersistedToken(opts?.profile); - if (existing) { - const { valid } = validateToken(existing); - if (valid) return existing; - } - } - - try { - message('Authentication required. Opening browser...'); - const result = await authenticate({ - mode: 'login', - profile: opts?.profile, - onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), - }); - return result.accessToken; - } catch (err) { - fail(`Authentication failed: ${(err as Error).message}`); - return null; - } -} - -function reportResults(opts: { - spinner: Ora; - servers: McpServer[]; - action: 'install' | 'uninstall' | 'update'; - failed: string[]; - success: string; -}) { - const { spinner, action, success, failed, servers } = opts; - const errorReport = failed.join('\n'); - - if (failed.length === 0) { - spinner.succeed(success); - return; - } - - if (failed.length === servers.length) { - spinner.stop(); - fail(`Failed to ${action} MCP servers:\n${errorReport}`); - return; - } - - spinner.stop(); - fail(`Some MCP servers failed to ${action}:\n${errorReport}`); -} diff --git a/packages/cli/src/features/mcp/index.ts b/packages/cli/src/features/mcp/index.ts index 025696a..2024561 100644 --- a/packages/cli/src/features/mcp/index.ts +++ b/packages/cli/src/features/mcp/index.ts @@ -1,8 +1,5 @@ export { resolveIde } from './resolve-ide.js'; export { resolveProjectDir, resolveProfile } from './argv.js'; -export { - installMcpServers, - uninstallMcpServers, - getMcpStatuses, - refreshMcpAuth, -} from './actions.js'; +export { installMcpServers, refreshMcpAuth } from './install.js'; +export { uninstallMcpServers } from './uninstall.js'; +export { getMcpStatuses } from './status.js'; diff --git a/packages/cli/src/features/mcp/install.ts b/packages/cli/src/features/mcp/install.ts new file mode 100644 index 0000000..0addd59 --- /dev/null +++ b/packages/cli/src/features/mcp/install.ts @@ -0,0 +1,83 @@ +import ora from 'ora'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { + getIntegration, + getAvailableMcpServers, + MCP_SERVERS, + type IdeIntegration, +} from '@spotify-confidence/core'; +import { resolveAuthToken } from './resolve-token.js'; +import { reportResults } from './report-results.js'; + +export async function installMcpServers( + ideId: IdeId, + projectDir: string, + profile?: string, +): Promise { + const integration = getIntegration(ideId); + await connectAllServers({ + integration, + projectDir, + profile, + spinnerLabel: `Installing MCP servers for ${integration.name}...`, + progressVerb: 'Connecting', + action: 'install', + success: `MCP servers installed for ${integration.name}`, + }); +} + +export async function refreshMcpAuth( + ideId: IdeId, + projectDir: string, + profile?: string, +): Promise { + await connectAllServers({ + integration: getIntegration(ideId), + projectDir, + profile, + forceNewToken: true, + spinnerLabel: 'Updating MCP server credentials...', + progressVerb: 'Updating', + action: 'update', + success: 'MCP server credentials updated', + }); +} + +async function connectAllServers(opts: { + integration: IdeIntegration; + projectDir: string; + profile?: string; + forceNewToken?: boolean; + spinnerLabel: string; + progressVerb: string; + action: 'install' | 'update'; + success: string; +}): Promise { + const servers = getAvailableMcpServers(); + + const token = await resolveAuthToken({ forceNew: opts.forceNewToken, profile: opts.profile }); + if (!token) return; + + const spinner = ora(opts.spinnerLabel).start(); + const failed: string[] = []; + + for (const server of servers) { + const serverDef = MCP_SERVERS[server.name]; + spinner.text = `${opts.progressVerb} ${server.name}...`; + + try { + await opts.integration.connectMcpServer({ + serverName: server.name, + serverUrl: server.url, + serverType: serverDef.type, + serverHeaders: serverDef.headers, + projectDir: opts.projectDir, + accessToken: token, + }); + } catch (err) { + failed.push(`${server.name}: ${(err as Error).message}`); + } + } + + reportResults({ spinner, servers, failed, action: opts.action, success: opts.success }); +} diff --git a/packages/cli/src/features/mcp/report-results.ts b/packages/cli/src/features/mcp/report-results.ts new file mode 100644 index 0000000..ef07b0e --- /dev/null +++ b/packages/cli/src/features/mcp/report-results.ts @@ -0,0 +1,28 @@ +import type { Ora } from 'ora'; +import { fail } from '@output/print.js'; +import type { McpServer } from '@spotify-confidence/core'; + +export function reportResults(opts: { + spinner: Ora; + servers: McpServer[]; + action: 'install' | 'uninstall' | 'update'; + failed: string[]; + success: string; +}): void { + const { spinner, action, success, failed, servers } = opts; + const errorReport = failed.join('\n'); + + if (failed.length === 0) { + spinner.succeed(success); + return; + } + + if (failed.length === servers.length) { + spinner.stop(); + fail(`Failed to ${action} MCP servers:\n${errorReport}`); + return; + } + + spinner.stop(); + fail(`Some MCP servers failed to ${action}:\n${errorReport}`); +} diff --git a/packages/cli/src/features/mcp/resolve-token.ts b/packages/cli/src/features/mcp/resolve-token.ts new file mode 100644 index 0000000..15d8193 --- /dev/null +++ b/packages/cli/src/features/mcp/resolve-token.ts @@ -0,0 +1,28 @@ +import { message, fail } from '@output/print.js'; +import { loadPersistedToken, authenticate, validateToken } from '@spotify-confidence/core'; + +export async function resolveAuthToken(opts?: { + forceNew?: boolean; + profile?: string; +}): Promise { + if (!opts?.forceNew) { + const existing = loadPersistedToken(opts?.profile); + if (existing) { + const { valid } = validateToken(existing); + if (valid) return existing; + } + } + + try { + message('Authentication required. Opening browser...'); + const result = await authenticate({ + mode: 'login', + profile: opts?.profile, + onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), + }); + return result.accessToken; + } catch (err) { + fail(`Authentication failed: ${(err as Error).message}`); + return null; + } +} diff --git a/packages/cli/src/features/mcp/status.ts b/packages/cli/src/features/mcp/status.ts new file mode 100644 index 0000000..dee55c6 --- /dev/null +++ b/packages/cli/src/features/mcp/status.ts @@ -0,0 +1,10 @@ +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { getIntegration, type McpServerName, type McpServerStatus } from '@spotify-confidence/core'; + +export async function getMcpStatuses( + ideId: IdeId, + projectDir: string, +): Promise> { + const integration = getIntegration(ideId); + return integration.detectMcpStatuses(projectDir); +} diff --git a/packages/cli/src/features/mcp/uninstall.ts b/packages/cli/src/features/mcp/uninstall.ts new file mode 100644 index 0000000..ffa3d48 --- /dev/null +++ b/packages/cli/src/features/mcp/uninstall.ts @@ -0,0 +1,32 @@ +import ora from 'ora'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { getIntegration, getAvailableMcpServers } from '@spotify-confidence/core'; +import { reportResults } from './report-results.js'; + +export async function uninstallMcpServers(ideId: IdeId, projectDir: string): Promise { + const integration = getIntegration(ideId); + const servers = getAvailableMcpServers(); + const spinner = ora(`Removing MCP servers from ${integration.name}...`).start(); + const failed: string[] = []; + + for (const server of servers) { + spinner.text = `Removing ${server.name}...`; + + try { + await integration.disconnectMcpServer({ + serverName: server.name, + projectDir, + }); + } catch (err) { + failed.push(`${server.name}: ${(err as Error).message}`); + } + } + + reportResults({ + spinner, + servers, + failed, + action: 'uninstall', + success: `MCP servers removed from ${integration.name}`, + }); +} From fd709319a2efaf886b629afc6eae734e9dcf3228 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:48:54 +0200 Subject: [PATCH 16/22] feat: add --ide flag and fail fast when non-interactive resolveIde now accepts an explicit IDE override, validates it against the known set, and throws when no IDE is configured in a non-interactive session. Adds isInteractive() to core/system and a resolveFlag() argv helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/mcp.test.ts | 28 ++++++++---------- .../features/mcp/resolve-ide.test.ts | 29 ++++++++++++++++++- .../cli/__tests__/helpers/simulate-tty.ts | 5 ++++ packages/cli/src/commands/mcp.ts | 19 +++++++----- packages/cli/src/features/mcp/argv.ts | 10 +++---- packages/cli/src/features/mcp/index.ts | 2 +- packages/cli/src/features/mcp/resolve-ide.ts | 29 +++++++++++++++++-- packages/core/src/system/env.ts | 4 +++ packages/core/src/system/index.ts | 2 +- 9 files changed, 95 insertions(+), 33 deletions(-) create mode 100644 packages/cli/__tests__/helpers/simulate-tty.ts diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts index a892171..7095feb 100644 --- a/packages/cli/__tests__/commands/mcp.test.ts +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -28,10 +28,6 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => { }; }); -vi.mock('@inquirer/select', () => ({ - default: vi.fn().mockResolvedValue('claude'), -})); - beforeEach(() => { vi.clearAllMocks(); }); @@ -76,7 +72,7 @@ describe('mcp status', () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - await run(['mcp', 'status', '--json']); + await run(['mcp', 'status', '--ide', 'claude', '--json']); const parsed = JSON.parse(output.stdout); expect(parsed.data).toEqual( @@ -93,7 +89,7 @@ describe('mcp install', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'install']); + await run(['mcp', 'install', '--ide', 'claude']); expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( @@ -111,7 +107,7 @@ describe('mcp install', () => { new Error('connection refused'), ); - await run(['mcp', 'install']); + await run(['mcp', 'install', '--ide', 'claude']); expect(output.stderr).toContain('connection refused'); }); @@ -123,7 +119,7 @@ describe('mcp install', () => { new Error('connection refused'), ); - await run(['mcp', 'install']); + await run(['mcp', 'install', '--ide', 'claude']); expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); }); @@ -134,7 +130,7 @@ describe('mcp uninstall', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'uninstall']); + await run(['mcp', 'uninstall', '--ide', 'claude']); expect(mockIntegration.disconnectMcpServer).toHaveBeenCalledTimes(2); }); @@ -145,7 +141,7 @@ describe('mcp auth', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'auth']); + await run(['mcp', 'auth', '--ide', 'claude']); expect(mockAuthenticate).toHaveBeenCalled(); expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); @@ -156,7 +152,7 @@ describe('--profile', () => { it('forwards profile to authenticate during install', async () => { using _output = captureOutput(); - await run(['mcp', 'install', '--profile', 'staging']); + await run(['mcp', 'install', '--ide', 'claude', '--profile', 'staging']); expect(mockAuthenticate).toHaveBeenCalledWith( expect.objectContaining({ mode: 'login', profile: 'staging' }), @@ -166,7 +162,7 @@ describe('--profile', () => { it('forwards profile to authenticate during auth refresh', async () => { using _output = captureOutput(); - await run(['mcp', 'auth', '--profile', 'staging']); + await run(['mcp', 'auth', '--ide', 'claude', '--profile', 'staging']); expect(mockAuthenticate).toHaveBeenCalledWith( expect.objectContaining({ mode: 'login', profile: 'staging' }), @@ -179,7 +175,7 @@ describe('--dir', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'install']); + await run(['mcp', 'install', '--ide', 'claude']); expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( expect.objectContaining({ projectDir: process.cwd() }), @@ -190,7 +186,7 @@ describe('--dir', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'install', '--dir', 'some/relative/path']); + await run(['mcp', 'install', '--ide', 'claude', '--dir', 'some/relative/path']); expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( expect.objectContaining({ projectDir: resolve('some/relative/path') }), @@ -201,7 +197,7 @@ describe('--dir', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'status', '--dir', '/tmp/my-project', '--json']); + await run(['mcp', 'status', '--ide', 'claude', '--dir', '/tmp/my-project', '--json']); expect(mockIntegration.detectMcpStatuses).toHaveBeenCalledWith('/tmp/my-project'); }); @@ -210,7 +206,7 @@ describe('--dir', () => { using _auth = prepareAuthTokens('valid'); using _output = captureOutput(); - await run(['mcp', 'auth', '--dir', '/tmp/my-project']); + await run(['mcp', 'auth', '--ide', 'claude', '--dir', '/tmp/my-project']); expect(mockIntegration.connectMcpServer).toHaveBeenCalledWith( expect.objectContaining({ projectDir: '/tmp/my-project' }), diff --git a/packages/cli/__tests__/features/mcp/resolve-ide.test.ts b/packages/cli/__tests__/features/mcp/resolve-ide.test.ts index 33d23d0..6299aca 100644 --- a/packages/cli/__tests__/features/mcp/resolve-ide.test.ts +++ b/packages/cli/__tests__/features/mcp/resolve-ide.test.ts @@ -1,5 +1,6 @@ import { setConfigValue } from '@spotify-confidence/core'; import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { simulateTTY } from '../../helpers/simulate-tty.js'; vi.mock('@inquirer/select', () => ({ default: vi.fn(), @@ -24,8 +25,9 @@ describe('resolveIde', () => { expect(select).not.toHaveBeenCalled(); }); - it('prompts when no IDE is saved and returns the selection', async () => { + it('prompts when no IDE is saved and stdin is a TTY', async () => { using _config = prepareAuthTokens('none'); + using _tty = simulateTTY(true); vi.mocked(select).mockResolvedValueOnce('codex'); const sut = await loadResolveIde(); @@ -41,6 +43,7 @@ describe('resolveIde', () => { it('persists the selected IDE to config', async () => { using _config = prepareAuthTokens('none'); + using _tty = simulateTTY(true); vi.mocked(select).mockResolvedValueOnce('claude'); const sut = await loadResolveIde(); @@ -49,4 +52,28 @@ describe('resolveIde', () => { const { getConfigValue } = await import('@spotify-confidence/core'); expect(getConfigValue('ide')).toBe('claude'); }); + + it('returns the explicit IDE without prompting or reading config', async () => { + using _config = prepareAuthTokens('none'); + const sut = await loadResolveIde(); + + const result = await sut('codex'); + + expect(result).toBe('codex'); + expect(select).not.toHaveBeenCalled(); + }); + + it('rejects an unknown explicit IDE', async () => { + const sut = await loadResolveIde(); + + await expect(sut('vim')).rejects.toThrow('Unsupported IDE "vim"'); + }); + + it('throws when non-interactive and no IDE is configured', async () => { + using _config = prepareAuthTokens('none'); + using _tty = simulateTTY(false); + const sut = await loadResolveIde(); + + await expect(sut()).rejects.toThrow('No IDE configured'); + }); }); diff --git a/packages/cli/__tests__/helpers/simulate-tty.ts b/packages/cli/__tests__/helpers/simulate-tty.ts new file mode 100644 index 0000000..6192fb1 --- /dev/null +++ b/packages/cli/__tests__/helpers/simulate-tty.ts @@ -0,0 +1,5 @@ +export function simulateTTY(isTTY: boolean): Disposable { + const original = process.stdin.isTTY; + process.stdin.isTTY = (isTTY || undefined) as typeof process.stdin.isTTY; + return { [Symbol.dispose]: () => void (process.stdin.isTTY = original) }; +} diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index c2da604..3a4dbf2 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -3,8 +3,8 @@ import { print, fail, extractFlags } from '@output/print.js'; import { getAvailableMcpServers, type McpServerStatus } from '@spotify-confidence/core'; import { resolveIde, + resolveFlag, resolveProjectDir, - resolveProfile, installMcpServers, uninstallMcpServers, getMcpStatuses, @@ -27,14 +27,19 @@ export const mcpCommand = { type: 'string', describe: 'Target project directory', }) + .option('ide', { + type: 'string', + choices: ['claude', 'cursor', 'codex'] as const, + describe: 'AI coding agent to configure', + }) .command( 'install', 'Install Confidence MCP servers for your AI coding agent', () => {}, async (argv) => { try { - const ideId = await resolveIde(); - await installMcpServers(ideId, resolveProjectDir(argv), resolveProfile(argv)); + const ideId = await resolveIde(resolveFlag('ide', argv)); + await installMcpServers(ideId, resolveProjectDir(argv), resolveFlag('profile', argv)); } catch (err) { fail((err as Error).message); } @@ -46,7 +51,7 @@ export const mcpCommand = { () => {}, async (argv) => { try { - const ideId = await resolveIde(); + const ideId = await resolveIde(resolveFlag('ide', argv)); await uninstallMcpServers(ideId, resolveProjectDir(argv)); } catch (err) { fail((err as Error).message); @@ -59,7 +64,7 @@ export const mcpCommand = { () => {}, async (argv) => { try { - const ideId = await resolveIde(); + const ideId = await resolveIde(resolveFlag('ide', argv)); const statuses = await getMcpStatuses(ideId, resolveProjectDir(argv)); const rows = Object.entries(statuses).map(([server, status]) => ({ server, @@ -100,8 +105,8 @@ export const mcpCommand = { () => {}, async (argv) => { try { - const ideId = await resolveIde(); - await refreshMcpAuth(ideId, resolveProjectDir(argv), resolveProfile(argv)); + const ideId = await resolveIde(resolveFlag('ide', argv)); + await refreshMcpAuth(ideId, resolveProjectDir(argv), resolveFlag('profile', argv)); } catch (err) { fail((err as Error).message); } diff --git a/packages/cli/src/features/mcp/argv.ts b/packages/cli/src/features/mcp/argv.ts index d471842..65f3ff6 100644 --- a/packages/cli/src/features/mcp/argv.ts +++ b/packages/cli/src/features/mcp/argv.ts @@ -1,10 +1,10 @@ import { resolve } from 'node:path'; -export function resolveProjectDir(argv: Record): string { - const dir = argv.dir as string | undefined; - return dir ? resolve(dir) : process.cwd(); +export function resolveFlag(name: string, argv: Record): string | undefined { + return argv[name] as string | undefined; } -export function resolveProfile(argv: Record): string | undefined { - return argv.profile as string | undefined; +export function resolveProjectDir(argv: Record): string { + const dir = resolveFlag('dir', argv); + return dir ? resolve(dir) : process.cwd(); } diff --git a/packages/cli/src/features/mcp/index.ts b/packages/cli/src/features/mcp/index.ts index 2024561..6c2718c 100644 --- a/packages/cli/src/features/mcp/index.ts +++ b/packages/cli/src/features/mcp/index.ts @@ -1,5 +1,5 @@ export { resolveIde } from './resolve-ide.js'; -export { resolveProjectDir, resolveProfile } from './argv.js'; +export { resolveFlag, resolveProjectDir } from './argv.js'; export { installMcpServers, refreshMcpAuth } from './install.js'; export { uninstallMcpServers } from './uninstall.js'; export { getMcpStatuses } from './status.js'; diff --git a/packages/cli/src/features/mcp/resolve-ide.ts b/packages/cli/src/features/mcp/resolve-ide.ts index 414c70b..c022cd2 100644 --- a/packages/cli/src/features/mcp/resolve-ide.ts +++ b/packages/cli/src/features/mcp/resolve-ide.ts @@ -1,11 +1,36 @@ import select from '@inquirer/select'; import type { IdeId } from '@spotify-confidence/shared-kernel'; -import { getConfigValue, setConfigValue, getIntegrations } from '@spotify-confidence/core'; +import { + getConfigValue, + setConfigValue, + getIntegrations, + isInteractive, +} from '@spotify-confidence/core'; + +const VALID_IDE_IDS = new Set(['claude', 'cursor', 'codex']); +const VALID_IDE_LIST = [...VALID_IDE_IDS].join(', '); + +export async function resolveIde(explicit?: string): Promise { + if (explicit) return validateIdeId(explicit); -export async function resolveIde(): Promise { const saved = getConfigValue('ide') as IdeId | undefined; if (saved) return saved; + return promptForIde(); +} + +function validateIdeId(value: string): IdeId { + if (!VALID_IDE_IDS.has(value as IdeId)) { + throw new Error(`Unsupported IDE "${value}". Valid options: ${VALID_IDE_LIST}`); + } + return value as IdeId; +} + +async function promptForIde(): Promise { + if (!isInteractive()) { + throw new Error(`No IDE configured. Pass --ide (${VALID_IDE_LIST}) or run interactively.`); + } + const integrations = getIntegrations(); const chosen = await select({ message: 'Which AI coding agent are you using?', diff --git a/packages/core/src/system/env.ts b/packages/core/src/system/env.ts index 75bb057..ec27615 100644 --- a/packages/core/src/system/env.ts +++ b/packages/core/src/system/env.ts @@ -27,3 +27,7 @@ export function isCI(): boolean { export function isDebug(): boolean { return env('DEBUG') === 'true'; } + +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY) && !isCI(); +} diff --git a/packages/core/src/system/index.ts b/packages/core/src/system/index.ts index 680c1ba..c4e6241 100644 --- a/packages/core/src/system/index.ts +++ b/packages/core/src/system/index.ts @@ -1,3 +1,3 @@ -export { env, isCI, isDebug } from './env.js'; +export { env, isCI, isDebug, isInteractive } from './env.js'; export { isDirectoryEmpty } from './fs.js'; export { checkNode, checkGit, runAllChecks } from './system-check.js'; From 2867aeb663e2dbabc4e132188eebdd4e68f1f015 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 16:57:41 +0200 Subject: [PATCH 17/22] fix: handle empty data consistently in print output Empty arrays previously fell through to formatTable, printing only headers. Empty key-value objects lost their domain-specific message. Both shapes now go through a unified check with a customizable `empty` option, defaulting to "No results." Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/output/print.test.ts | 44 +++++++++++++++++++++ packages/cli/src/commands/config.ts | 1 + packages/cli/src/output/print.ts | 40 ++++++++++++------- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/cli/__tests__/output/print.test.ts b/packages/cli/__tests__/output/print.test.ts index 55f25d4..927c169 100644 --- a/packages/cli/__tests__/output/print.test.ts +++ b/packages/cli/__tests__/output/print.test.ts @@ -117,4 +117,48 @@ describe('print', () => { const output = (spy.mock.calls[0] as string[])[0]; expect(output).toContain('No results.'); }); + + it('prints "No results." for an empty array', () => { + print({ + data: [], + columns: [ + { key: 'name', header: 'Server' }, + { key: 'url', header: 'URL' }, + ], + flags: { output: 'table' }, + }); + + const output = (spy.mock.calls[0] as string[])[0]; + expect(output).toContain('No results.'); + }); + + it('uses a custom empty for an empty object', () => { + print({ + data: {}, + columns: [ + { key: 'key', header: 'Key' }, + { key: 'value', header: 'Value' }, + ], + flags: { output: 'table' }, + empty: 'No configuration set.', + }); + + const output = (spy.mock.calls[0] as string[])[0]; + expect(output).toContain('No configuration set.'); + }); + + it('uses a custom empty for an empty array', () => { + print({ + data: [], + columns: [ + { key: 'name', header: 'Server' }, + { key: 'url', header: 'URL' }, + ], + flags: { output: 'table' }, + empty: 'No servers configured.', + }); + + const output = (spy.mock.calls[0] as string[])[0]; + expect(output).toContain('No servers configured.'); + }); }); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 308aacc..5cc28cb 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -59,6 +59,7 @@ export const configCommand = { { key: 'value', header: 'Value' }, ], flags: extractFlags(argv), + empty: 'No configuration set.', }); }, ) diff --git a/packages/cli/src/output/print.ts b/packages/cli/src/output/print.ts index 8312797..e11a12a 100644 --- a/packages/cli/src/output/print.ts +++ b/packages/cli/src/output/print.ts @@ -9,6 +9,11 @@ type Column = { width?: number; }; +type KeyValuePair = { + key: string; + value: string; +}; + type FormatFlags = { json?: boolean; output?: OutputFormat; @@ -18,12 +23,14 @@ type PrintTableOpts> = { data: T[]; columns: Column[]; flags: FormatFlags; + empty?: string; }; type PrintKeyValueOpts = { data: Record; - columns: [Column<{ key: string; value: string }>, Column<{ key: string; value: string }>]; + columns: [Column, Column]; flags: FormatFlags; + empty?: string; }; export function message(text: string): void { @@ -39,6 +46,13 @@ export function fail(text: string): void { process.exitCode = 1; } +export function extractFlags(argv: Record): FormatFlags { + return { + json: argv.json as boolean | undefined, + output: argv.output as OutputFormat | undefined, + }; +} + export function print>(opts: PrintTableOpts): void; export function print(opts: PrintKeyValueOpts): void; export function print>( @@ -51,24 +65,20 @@ export function print>( return; } - if (Array.isArray(opts.data)) { - message(formatTable(opts.data, opts.columns as Column[])); - return; - } + const rows = intoRows(opts.data) as T[]; - const entries = Object.entries(opts.data); - if (entries.length === 0) { - message('No results.'); + if (rows.length === 0) { + message(opts.empty ?? 'No results.'); return; } - const rows = entries.map(([key, value]) => ({ key, value: String(value ?? '') })); - message(formatTable(rows, opts.columns as Column<{ key: string; value: string }>[])); + message(formatTable(rows, opts.columns as Column[])); } -export function extractFlags(argv: Record): FormatFlags { - return { - json: argv.json as boolean | undefined, - output: argv.output as OutputFormat | undefined, - }; +function intoRows( + data: Record | Record[], +): Record[] { + return Array.isArray(data) + ? data + : Object.entries(data).map(([key, value]) => ({ key, value: String(value ?? '') })); } From 222a9d2b9d26aba7dd5ab63531a42356650d295b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 17:01:29 +0200 Subject: [PATCH 18/22] fix: remove codex MCP server from both project and global config disconnectMcpServer previously shelled out to `codex mcp remove` which only touches global config. Servers registered in the project-scoped config would still show as installed after uninstall. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integrations/mcp-disconnect.test.ts | 36 ++++++++++++++----- packages/core/src/integrations/codex/mcp.ts | 27 ++++++++++++-- packages/testing/src/scaffold/ide-scaffold.ts | 6 ++++ packages/testing/src/scaffold/index.ts | 1 + 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/core/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts index 3e59182..19539da 100644 --- a/packages/core/__tests__/integrations/mcp-disconnect.test.ts +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -1,8 +1,9 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { createProjectDir } from '@spotify-confidence/testing/scaffold'; import { + createProjectDir, writeClaudeSettings, + writeCodexConfig, writeCursorMcpConfig, writeCursorCliConfig, } from '@spotify-confidence/testing/scaffold'; @@ -110,15 +111,34 @@ describe('codex disconnectMcpServer', () => { return mod.disconnectMcpServer; } - it('calls codex mcp remove with the server name', async () => { + it('removes the server section from project and global configs', async () => { + using project = createProjectDir('empty'); + vi.stubEnv('HOME', project.path); + const toml = [ + '[mcp_servers.confidence-flags]', + 'url = "https://example.com"', + '', + '[mcp_servers.other-server]', + 'url = "https://other.com"', + ].join('\n'); + writeCodexConfig(project.path, toml); + const sut = await loadDisconnect(); - await sut({ serverName: 'confidence-flags', projectDir: '/project' }); + await sut({ serverName: 'confidence-flags', projectDir: project.path }); - expect(execFile).toHaveBeenCalledWith( - 'codex', - ['mcp', 'remove', 'confidence-flags'], - undefined, - ); + const updated = readFileSync(join(project.path, '.codex', 'config.toml'), 'utf-8'); + expect(updated).not.toContain('confidence-flags'); + expect(updated).toContain('[mcp_servers.other-server]'); + }); + + it('does not fail when config files do not exist', async () => { + using project = createProjectDir('empty'); + vi.stubEnv('HOME', project.path); + const sut = await loadDisconnect(); + + await expect( + sut({ serverName: 'confidence-flags', projectDir: project.path }), + ).resolves.toBeUndefined(); }); }); diff --git a/packages/core/src/integrations/codex/mcp.ts b/packages/core/src/integrations/codex/mcp.ts index ac40236..9ffddbc 100644 --- a/packages/core/src/integrations/codex/mcp.ts +++ b/packages/core/src/integrations/codex/mcp.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { execFile } from '../../exec/exec.js'; import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; import { @@ -35,8 +35,10 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { patchHttpHeaders(opts.serverName, headers); } -export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { - await execFile('codex', ['mcp', 'remove', opts.serverName]); +export function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeTomlSection(projectConfigPath(opts.projectDir), opts.serverName); + removeTomlSection(globalConfigPath(), opts.serverName); + return Promise.resolve(); } function getRegisteredMcpNames(projectDir: string): McpServerName[] { @@ -73,6 +75,25 @@ function getStoredAuthToken(serverName: McpServerName): string | null { } } +function removeTomlSection(configPath: string, serverName: string): void { + if (!existsSync(configPath)) return; + try { + const content = readFileSync(configPath, 'utf-8'); + const sectionHeader = `[mcp_servers.${serverName}]`; + const idx = content.indexOf(sectionHeader); + if (idx === -1) return; + + const nextSection = content.indexOf('\n[', idx + sectionHeader.length); + const before = content.slice(0, idx).replace(/\n+$/, ''); + const after = nextSection === -1 ? '' : content.slice(nextSection); + const result = (before + after).trim(); + + writeFileSync(configPath, result ? result + '\n' : '', 'utf-8'); + } catch { + // Corrupt or unreadable config — server is effectively unregistered already + } +} + export function patchHttpHeaders( serverName: string, headers: Readonly>, diff --git a/packages/testing/src/scaffold/ide-scaffold.ts b/packages/testing/src/scaffold/ide-scaffold.ts index fda8e18..d2518a4 100644 --- a/packages/testing/src/scaffold/ide-scaffold.ts +++ b/packages/testing/src/scaffold/ide-scaffold.ts @@ -31,3 +31,9 @@ export function writeCursorCliConfig(projectDir: string, config: CursorCliConfig mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'cli.json'), JSON.stringify(config, null, 2)); } + +export function writeCodexConfig(projectDir: string, content: string): void { + const dir = join(projectDir, '.codex'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.toml'), content); +} diff --git a/packages/testing/src/scaffold/index.ts b/packages/testing/src/scaffold/index.ts index 36e2b5f..ded47cf 100644 --- a/packages/testing/src/scaffold/index.ts +++ b/packages/testing/src/scaffold/index.ts @@ -4,5 +4,6 @@ export { writeClaudeSettings, writeCursorMcpConfig, writeCursorCliConfig, + writeCodexConfig, } from './ide-scaffold.js'; export type { ProjectType } from './types.js'; From 0a886dd4c0cceec5a450faf51742285215e2b1cf Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 17:02:54 +0200 Subject: [PATCH 19/22] fix: scope cursor MCP disconnect to project config only Uninstalling a server from one project previously also removed it from the global ~/.cursor/mcp.json, breaking every other project that relied on the global entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integrations/mcp-disconnect.test.ts | 22 +++++++++++++++++-- packages/core/src/integrations/cursor/mcp.ts | 1 - 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/core/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts index 19539da..0b90bcf 100644 --- a/packages/core/__tests__/integrations/mcp-disconnect.test.ts +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -70,7 +70,7 @@ describe('cursor disconnectMcpServer', () => { return mod.disconnectMcpServer; } - it('removes the server entry from project and global MCP configs', async () => { + it('removes the server entry from project config and CLI permissions', async () => { using project = createProjectDir('empty'); writeCursorMcpConfig(project.path, { mcpServers: { @@ -83,7 +83,6 @@ describe('cursor disconnectMcpServer', () => { }); const sut = await loadDisconnect(); - vi.stubEnv('HOME', project.path); await sut({ serverName: 'confidence-flags', projectDir: project.path }); @@ -95,6 +94,25 @@ describe('cursor disconnectMcpServer', () => { expect(updatedCli.permissions.allow).toEqual(['Mcp(other:*)']); }); + it('does not remove the server from global config', async () => { + using project = createProjectDir('empty'); + using home = createProjectDir('empty'); + vi.stubEnv('HOME', home.path); + writeCursorMcpConfig(project.path, { + mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' } }, + }); + writeCursorMcpConfig(home.path, { + mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' } }, + }); + + const sut = await loadDisconnect(); + + await sut({ serverName: 'confidence-flags', projectDir: project.path }); + + const globalMcp = JSON.parse(readFileSync(join(home.path, '.cursor', 'mcp.json'), 'utf-8')); + expect(globalMcp.mcpServers).toHaveProperty('confidence-flags'); + }); + it('does not fail when config files do not exist', async () => { using project = createProjectDir('empty'); const sut = await loadDisconnect(); diff --git a/packages/core/src/integrations/cursor/mcp.ts b/packages/core/src/integrations/cursor/mcp.ts index 1b54154..56aeb60 100644 --- a/packages/core/src/integrations/cursor/mcp.ts +++ b/packages/core/src/integrations/cursor/mcp.ts @@ -41,7 +41,6 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { removeMcpEntry(mcpConfigPath(opts.projectDir), opts.serverName); - removeMcpEntry(globalConfigPath(), opts.serverName); removeCliPermission(cliConfigPath(opts.projectDir), opts.serverName); } From a1c4454af43b68cb2631fda3c295533b1305263f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 17:04:08 +0200 Subject: [PATCH 20/22] fix: clean claude settings before mcp remove to avoid half-cleaned state If `claude mcp remove` threw, removeMcpToolsFromSettings never ran, leaving stale permissions and enabled-server entries in settings.local.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integrations/mcp-disconnect.test.ts | 20 +++++++++++++++++++ packages/core/src/integrations/claude/mcp.ts | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/core/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts index 0b90bcf..c01f2da 100644 --- a/packages/core/__tests__/integrations/mcp-disconnect.test.ts +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -62,6 +62,26 @@ describe('claude disconnectMcpServer', () => { sut({ serverName: 'confidence-flags', projectDir: project.path }), ).resolves.toBeUndefined(); }); + + it('cleans settings even when claude mcp remove fails', async () => { + using project = createProjectDir('empty'); + writeClaudeSettings(project.path, { + permissions: { allow: ['mcp__confidence-flags__*'] }, + enabledMcpjsonServers: ['confidence-flags'], + }); + execFile.mockRejectedValueOnce(new Error('claude not found')); + const sut = await loadDisconnect(); + + await expect(sut({ serverName: 'confidence-flags', projectDir: project.path })).rejects.toThrow( + 'claude not found', + ); + + const settings = JSON.parse( + readFileSync(join(project.path, '.claude', 'settings.local.json'), 'utf-8'), + ); + expect(settings.permissions.allow).toEqual([]); + expect(settings.enabledMcpjsonServers).toEqual([]); + }); }); describe('cursor disconnectMcpServer', () => { diff --git a/packages/core/src/integrations/claude/mcp.ts b/packages/core/src/integrations/claude/mcp.ts index cb7ad8a..155ef73 100644 --- a/packages/core/src/integrations/claude/mcp.ts +++ b/packages/core/src/integrations/claude/mcp.ts @@ -54,11 +54,11 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { } export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeMcpToolsFromSettings(opts.serverName, opts.projectDir); + await execFile('claude', ['mcp', 'remove', '--scope', 'project', opts.serverName], { cwd: opts.projectDir, }); - - removeMcpToolsFromSettings(opts.serverName, opts.projectDir); } function removeMcpToolsFromSettings(serverName: string, projectDir: string): void { From c8d9d790ba3979f2c3690a3d0c06b5c4a8a5271d Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 17:06:13 +0200 Subject: [PATCH 21/22] refactor: narrow serverName to McpServerName in MCP opts Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/integrations/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/integrations/types.ts b/packages/core/src/integrations/types.ts index 1649a7d..dd146f4 100644 --- a/packages/core/src/integrations/types.ts +++ b/packages/core/src/integrations/types.ts @@ -8,7 +8,7 @@ export type InstalledPlugin = { }; export type McpConnectOpts = { - serverName: string; + serverName: McpServerName; serverUrl: string; serverType: string; serverHeaders: Record; @@ -17,7 +17,7 @@ export type McpConnectOpts = { }; export type McpDisconnectOpts = { - serverName: string; + serverName: McpServerName; projectDir: string; }; From ba403e9fdda8f103d6e5be7d2aed186f4943370e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 2 Oct 2026 17:10:07 +0200 Subject: [PATCH 22/22] fix: clear CI env var in simulateTTY to fix interactive tests in CI isInteractive() checks both stdin.isTTY and !isCI(), so stubbing only the TTY flag was insufficient when CI=true is set in the environment. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/helpers/simulate-tty.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/cli/__tests__/helpers/simulate-tty.ts b/packages/cli/__tests__/helpers/simulate-tty.ts index 6192fb1..6cc82c9 100644 --- a/packages/cli/__tests__/helpers/simulate-tty.ts +++ b/packages/cli/__tests__/helpers/simulate-tty.ts @@ -1,5 +1,15 @@ export function simulateTTY(isTTY: boolean): Disposable { - const original = process.stdin.isTTY; + const originalTTY = process.stdin.isTTY; + const originalCI = process.env.CI; process.stdin.isTTY = (isTTY || undefined) as typeof process.stdin.isTTY; - return { [Symbol.dispose]: () => void (process.stdin.isTTY = original) }; + + if (isTTY) delete process.env.CI; + + return { + [Symbol.dispose]: () => { + process.stdin.isTTY = originalTTY; + if (originalCI !== undefined) process.env.CI = originalCI; + else delete process.env.CI; + }, + }; }