diff --git a/packages/cli/__tests__/commands/mcp.test.ts b/packages/cli/__tests__/commands/mcp.test.ts new file mode 100644 index 0000000..7095feb --- /dev/null +++ b/packages/cli/__tests__/commands/mcp.test.ts @@ -0,0 +1,215 @@ +import { resolve } from 'node:path'; +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), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function run(args: string[]) { + return yargs(args) + .option('json', { type: 'boolean', default: false }) + .option('output', { type: 'string' }) + .option('profile', { 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', '--ide', 'claude', '--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', '--ide', 'claude']); + + 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', '--ide', 'claude']); + + 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', '--ide', 'claude']); + + expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); + }); +}); + +describe('mcp uninstall', () => { + it('disconnects all MCP servers', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['mcp', 'uninstall', '--ide', 'claude']); + + 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', '--ide', 'claude']); + + expect(mockAuthenticate).toHaveBeenCalled(); + expect(mockIntegration.connectMcpServer).toHaveBeenCalledTimes(2); + }); +}); + +describe('--profile', () => { + it('forwards profile to authenticate during install', async () => { + using _output = captureOutput(); + + await run(['mcp', 'install', '--ide', 'claude', '--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', '--ide', 'claude', '--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'); + using _output = captureOutput(); + + await run(['mcp', 'install', '--ide', 'claude']); + + 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', '--ide', 'claude', '--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', '--ide', 'claude', '--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', '--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 new file mode 100644 index 0000000..6299aca --- /dev/null +++ b/packages/cli/__tests__/features/mcp/resolve-ide.test.ts @@ -0,0 +1,79 @@ +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(), +})); + +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 stdin is a TTY', async () => { + using _config = prepareAuthTokens('none'); + using _tty = simulateTTY(true); + 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'); + using _tty = simulateTTY(true); + vi.mocked(select).mockResolvedValueOnce('claude'); + const sut = await loadResolveIde(); + + await sut(); + + 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/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__/helpers/simulate-tty.ts b/packages/cli/__tests__/helpers/simulate-tty.ts new file mode 100644 index 0000000..6cc82c9 --- /dev/null +++ b/packages/cli/__tests__/helpers/simulate-tty.ts @@ -0,0 +1,15 @@ +export function simulateTTY(isTTY: boolean): Disposable { + const originalTTY = process.stdin.isTTY; + const originalCI = process.env.CI; + process.stdin.isTTY = (isTTY || undefined) as typeof process.stdin.isTTY; + + 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; + }, + }; +} diff --git a/packages/cli/__tests__/output/print.test.ts b/packages/cli/__tests__/output/print.test.ts new file mode 100644 index 0000000..927c169 --- /dev/null +++ b/packages/cli/__tests__/output/print.test.ts @@ -0,0 +1,164 @@ +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.'); + }); + + 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/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/config.ts b/packages/cli/src/commands/config.ts index fab5874..5cc28cb 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, message, fail, extractFlags } from '@output/print.js'; export const configCommand = { command: 'config ', @@ -26,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); } }, ) @@ -41,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); } }, ) @@ -57,30 +52,15 @@ 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), + empty: 'No configuration set.', }); - - 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( @@ -89,7 +69,7 @@ export const configCommand = { () => {}, () => { resetConfig(); - console.log('Configuration reset.'); + message('Configuration reset.'); }, ) .demandCommand( 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/login.ts b/packages/cli/src/commands/login.ts index 9a63720..68d0a70 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,16 @@ export const loginCommand = { const profile = argv.profile as string | undefined; try { - console.log('Opening browser for authentication...'); - const result = await authenticate('login', undefined, profile, (url) => { - console.log(`If the browser did not open, visit:\n${url}`); + message('Opening browser for authentication...'); + const result = await authenticate({ + mode: 'login', + profile, + onUrl: (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/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000..3a4dbf2 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,119 @@ +import type { Argv } from 'yargs'; +import { print, fail, extractFlags } from '@output/print.js'; +import { getAvailableMcpServers, type McpServerStatus } from '@spotify-confidence/core'; +import { + resolveIde, + resolveFlag, + resolveProjectDir, + 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', +}; + +export const mcpCommand = { + command: 'mcp ', + describe: 'Manage Confidence MCP server connections', + builder(yargs: Argv) { + return yargs + .option('dir', { + 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(resolveFlag('ide', argv)); + await installMcpServers(ideId, resolveProjectDir(argv), resolveFlag('profile', 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(resolveFlag('ide', argv)); + 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(resolveFlag('ide', argv)); + 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(resolveFlag('ide', argv)); + await refreshMcpAuth(ideId, resolveProjectDir(argv), resolveFlag('profile', 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/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index f3d3117..93ef0be 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,27 +1,21 @@ 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, fail, 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) { - 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; } @@ -34,30 +28,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/features/mcp/argv.ts b/packages/cli/src/features/mcp/argv.ts new file mode 100644 index 0000000..65f3ff6 --- /dev/null +++ b/packages/cli/src/features/mcp/argv.ts @@ -0,0 +1,10 @@ +import { resolve } from 'node:path'; + +export function resolveFlag(name: string, argv: Record): string | undefined { + return argv[name] 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 new file mode 100644 index 0000000..6c2718c --- /dev/null +++ b/packages/cli/src/features/mcp/index.ts @@ -0,0 +1,5 @@ +export { resolveIde } from './resolve-ide.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/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-ide.ts b/packages/cli/src/features/mcp/resolve-ide.ts new file mode 100644 index 0000000..c022cd2 --- /dev/null +++ b/packages/cli/src/features/mcp/resolve-ide.ts @@ -0,0 +1,43 @@ +import select from '@inquirer/select'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; +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); + + 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?', + choices: integrations.map((i) => ({ name: i.name, value: i.id })), + }); + + setConfigValue('ide', chosen); + + return chosen; +} 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}`, + }); +} diff --git a/packages/cli/src/output/index.ts b/packages/cli/src/output/index.ts index 6983be3..5264659 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, message, error, fail, 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..e11a12a --- /dev/null +++ b/packages/cli/src/output/print.ts @@ -0,0 +1,84 @@ +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 KeyValuePair = { + key: string; + value: string; +}; + +type FormatFlags = { + json?: boolean; + output?: OutputFormat; +}; + +type PrintTableOpts> = { + data: T[]; + columns: Column[]; + flags: FormatFlags; + empty?: string; +}; + +type PrintKeyValueOpts = { + data: Record; + columns: [Column, Column]; + flags: FormatFlags; + empty?: string; +}; + +export function message(text: string): void { + process.stdout.write(text + '\n'); +} + +export function error(text: string): void { + process.stderr.write(text + '\n'); +} + +export function fail(text: string): void { + error(text); + 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>( + opts: PrintTableOpts | PrintKeyValueOpts, +): void { + const format = resolveFormat(opts.flags); + + if (format === 'json') { + message(formatJson(opts.data)); + return; + } + + const rows = intoRows(opts.data) as T[]; + + if (rows.length === 0) { + message(opts.empty ?? 'No results.'); + return; + } + + message(formatTable(rows, opts.columns as Column[])); +} + +function intoRows( + data: Record | Record[], +): Record[] { + return Array.isArray(data) + ? data + : Object.entries(data).map(([key, value]) => ({ key, value: String(value ?? '') })); +} 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/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts new file mode 100644 index 0000000..c01f2da --- /dev/null +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -0,0 +1,182 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createProjectDir, + writeClaudeSettings, + writeCodexConfig, + 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(); + }); + + 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', () => { + async function loadDisconnect() { + const mod = await import('@integrations/cursor/mcp.js'); + return mod.disconnectMcpServer; + } + + it('removes the server entry from project config and CLI permissions', 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(); + + 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 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(); + + 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('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.path }); + + 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/__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/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/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..155ef73 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 { + removeMcpToolsFromSettings(opts.serverName, opts.projectDir); + + await execFile('claude', ['mcp', 'remove', '--scope', 'project', opts.serverName], { + cwd: 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..9ffddbc 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 { existsSync, 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,12 @@ export async function connectMcpServer(opts: McpConnectOpts): Promise { patchHttpHeaders(opts.serverName, headers); } +export function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeTomlSection(projectConfigPath(opts.projectDir), opts.serverName); + removeTomlSection(globalConfigPath(), opts.serverName); + return Promise.resolve(); +} + function getRegisteredMcpNames(projectDir: string): McpServerName[] { const names = Object.keys(MCP_SERVERS) as McpServerName[]; const paths = [globalConfigPath(), projectConfigPath(projectDir)]; @@ -45,6 +51,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,10 +70,30 @@ 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; } } +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>, @@ -105,6 +132,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..56aeb60 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,40 @@ 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); + 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 +78,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 +97,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..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; @@ -16,6 +16,11 @@ export type McpConnectOpts = { accessToken?: string; }; +export type McpDisconnectOpts = { + serverName: McpServerName; + 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; 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'; 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', diff --git a/packages/testing/src/scaffold/ide-scaffold.ts b/packages/testing/src/scaffold/ide-scaffold.ts new file mode 100644 index 0000000..d2518a4 --- /dev/null +++ b/packages/testing/src/scaffold/ide-scaffold.ts @@ -0,0 +1,39 @@ +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)); +} + +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 53eb232..ded47cf 100644 --- a/packages/testing/src/scaffold/index.ts +++ b/packages/testing/src/scaffold/index.ts @@ -1,3 +1,9 @@ export { createConfigDir } from './config-scaffold.js'; export { createProjectDir } from './project-scaffold.js'; +export { + writeClaudeSettings, + writeCursorMcpConfig, + writeCursorCliConfig, + writeCodexConfig, +} from './ide-scaffold.js'; export type { ProjectType } from './types.js'; 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: