import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; import type { ApiClient } from '../../src/api-client.js'; function mockClient(visibleSkills: unknown[] = []): ApiClient { return { get: vi.fn(async (path: string) => { if (path.endsWith('/skills/visible')) return visibleSkills; if (path.startsWith('/api/v1/skills/')) return { id: 's1', name: 'demo', description: 'd', semver: '1.0.0', content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null, }; return {}; }), post: vi.fn(async () => ({ token: 't', user: { email: 'x' } })), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } describe('config pi', () => { let client: ReturnType; let output: string[]; let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-pi-')); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('installs the extension + registers with a custom pi dir (skips skills)', async () => { // Stage a src/pi-ext source tree to install from. const srcPiExt = join(tmpDir, 'src-pi-ext'); mkdirSync(srcPiExt, { recursive: true }); writeFileSync(join(srcPiExt, 'mcpctl-pi.ts'), '// a\n'); writeFileSync(join(srcPiExt, 'mcp-http.ts'), '// b\n'); const piDir = join(tmpDir, 'pi-agent'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync( ['pi', '--project', 'docmost', '--extension-dir', srcPiExt, '--pi-dir', piDir, '--skip-skills'], { from: 'user' }, ); // Extension files landed in the pi dir. expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'))).toBe(true); expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcp-http.ts'))).toBe(true); // settings.json updated. const settings = JSON.parse(readFileSync(join(piDir, 'settings.json'), 'utf-8')); expect(settings.extensions).toContain(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts')); expect(settings.skills).toContain(join(piDir, 'skills')); // Active project persisted under the pi dir (fully isolated). const state = JSON.parse(readFileSync(join(piDir, 'pi-state.json'), 'utf-8')); expect(state.project).toBe('docmost'); // Output mentions the write. expect(output.join('\n')).toContain('Wrote active project'); }); it('requires a project', async () => { const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['pi', '--pi-dir', join(tmpDir, 'x'), '--skip-skills'], { from: 'user' }); expect(output.join('\n')).toContain('--project is required'); }); });