import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createConfigSetupCommand } from '../../src/commands/config-setup.js'; import type { ConfigSetupDeps, ConfigSetupPrompt } from '../../src/commands/config-setup.js'; import type { SecretStore } from '@mcpctl/shared'; import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; let tempDir: string; let logs: string[]; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-setup-test-')); logs = []; }); function cleanup() { rmSync(tempDir, { recursive: true, force: true }); } function mockSecretStore(secrets: Record = {}): SecretStore { const store: Record = { ...secrets }; return { get: vi.fn(async (key: string) => store[key] ?? null), set: vi.fn(async (key: string, value: string) => { store[key] = value; }), delete: vi.fn(async () => true), backend: () => 'mock', }; } function mockPrompt(answers: unknown[]): ConfigSetupPrompt { let callIndex = 0; return { select: vi.fn(async () => answers[callIndex++]), input: vi.fn(async () => answers[callIndex++] as string), password: vi.fn(async () => answers[callIndex++] as string), confirm: vi.fn(async () => answers[callIndex++] as boolean), }; } function buildDeps(overrides: { secrets?: Record; answers?: unknown[]; fetchModels?: ConfigSetupDeps['fetchModels']; whichBinary?: ConfigSetupDeps['whichBinary']; } = {}): ConfigSetupDeps { return { configDeps: { configDir: tempDir }, secretStore: mockSecretStore(overrides.secrets), log: (...args: string[]) => logs.push(args.join(' ')), prompt: mockPrompt(overrides.answers ?? []), fetchModels: overrides.fetchModels ?? vi.fn(async () => []), whichBinary: overrides.whichBinary ?? vi.fn(async () => '/usr/bin/gemini'), }; } function readConfig(): Record { const raw = readFileSync(join(tempDir, 'config.json'), 'utf-8'); return JSON.parse(raw) as Record; } async function runSetup(deps: ConfigSetupDeps): Promise { const cmd = createConfigSetupCommand(deps); await cmd.parseAsync([], { from: 'user' }); } describe('config setup wizard', () => { describe('provider: none', () => { it('disables LLM and saves config', async () => { const deps = buildDeps({ answers: ['simple', 'none'] }); await runSetup(deps); const config = readConfig(); expect(config.llm).toEqual({ provider: 'none' }); expect(logs.some((l) => l.includes('LLM disabled'))).toBe(true); cleanup(); }); }); describe('provider: gemini-cli', () => { it('auto-detects binary path and saves config', async () => { // Answers: select provider, select model (no binary prompt — auto-detected) const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'], whichBinary: vi.fn(async () => '/home/user/.npm-global/bin/gemini'), }); await runSetup(deps); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('gemini-cli'); expect(llm.model).toBe('gemini-2.5-flash'); expect(llm.binaryPath).toBe('/home/user/.npm-global/bin/gemini'); expect(logs.some((l) => l.includes('Found gemini at'))).toBe(true); cleanup(); }); it('prompts for manual path when binary not found', async () => { // Answers: select provider, select model, enter manual path const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash', '/opt/gemini'], whichBinary: vi.fn(async () => null), }); await runSetup(deps); const config = readConfig(); const llm = config.llm as Record; expect(llm.binaryPath).toBe('/opt/gemini'); expect(logs.some((l) => l.includes('not found'))).toBe(true); cleanup(); }); it('saves gemini-cli with custom model', async () => { // Answers: select provider, select custom, enter model name const deps = buildDeps({ answers: ['simple', 'gemini-cli', '__custom__', 'gemini-3.0-flash'], whichBinary: vi.fn(async () => '/usr/bin/gemini'), }); await runSetup(deps); const config = readConfig(); const llm = config.llm as Record; expect(llm.model).toBe('gemini-3.0-flash'); cleanup(); }); }); describe('provider: ollama', () => { it('fetches models and allows selection', async () => { const fetchModels = vi.fn(async () => ['llama3.2', 'codellama', 'mistral']); // Answers: select provider, enter URL, select model const deps = buildDeps({ answers: ['simple', 'ollama', 'http://localhost:11434', 'codellama'], fetchModels, }); await runSetup(deps); expect(fetchModels).toHaveBeenCalledWith('http://localhost:11434', '/api/tags'); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('ollama'); expect(llm.model).toBe('codellama'); expect(llm.url).toBe('http://localhost:11434'); cleanup(); }); it('falls back to manual input when fetch fails', async () => { const fetchModels = vi.fn(async () => []); // Answers: select provider, enter URL, enter model manually const deps = buildDeps({ answers: ['simple', 'ollama', 'http://localhost:11434', 'llama3.2'], fetchModels, }); await runSetup(deps); const config = readConfig(); expect((config.llm as Record).model).toBe('llama3.2'); cleanup(); }); }); describe('provider: anthropic', () => { it('prompts for API key and saves to secret store', async () => { // Flow: simple → anthropic → (no existing key) → whichBinary('claude') returns null → // log tip → password prompt → select model const deps = buildDeps({ answers: ['simple', 'anthropic', 'sk-ant-new-key', 'claude-haiku-3-5-20241022'], whichBinary: vi.fn(async () => null), }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-new-key'); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('anthropic'); expect(llm.model).toBe('claude-haiku-3-5-20241022'); // API key should NOT be in config file expect(llm).not.toHaveProperty('apiKey'); cleanup(); }); it('shows existing key masked and allows keeping it', async () => { // Answers: select provider, confirm change=false, select model const deps = buildDeps({ secrets: { 'anthropic-api-key': 'sk-ant-existing-key-1234' }, answers: ['simple', 'anthropic', false, 'claude-sonnet-4-20250514'], }); await runSetup(deps); // Should NOT have called set (kept existing key) expect(deps.secretStore.set).not.toHaveBeenCalled(); const config = readConfig(); expect((config.llm as Record).model).toBe('claude-sonnet-4-20250514'); cleanup(); }); it('allows replacing existing key', async () => { // Answers: select provider, confirm change=true, enter new key, select model // Change=true → promptForAnthropicKey → whichBinary returns null → password prompt const deps = buildDeps({ secrets: { 'anthropic-api-key': 'sk-ant-old' }, answers: ['simple', 'anthropic', true, 'sk-ant-new', 'claude-haiku-3-5-20241022'], whichBinary: vi.fn(async () => null), }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-new'); cleanup(); }); it('detects claude binary and prompts for OAuth token', async () => { // Flow: simple → anthropic → (no existing key) → whichBinary finds claude → // confirm OAuth=true → password prompt → select model const deps = buildDeps({ answers: ['simple', 'anthropic', true, 'sk-ant-oat01-test-token', 'claude-haiku-3-5-20241022'], whichBinary: vi.fn(async () => '/usr/bin/claude'), }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-oat01-test-token'); expect(logs.some((l) => l.includes('Found Claude CLI at'))).toBe(true); expect(logs.some((l) => l.includes('claude setup-token'))).toBe(true); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('anthropic'); expect(llm.model).toBe('claude-haiku-3-5-20241022'); cleanup(); }); it('falls back to API key when claude binary not found', async () => { // Flow: simple → anthropic → (no existing key) → whichBinary returns null → // password prompt (API key) → select model const deps = buildDeps({ answers: ['simple', 'anthropic', 'sk-ant-api03-test', 'claude-sonnet-4-20250514'], whichBinary: vi.fn(async () => null), }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-api03-test'); expect(logs.some((l) => l.includes('Tip: Install Claude CLI'))).toBe(true); const config = readConfig(); const llm = config.llm as Record; expect(llm.model).toBe('claude-sonnet-4-20250514'); cleanup(); }); it('shows OAuth label when existing token is OAuth', async () => { // Flow: simple → anthropic → existing OAuth key → confirm change=false → select model const deps = buildDeps({ secrets: { 'anthropic-api-key': 'sk-ant-oat01-existing-token' }, answers: ['simple', 'anthropic', false, 'claude-haiku-3-5-20241022'], }); await runSetup(deps); // Should NOT have called set (kept existing key) expect(deps.secretStore.set).not.toHaveBeenCalled(); // Confirm prompt should have received an OAuth label expect(deps.prompt.confirm).toHaveBeenCalledWith( expect.stringContaining('OAuth token stored'), false, ); cleanup(); }); it('declines OAuth and enters API key instead', async () => { // Flow: simple → anthropic → (no existing key) → whichBinary finds claude → // confirm OAuth=false → password prompt (API key) → select model const deps = buildDeps({ answers: ['simple', 'anthropic', false, 'sk-ant-api03-manual', 'claude-sonnet-4-20250514'], whichBinary: vi.fn(async () => '/usr/bin/claude'), }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-api03-manual'); cleanup(); }); }); describe('provider: vllm', () => { it('fetches models from vLLM and allows selection', async () => { const fetchModels = vi.fn(async () => ['my-model', 'llama-70b']); // Answers: select provider, enter URL, select model const deps = buildDeps({ answers: ['simple', 'vllm', 'http://gpu:8000', 'llama-70b'], fetchModels, }); await runSetup(deps); expect(fetchModels).toHaveBeenCalledWith('http://gpu:8000', '/v1/models'); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('vllm'); expect(llm.url).toBe('http://gpu:8000'); expect(llm.model).toBe('llama-70b'); cleanup(); }); }); describe('provider: openai', () => { it('prompts for key, model, and optional custom endpoint', async () => { // Answers: select provider, enter key, enter model, confirm custom URL=true, enter URL const deps = buildDeps({ answers: ['simple', 'openai', 'sk-openai-key', 'gpt-4o', true, 'https://custom.api.com'], }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('openai-api-key', 'sk-openai-key'); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('openai'); expect(llm.model).toBe('gpt-4o'); expect(llm.url).toBe('https://custom.api.com'); cleanup(); }); it('skips custom URL when not requested', async () => { // Answers: select provider, enter key, enter model, confirm custom URL=false const deps = buildDeps({ answers: ['simple', 'openai', 'sk-openai-key', 'gpt-4o-mini', false], }); await runSetup(deps); const config = readConfig(); const llm = config.llm as Record; expect(llm.url).toBeUndefined(); cleanup(); }); }); describe('provider: deepseek', () => { it('prompts for key and model', async () => { // Answers: select provider, enter key, select model const deps = buildDeps({ answers: ['simple', 'deepseek', 'sk-ds-key', 'deepseek-chat'], }); await runSetup(deps); expect(deps.secretStore.set).toHaveBeenCalledWith('deepseek-api-key', 'sk-ds-key'); const config = readConfig(); const llm = config.llm as Record; expect(llm.provider).toBe('deepseek'); expect(llm.model).toBe('deepseek-chat'); cleanup(); }); }); describe('advanced mode: duplicate names', () => { it('generates unique default name when same provider added to both tiers', async () => { // Flow: advanced → // add fast? yes → anthropic → name "anthropic" (default) → whichBinary null → key → model → add more? no → // add heavy? yes → anthropic → name "anthropic-2" (deduped default) → existing key, keep → model → add more? no const deps = buildDeps({ answers: [ 'advanced', // fast tier true, // add fast? 'anthropic', // fast provider type 'anthropic', // provider name (default) 'sk-ant-oat01-token', // API key (whichBinary returns null → password prompt) 'claude-haiku-3-5-20241022', // model false, // add another fast? // heavy tier true, // add heavy? 'anthropic', // heavy provider type 'anthropic-2', // provider name (deduped default) false, // keep existing key 'claude-opus-4-20250514', // model false, // add another heavy? ], whichBinary: vi.fn(async () => null), }); await runSetup(deps); const config = readConfig(); const llm = config.llm as { providers: Array<{ name: string; type: string; model: string; tier: string }> }; expect(llm.providers).toHaveLength(2); expect(llm.providers[0].name).toBe('anthropic'); expect(llm.providers[0].tier).toBe('fast'); expect(llm.providers[1].name).toBe('anthropic-2'); expect(llm.providers[1].tier).toBe('heavy'); cleanup(); }); }); describe('output messages', () => { it('shows restart instruction', async () => { const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'] }); await runSetup(deps); expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true); cleanup(); }); it('shows configured provider and model', async () => { const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'] }); await runSetup(deps); expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true); cleanup(); }); }); });