Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2ac7411
refactor: encapsulate representation as stdout write
bespoyasov Oct 2, 2026
96cf917
feat: extend integrations with MCP disconnectors
bespoyasov Oct 2, 2026
e5e3e95
chore: extend output with message and error
bespoyasov Oct 2, 2026
5a7eedf
chore: encapsulate exit code
bespoyasov Oct 2, 2026
4cb4d0b
chore: refactor existing commands
bespoyasov Oct 2, 2026
c4b1c5c
feat: implement MCP commands
bespoyasov Oct 2, 2026
d5a5f1c
test: cover feature with tests
bespoyasov Oct 2, 2026
87cd02d
chore: prettier
bespoyasov Oct 2, 2026
377065a
fix: use --dir instead of --project for MCP target directory
bespoyasov Oct 2, 2026
0c3f033
refactor: type STATUS_LABELS as Record<McpServerStatus, string>
bespoyasov Oct 2, 2026
08382f8
refactor: change authenticate() to accept an options object
bespoyasov Oct 2, 2026
a8a0eec
fix: set non-zero exit code on MCP action failures
bespoyasov Oct 2, 2026
ad4700c
fix: continue installing remaining MCP servers after a failure
bespoyasov Oct 2, 2026
b06d9ab
fix: report uninstall failures instead of swallowing all errors
bespoyasov Oct 2, 2026
fcb81b4
refactor: split MCP actions into separate files
bespoyasov Oct 2, 2026
fd70931
feat: add --ide flag and fail fast when non-interactive
bespoyasov Oct 2, 2026
2867aeb
fix: handle empty data consistently in print output
bespoyasov Oct 2, 2026
222a9d2
fix: remove codex MCP server from both project and global config
bespoyasov Oct 2, 2026
0a886dd
fix: scope cursor MCP disconnect to project config only
bespoyasov Oct 2, 2026
a1c4454
fix: clean claude settings before mcp remove to avoid half-cleaned state
bespoyasov Oct 2, 2026
c8d9d79
refactor: narrow serverName to McpServerName in MCP opts
bespoyasov Oct 2, 2026
ba403e9
fix: clear CI env var in simulateTTY to fix interactive tests in CI
bespoyasov Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions packages/cli/__tests__/commands/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@spotify-confidence/core')>();
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' }),
);
});
});
79 changes: 79 additions & 0 deletions packages/cli/__tests__/features/mcp/resolve-ide.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
30 changes: 30 additions & 0 deletions packages/cli/__tests__/helpers/capture.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
19 changes: 19 additions & 0 deletions packages/cli/__tests__/helpers/mock-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { IdeIntegration } from '@spotify-confidence/core';

export function createMockIntegration(overrides?: Partial<IdeIntegration>): 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,
};
}
15 changes: 15 additions & 0 deletions packages/cli/__tests__/helpers/simulate-tty.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
Loading
Loading