import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; import type { ApiClient } from '../../src/api-client.js'; import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; function mockClient(): ApiClient { return { get: vi.fn(async () => ({})), post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } describe('config prime-agent', () => { let client: ReturnType; let output: string[]; let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); let prevCwd: string; beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-')); // config prime-agent writes the .mcpctl-project marker into cwd, so run // every test from an isolated temp dir to avoid polluting the repo. prevCwd = process.cwd(); process.chdir(tmpDir); }); afterEach(() => { process.chdir(prevCwd); process.exitCode = 0; rmSync(tmpDir, { recursive: true, force: true }); }); it('requires --project', async () => { const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' }); expect(output.join('\n')).toContain('--project is required'); expect(process.exitCode).toBe(1); }); it('writes proxy MCP entry into prime-agent settings.json', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' }); const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.mcpServers['homeautomation']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true, }); expect(output.join('\n')).toContain('homeautomation'); }); it('merges with existing servers and preserves other settings', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ defaultProvider: 'itaz', mcpServers: { sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, }, })); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.defaultProvider).toBe('itaz'); // untouched expect(written.mcpServers['sre']).toBeDefined(); // preserved expect(written.mcpServers['proj-1']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, mcpctlManaged: true, }); }); it('writes a project marker for later skills sync', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' }); const markerPath = join(tmpDir, '.mcpctl-project'); expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre'); }); it('--dry-run prints the change without writing', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' }); expect(output.join('\n')).toContain('proj-2'); // No file should have been created. expect(exceptionSafeRead(settingsPath)).toBeNull(); }); it('does not call the API when --skip-skills and --token are given', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' }); expect(client.get).not.toHaveBeenCalled(); expect(client.post).not.toHaveBeenCalled(); }); it('backward compat: prime-agent-generate still works', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.mcpServers['proj-1']).toBeDefined(); }); it('provisions auth.json by minting a project token', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' })); const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' }); }); it('uses --token without calling the API', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' }); expect(client.post).not.toHaveBeenCalled(); const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' }); }); it('keeps an existing credential and does not re-mint', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } })); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); expect(client.post).not.toHaveBeenCalled(); const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); expect(auth['mcp:labctl'].key).toBe('existing'); }); it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); const extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts'); expect(existsSync(extPath)).toBe(true); expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'"); output.length = 0; const cmd2 = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); expect(output.join('\n')).not.toContain('switcher extension'); }); it('does not write a .mcpctl-project marker when run from $HOME', async () => { const settingsPath = join(tmpDir, 'settings.json'); const prevCwd = process.cwd(); process.chdir(homedir()); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); try { await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); } finally { process.chdir(prevCwd); } expect(output.join('\n')).toContain('Skipped .mcpctl-project marker'); expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull(); }); it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); expect(output.join('\n')).toContain('refusing to overwrite'); expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json'); expect(process.exitCode).toBe(1); }); it('exits non-zero when a credential cannot be provisioned', async () => { // mockClient post returns { token: ... } by default; override to no token. const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client; const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); expect(process.exitCode).toBe(1); // body of provisioning error surfaced expect(output.join('\n')).toContain('no token returned'); }); it('writes auth.json with mode 0600', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777; expect(mode).toBe(0o600); }); it('refuses to overwrite a corrupt settings.json', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, '{ this is not valid json !!!'); const prevCwd = process.cwd(); process.chdir(tmpDir); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); try { await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); } finally { process.chdir(prevCwd); } expect(output.join('\n')).toContain('refusing to overwrite'); // The corrupt file is untouched. expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!'); }); it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ mcpServers: { sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, }, })); // Active project is homeautomation (tagged). Switch to labctl. const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved }); it('merges a re-configured project entry, preserving user-added fields', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ mcpServers: { ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } }, }, })); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.mcpServers['ha']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`, headers: { Authorization: 'Bearer u' }, // user-added field preserved mcpctlManaged: true, }); }); }); function exceptionSafeRead(path: string): string | null { try { return readFileSync(path, 'utf-8'); } catch { return null; } }