Compare commits

..

4 Commits

Author SHA1 Message Date
Michal
11da8b1fbf feat: persistent Gemini ACP provider + status spinner
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Replace per-call gemini CLI spawning (~10s cold start each time) with
persistent ACP (Agent Client Protocol) subprocess. First call absorbs
the cold start, subsequent calls are near-instant over JSON-RPC stdio.

- Add AcpClient: manages persistent gemini --experimental-acp subprocess
  with lazy init, auto-restart on crash/timeout, NDJSON framing
- Add GeminiAcpProvider: LlmProvider wrapper with serial queue for
  concurrent calls, same interface as GeminiCliProvider
- Add dispose() to LlmProvider interface + disposeAll() to registry
- Wire provider disposal into mcplocal shutdown handler
- Add status command spinner with progressive output and color-coded
  LLM health check results (green checkmark/red cross)
- 25 new tests (17 ACP client + 8 provider)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:52:04 +00:00
Michal
848868d45f feat: auto-detect gemini binary path, LLM health check in status
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
- Setup wizard auto-detects gemini binary via `which`, saves full path
  so systemd service can find it without user PATH
- `mcpctl status` tests LLM provider health (gemini: quick prompt test,
  ollama: health check, API providers: key stored confirmation)
- Shows error details inline: "gemini-cli / gemini-2.5-flash (not authenticated)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:24:31 +00:00
Michal
869217a07a fix: exactOptionalPropertyTypes and ResponsePaginator type errors
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:15:15 +00:00
04d115933b Merge pull request 'feat: LLM provider configuration, secret store, and setup wizard' (#39) from feat/llm-config-and-secrets into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 22:48:39 +00:00
15 changed files with 1250 additions and 152 deletions

View File

@@ -1,11 +1,15 @@
import { Command } from 'commander'; import { Command } from 'commander';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { loadConfig, saveConfig } from '../config/index.js'; import { loadConfig, saveConfig } from '../config/index.js';
import type { ConfigLoaderDeps, McpctlConfig, LlmConfig, LlmProviderName } from '../config/index.js'; import type { ConfigLoaderDeps, McpctlConfig, LlmConfig, LlmProviderName } from '../config/index.js';
import type { SecretStore } from '@mcpctl/shared'; import type { SecretStore } from '@mcpctl/shared';
import { createSecretStore } from '@mcpctl/shared'; import { createSecretStore } from '@mcpctl/shared';
const execFileAsync = promisify(execFile);
export interface ConfigSetupPrompt { export interface ConfigSetupPrompt {
select<T>(message: string, choices: Array<{ name: string; value: T; description?: string }>): Promise<T>; select<T>(message: string, choices: Array<{ name: string; value: T; description?: string }>): Promise<T>;
input(message: string, defaultValue?: string): Promise<string>; input(message: string, defaultValue?: string): Promise<string>;
@@ -19,6 +23,7 @@ export interface ConfigSetupDeps {
log: (...args: string[]) => void; log: (...args: string[]) => void;
prompt: ConfigSetupPrompt; prompt: ConfigSetupPrompt;
fetchModels: (url: string, path: string) => Promise<string[]>; fetchModels: (url: string, path: string) => Promise<string[]>;
whichBinary: (name: string) => Promise<string | null>;
} }
interface ProviderChoice { interface ProviderChoice {
@@ -130,6 +135,16 @@ const defaultPrompt: ConfigSetupPrompt = {
confirm: defaultConfirm, confirm: defaultConfirm,
}; };
async function defaultWhichBinary(name: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync('which', [name], { timeout: 3000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Command { export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Command {
return new Command('setup') return new Command('setup')
.description('Interactive LLM provider setup wizard') .description('Interactive LLM provider setup wizard')
@@ -138,6 +153,7 @@ export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Comma
const log = deps?.log ?? ((...args: string[]) => console.log(...args)); const log = deps?.log ?? ((...args: string[]) => console.log(...args));
const prompt = deps?.prompt ?? defaultPrompt; const prompt = deps?.prompt ?? defaultPrompt;
const fetchModels = deps?.fetchModels ?? defaultFetchModels; const fetchModels = deps?.fetchModels ?? defaultFetchModels;
const whichBinary = deps?.whichBinary ?? defaultWhichBinary;
const secretStore = deps?.secretStore ?? await createSecretStore(); const secretStore = deps?.secretStore ?? await createSecretStore();
const config = loadConfig(configDeps); const config = loadConfig(configDeps);
@@ -164,7 +180,7 @@ export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Comma
switch (provider) { switch (provider) {
case 'gemini-cli': case 'gemini-cli':
llmConfig = await setupGeminiCli(prompt, currentLlm); llmConfig = await setupGeminiCli(prompt, log, whichBinary, currentLlm);
break; break;
case 'ollama': case 'ollama':
llmConfig = await setupOllama(prompt, fetchModels, currentLlm); llmConfig = await setupOllama(prompt, fetchModels, currentLlm);
@@ -192,7 +208,12 @@ export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Comma
}); });
} }
async function setupGeminiCli(prompt: ConfigSetupPrompt, current?: LlmConfig): Promise<LlmConfig> { async function setupGeminiCli(
prompt: ConfigSetupPrompt,
log: (...args: string[]) => void,
whichBinary: (name: string) => Promise<string | null>,
current?: LlmConfig,
): Promise<LlmConfig> {
const model = await prompt.select<string>('Select model:', [ const model = await prompt.select<string>('Select model:', [
...GEMINI_MODELS.map((m) => ({ ...GEMINI_MODELS.map((m) => ({
name: m === current?.model ? `${m} (current)` : m, name: m === current?.model ? `${m} (current)` : m,
@@ -205,10 +226,17 @@ async function setupGeminiCli(prompt: ConfigSetupPrompt, current?: LlmConfig): P
? await prompt.input('Model name:', current?.model) ? await prompt.input('Model name:', current?.model)
: model; : model;
const customBinary = await prompt.confirm('Use custom binary path?', false); // Auto-detect gemini binary path
const binaryPath = customBinary let binaryPath: string | undefined;
? await prompt.input('Binary path:', current?.binaryPath ?? 'gemini') const detected = await whichBinary('gemini');
: undefined; if (detected) {
log(`Found gemini at: ${detected}`);
binaryPath = detected;
} else {
log('Warning: gemini binary not found in PATH');
const manualPath = await prompt.input('Binary path (or install with: npm i -g @google/gemini-cli):');
if (manualPath) binaryPath = manualPath;
}
return { provider: 'gemini-cli', model: finalModel, binaryPath }; return { provider: 'gemini-cli', model: finalModel, binaryPath };
} }

View File

@@ -1,17 +1,31 @@
import { Command } from 'commander'; import { Command } from 'commander';
import http from 'node:http'; import http from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { loadConfig } from '../config/index.js'; import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js'; import type { ConfigLoaderDeps, LlmConfig } from '../config/index.js';
import { loadCredentials } from '../auth/index.js'; import { loadCredentials } from '../auth/index.js';
import type { CredentialsDeps } from '../auth/index.js'; import type { CredentialsDeps } from '../auth/index.js';
import { formatJson, formatYaml } from '../formatters/index.js'; import { formatJson, formatYaml } from '../formatters/index.js';
import { APP_VERSION } from '@mcpctl/shared'; import { APP_VERSION } from '@mcpctl/shared';
const execFileAsync = promisify(execFile);
// ANSI helpers
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const CLEAR_LINE = '\x1b[2K\r';
export interface StatusCommandDeps { export interface StatusCommandDeps {
configDeps: Partial<ConfigLoaderDeps>; configDeps: Partial<ConfigLoaderDeps>;
credentialsDeps: Partial<CredentialsDeps>; credentialsDeps: Partial<CredentialsDeps>;
log: (...args: string[]) => void; log: (...args: string[]) => void;
write: (text: string) => void;
checkHealth: (url: string) => Promise<boolean>; checkHealth: (url: string) => Promise<boolean>;
checkLlm: (llm: LlmConfig) => Promise<string>;
isTTY: boolean;
} }
function defaultCheckHealth(url: string): Promise<boolean> { function defaultCheckHealth(url: string): Promise<boolean> {
@@ -28,15 +42,51 @@ function defaultCheckHealth(url: string): Promise<boolean> {
}); });
} }
/**
* Quick LLM health check. Returns 'ok', 'binary not found', 'auth error', etc.
*/
async function defaultCheckLlm(llm: LlmConfig): Promise<string> {
if (llm.provider === 'gemini-cli') {
const bin = llm.binaryPath ?? 'gemini';
try {
const { stdout } = await execFileAsync(bin, ['-p', 'respond with exactly: ok', '-m', llm.model ?? 'gemini-2.5-flash', '-o', 'text'], { timeout: 15000 });
return stdout.trim().toLowerCase().includes('ok') ? 'ok' : 'unexpected response';
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('ENOENT')) return 'binary not found';
if (msg.includes('auth') || msg.includes('token') || msg.includes('login') || msg.includes('401')) return 'not authenticated';
return `error: ${msg.slice(0, 80)}`;
}
}
if (llm.provider === 'ollama') {
const url = llm.url ?? 'http://localhost:11434';
try {
const ok = await defaultCheckHealth(url);
return ok ? 'ok' : 'unreachable';
} catch {
return 'unreachable';
}
}
// For API-key providers, we don't want to make a billable call on every status check
return 'ok (key stored)';
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const defaultDeps: StatusCommandDeps = { const defaultDeps: StatusCommandDeps = {
configDeps: {}, configDeps: {},
credentialsDeps: {}, credentialsDeps: {},
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
write: (text) => process.stdout.write(text),
checkHealth: defaultCheckHealth, checkHealth: defaultCheckHealth,
checkLlm: defaultCheckLlm,
isTTY: process.stdout.isTTY ?? false,
}; };
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command { export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
const { configDeps, credentialsDeps, log, checkHealth } = { ...defaultDeps, ...deps }; const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, isTTY } = { ...defaultDeps, ...deps };
return new Command('status') return new Command('status')
.description('Show mcpctl status and connectivity') .description('Show mcpctl status and connectivity')
@@ -45,13 +95,20 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
const config = loadConfig(configDeps); const config = loadConfig(configDeps);
const creds = loadCredentials(credentialsDeps); const creds = loadCredentials(credentialsDeps);
const [mcplocalReachable, mcpdReachable] = await Promise.all([ const llmLabel = config.llm && config.llm.provider !== 'none'
? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}`
: null;
if (opts.output !== 'table') {
// JSON/YAML: run everything in parallel, wait, output at once
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
checkHealth(config.mcplocalUrl), checkHealth(config.mcplocalUrl),
checkHealth(config.mcpdUrl), checkHealth(config.mcpdUrl),
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
]); ]);
const llm = config.llm && config.llm.provider !== 'none' const llm = llmLabel
? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}` ? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})`
: null; : null;
const status = { const status = {
@@ -64,21 +121,59 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
registries: config.registries, registries: config.registries,
outputFormat: config.outputFormat, outputFormat: config.outputFormat,
llm, llm,
llmStatus,
}; };
if (opts.output === 'json') { log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
log(formatJson(status)); return;
} else if (opts.output === 'yaml') { }
log(formatYaml(status));
} else {
log(`mcpctl v${status.version}`);
log(`mcplocal: ${status.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
log(`mcpd: ${status.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`);
log(`Registries: ${status.registries.join(', ')}`);
log(`Output: ${status.outputFormat}`);
log(`LLM: ${status.llm ?? "not configured (run 'mcpctl config setup')"}`);
// Table format: print lines progressively, LLM last with spinner
// Fast health checks first
const [mcplocalReachable, mcpdReachable] = await Promise.all([
checkHealth(config.mcplocalUrl),
checkHealth(config.mcpdUrl),
]);
log(`mcpctl v${APP_VERSION}`);
log(`mcplocal: ${config.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
log(`mcpd: ${config.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`);
log(`Registries: ${config.registries.join(', ')}`);
log(`Output: ${config.outputFormat}`);
if (!llmLabel) {
log(`LLM: not configured (run 'mcpctl config setup')`);
return;
}
// LLM check with spinner
const llmPromise = checkLlm(config.llm!);
if (isTTY) {
let frame = 0;
const interval = setInterval(() => {
write(`${CLEAR_LINE}LLM: ${llmLabel} ${DIM}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} checking...${RESET}`);
frame++;
}, 80);
const llmStatus = await llmPromise;
clearInterval(interval);
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
write(`${CLEAR_LINE}LLM: ${llmLabel} ${GREEN}${llmStatus}${RESET}\n`);
} else {
write(`${CLEAR_LINE}LLM: ${llmLabel} ${RED}${llmStatus}${RESET}\n`);
}
} else {
// Non-TTY: no spinner, just wait and print
const llmStatus = await llmPromise;
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
log(`LLM: ${llmLabel}${llmStatus}`);
} else {
log(`LLM: ${llmLabel}${llmStatus}`);
}
} }
}); });
} }

View File

@@ -42,6 +42,7 @@ function buildDeps(overrides: {
secrets?: Record<string, string>; secrets?: Record<string, string>;
answers?: unknown[]; answers?: unknown[];
fetchModels?: ConfigSetupDeps['fetchModels']; fetchModels?: ConfigSetupDeps['fetchModels'];
whichBinary?: ConfigSetupDeps['whichBinary'];
} = {}): ConfigSetupDeps { } = {}): ConfigSetupDeps {
return { return {
configDeps: { configDir: tempDir }, configDeps: { configDir: tempDir },
@@ -49,6 +50,7 @@ function buildDeps(overrides: {
log: (...args: string[]) => logs.push(args.join(' ')), log: (...args: string[]) => logs.push(args.join(' ')),
prompt: mockPrompt(overrides.answers ?? []), prompt: mockPrompt(overrides.answers ?? []),
fetchModels: overrides.fetchModels ?? vi.fn(async () => []), fetchModels: overrides.fetchModels ?? vi.fn(async () => []),
whichBinary: overrides.whichBinary ?? vi.fn(async () => '/usr/bin/gemini'),
}; };
} }
@@ -76,26 +78,49 @@ describe('config setup wizard', () => {
}); });
describe('provider: gemini-cli', () => { describe('provider: gemini-cli', () => {
it('saves gemini-cli with selected model', async () => { it('auto-detects binary path and saves config', async () => {
// Answers: select provider, select model, confirm custom binary=false // Answers: select provider, select model (no binary prompt — auto-detected)
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash', false] }); const deps = buildDeps({
answers: ['gemini-cli', 'gemini-2.5-flash'],
whichBinary: vi.fn(async () => '/home/user/.npm-global/bin/gemini'),
});
await runSetup(deps); await runSetup(deps);
const config = readConfig(); const config = readConfig();
expect((config.llm as Record<string, unknown>).provider).toBe('gemini-cli'); const llm = config.llm as Record<string, unknown>;
expect((config.llm as Record<string, unknown>).model).toBe('gemini-2.5-flash'); 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(); cleanup();
}); });
it('saves gemini-cli with custom model and binary path', async () => { it('prompts for manual path when binary not found', async () => {
// Answers: select provider, select custom, enter model name, confirm custom binary=true, enter path // Answers: select provider, select model, enter manual path
const deps = buildDeps({ answers: ['gemini-cli', '__custom__', 'gemini-3.0-flash', true, '/opt/gemini'] }); const deps = buildDeps({
answers: ['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<string, unknown>;
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: ['gemini-cli', '__custom__', 'gemini-3.0-flash'],
whichBinary: vi.fn(async () => '/usr/bin/gemini'),
});
await runSetup(deps); await runSetup(deps);
const config = readConfig(); const config = readConfig();
const llm = config.llm as Record<string, unknown>; const llm = config.llm as Record<string, unknown>;
expect(llm.model).toBe('gemini-3.0-flash'); expect(llm.model).toBe('gemini-3.0-flash');
expect(llm.binaryPath).toBe('/opt/gemini');
cleanup(); cleanup();
}); });
}); });
@@ -250,7 +275,7 @@ describe('config setup wizard', () => {
describe('output messages', () => { describe('output messages', () => {
it('shows restart instruction', async () => { it('shows restart instruction', async () => {
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash', false] }); const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] });
await runSetup(deps); await runSetup(deps);
expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true); expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true);
@@ -258,7 +283,7 @@ describe('config setup wizard', () => {
}); });
it('shows configured provider and model', async () => { it('shows configured provider and model', async () => {
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash', false] }); const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] });
await runSetup(deps); await runSetup(deps);
expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true); expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true);

View File

@@ -3,19 +3,38 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { createStatusCommand } from '../../src/commands/status.js'; import { createStatusCommand } from '../../src/commands/status.js';
import type { StatusCommandDeps } from '../../src/commands/status.js';
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js'; import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
import { saveCredentials } from '../../src/auth/index.js'; import { saveCredentials } from '../../src/auth/index.js';
let tempDir: string; let tempDir: string;
let output: string[]; let output: string[];
let written: string[];
function log(...args: string[]) { function log(...args: string[]) {
output.push(args.join(' ')); output.push(args.join(' '));
} }
function write(text: string) {
written.push(text);
}
function baseDeps(overrides?: Partial<StatusCommandDeps>): Partial<StatusCommandDeps> {
return {
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
write,
checkHealth: async () => true,
isTTY: false,
...overrides,
};
}
beforeEach(() => { beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-')); tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
output = []; output = [];
written = [];
}); });
afterEach(() => { afterEach(() => {
@@ -24,12 +43,7 @@ afterEach(() => {
describe('status command', () => { describe('status command', () => {
it('shows status in table format', async () => { it('shows status in table format', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n'); const out = output.join('\n');
expect(out).toContain('mcpctl v'); expect(out).toContain('mcpctl v');
@@ -39,46 +53,26 @@ describe('status command', () => {
}); });
it('shows unreachable when daemons are down', async () => { it('shows unreachable when daemons are down', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false }));
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => false,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('unreachable'); expect(output.join('\n')).toContain('unreachable');
}); });
it('shows not logged in when no credentials', async () => { it('shows not logged in when no credentials', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('not logged in'); expect(output.join('\n')).toContain('not logged in');
}); });
it('shows logged in user when credentials exist', async () => { it('shows logged in user when credentials exist', async () => {
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir }); saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir });
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('logged in as alice@example.com'); expect(output.join('\n')).toContain('logged in as alice@example.com');
}); });
it('shows status in JSON format', async () => { it('shows status in JSON format', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' }); await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>; const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0'); expect(parsed['version']).toBe('0.1.0');
@@ -87,12 +81,7 @@ describe('status command', () => {
}); });
it('shows status in YAML format', async () => { it('shows status in YAML format', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false }));
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => false,
});
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' }); await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('mcplocalReachable: false'); expect(output[0]).toContain('mcplocalReachable: false');
}); });
@@ -100,15 +89,12 @@ describe('status command', () => {
it('checks correct URLs from config', async () => { it('checks correct URLs from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir }); saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir });
const checkedUrls: string[] = []; const checkedUrls: string[] = [];
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async (url) => { checkHealth: async (url) => {
checkedUrls.push(url); checkedUrls.push(url);
return false; return false;
}, },
}); }));
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(checkedUrls).toContain('http://local:3200'); expect(checkedUrls).toContain('http://local:3200');
expect(checkedUrls).toContain('http://remote:3100'); expect(checkedUrls).toContain('http://remote:3100');
@@ -116,24 +102,14 @@ describe('status command', () => {
it('shows registries from config', async () => { it('shows registries from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir }); saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('official'); expect(output.join('\n')).toContain('official');
expect(output.join('\n')).not.toContain('glama'); expect(output.join('\n')).not.toContain('glama');
}); });
it('shows LLM not configured hint when no LLM is set', async () => { it('shows LLM not configured hint when no LLM is set', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n'); const out = output.join('\n');
expect(out).toContain('LLM:'); expect(out).toContain('LLM:');
@@ -141,54 +117,75 @@ describe('status command', () => {
expect(out).toContain('mcpctl config setup'); expect(out).toContain('mcpctl config setup');
}); });
it('shows configured LLM provider and model', async () => { it('shows green check when LLM is healthy (non-TTY)', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' } }, { configDir: tempDir }); saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' } }, { configDir: tempDir });
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' }));
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n'); const out = output.join('\n');
expect(out).toContain('LLM:');
expect(out).toContain('anthropic / claude-haiku-3-5-20241022'); expect(out).toContain('anthropic / claude-haiku-3-5-20241022');
expect(out).toContain('✓ ok');
});
it('shows red cross when LLM check fails (non-TTY)', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'not authenticated' }));
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('✗ not authenticated');
});
it('shows binary not found error', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('✗ binary not found');
});
it('uses spinner on TTY and writes final result', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({
isTTY: true,
checkLlm: async () => 'ok',
}));
await cmd.parseAsync([], { from: 'user' });
// On TTY, the final LLM line goes through write(), not log()
const finalWrite = written[written.length - 1];
expect(finalWrite).toContain('gemini-cli / gemini-2.5-flash');
expect(finalWrite).toContain('✓ ok');
});
it('uses spinner on TTY and shows failure', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({
isTTY: true,
checkLlm: async () => 'not authenticated',
}));
await cmd.parseAsync([], { from: 'user' });
const finalWrite = written[written.length - 1];
expect(finalWrite).toContain('✗ not authenticated');
}); });
it('shows not configured when LLM provider is none', async () => { it('shows not configured when LLM provider is none', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'none' } }, { configDir: tempDir }); saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'none' } }, { configDir: tempDir });
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('not configured'); expect(output.join('\n')).toContain('not configured');
}); });
it('includes llm field in JSON output', async () => { it('includes llm and llmStatus in JSON output', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir }); saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' }));
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' }); await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>; const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['llm']).toBe('gemini-cli / gemini-2.5-flash'); expect(parsed['llm']).toBe('gemini-cli / gemini-2.5-flash');
expect(parsed['llmStatus']).toBe('ok');
}); });
it('includes null llm in JSON output when not configured', async () => { it('includes null llm in JSON output when not configured', async () => {
const cmd = createStatusCommand({ const cmd = createStatusCommand(baseDeps());
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' }); await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>; const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['llm']).toBeNull(); expect(parsed['llm']).toBeNull();
expect(parsed['llmStatus']).toBeNull();
}); });
}); });

View File

@@ -47,7 +47,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken); await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
// Wire pagination support with LLM provider if configured // Wire pagination support with LLM provider if configured
router.setPaginator(new ResponsePaginator(providerRegistry?.getActive() ?? null)); router.setPaginator(new ResponsePaginator(providerRegistry ?? null));
// Configure prompt resources with SA-scoped client for RBAC // Configure prompt resources with SA-scoped client for RBAC
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` }); const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });

View File

@@ -1,11 +1,16 @@
import type { SecretStore } from '@mcpctl/shared'; import type { SecretStore } from '@mcpctl/shared';
import type { LlmFileConfig } from './http/config.js'; import type { LlmFileConfig } from './http/config.js';
import { ProviderRegistry } from './providers/registry.js'; import { ProviderRegistry } from './providers/registry.js';
import { GeminiCliProvider } from './providers/gemini-cli.js'; import { GeminiAcpProvider } from './providers/gemini-acp.js';
import { OllamaProvider } from './providers/ollama.js'; import { OllamaProvider } from './providers/ollama.js';
import { AnthropicProvider } from './providers/anthropic.js'; import { AnthropicProvider } from './providers/anthropic.js';
import { OpenAiProvider } from './providers/openai.js'; import { OpenAiProvider } from './providers/openai.js';
import { DeepSeekProvider } from './providers/deepseek.js'; import { DeepSeekProvider } from './providers/deepseek.js';
import type { GeminiAcpConfig } from './providers/gemini-acp.js';
import type { OllamaConfig } from './providers/ollama.js';
import type { AnthropicConfig } from './providers/anthropic.js';
import type { OpenAiConfig } from './providers/openai.js';
import type { DeepSeekConfig } from './providers/deepseek.js';
/** /**
* Create a ProviderRegistry from user config + secret store. * Create a ProviderRegistry from user config + secret store.
@@ -19,19 +24,21 @@ export async function createProviderFromConfig(
if (!config?.provider || config.provider === 'none') return registry; if (!config?.provider || config.provider === 'none') return registry;
switch (config.provider) { switch (config.provider) {
case 'gemini-cli': case 'gemini-cli': {
registry.register(new GeminiCliProvider({ const cfg: GeminiAcpConfig = {};
binaryPath: config.binaryPath, if (config.binaryPath) cfg.binaryPath = config.binaryPath;
defaultModel: config.model, if (config.model) cfg.defaultModel = config.model;
})); registry.register(new GeminiAcpProvider(cfg));
break; break;
}
case 'ollama': case 'ollama': {
registry.register(new OllamaProvider({ const cfg: OllamaConfig = {};
baseUrl: config.url, if (config.url) cfg.baseUrl = config.url;
defaultModel: config.model, if (config.model) cfg.defaultModel = config.model;
})); registry.register(new OllamaProvider(cfg));
break; break;
}
case 'anthropic': { case 'anthropic': {
const apiKey = await secretStore.get('anthropic-api-key'); const apiKey = await secretStore.get('anthropic-api-key');
@@ -39,10 +46,9 @@ export async function createProviderFromConfig(
process.stderr.write('Warning: Anthropic API key not found in secret store. Run "mcpctl config setup" to configure.\n'); process.stderr.write('Warning: Anthropic API key not found in secret store. Run "mcpctl config setup" to configure.\n');
return registry; return registry;
} }
registry.register(new AnthropicProvider({ const cfg: AnthropicConfig = { apiKey };
apiKey, if (config.model) cfg.defaultModel = config.model;
defaultModel: config.model, registry.register(new AnthropicProvider(cfg));
}));
break; break;
} }
@@ -52,11 +58,10 @@ export async function createProviderFromConfig(
process.stderr.write('Warning: OpenAI API key not found in secret store. Run "mcpctl config setup" to configure.\n'); process.stderr.write('Warning: OpenAI API key not found in secret store. Run "mcpctl config setup" to configure.\n');
return registry; return registry;
} }
registry.register(new OpenAiProvider({ const cfg: OpenAiConfig = { apiKey };
apiKey, if (config.url) cfg.baseUrl = config.url;
baseUrl: config.url, if (config.model) cfg.defaultModel = config.model;
defaultModel: config.model, registry.register(new OpenAiProvider(cfg));
}));
break; break;
} }
@@ -66,11 +71,10 @@ export async function createProviderFromConfig(
process.stderr.write('Warning: DeepSeek API key not found in secret store. Run "mcpctl config setup" to configure.\n'); process.stderr.write('Warning: DeepSeek API key not found in secret store. Run "mcpctl config setup" to configure.\n');
return registry; return registry;
} }
registry.register(new DeepSeekProvider({ const cfg: DeepSeekConfig = { apiKey };
apiKey, if (config.url) cfg.baseUrl = config.url;
baseUrl: config.url, if (config.model) cfg.defaultModel = config.model;
defaultModel: config.model, registry.register(new DeepSeekProvider(cfg));
}));
break; break;
} }

View File

@@ -139,6 +139,7 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
providerRegistry.disposeAll();
server.stop(); server.stop();
if (httpServer) { if (httpServer) {
await httpServer.close(); await httpServer.close();

View File

@@ -0,0 +1,287 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
export interface AcpClientConfig {
binaryPath: string;
model: string;
/** Timeout for individual RPC requests in ms (default: 60000) */
requestTimeoutMs: number;
/** Timeout for process initialization in ms (default: 30000) */
initTimeoutMs: number;
/** Override spawn for testing */
spawn?: typeof spawn;
}
interface PendingRequest {
resolve: (result: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
/**
* Low-level ACP (Agent Client Protocol) client.
* Manages a persistent `gemini --experimental-acp` subprocess and communicates
* via JSON-RPC 2.0 over NDJSON stdio.
*
* Pattern follows StdioUpstream: readline for parsing, pending request map with timeouts.
*/
export class AcpClient {
private process: ChildProcess | null = null;
private readline: ReadlineInterface | null = null;
private pendingRequests = new Map<number, PendingRequest>();
private nextId = 1;
private sessionId: string | null = null;
private ready = false;
private initPromise: Promise<void> | null = null;
private readonly config: AcpClientConfig;
/** Accumulates text chunks from session/update agent_message_chunk during a prompt. */
private activePromptChunks: string[] = [];
constructor(config: AcpClientConfig) {
this.config = config;
}
/** Ensure the subprocess is spawned and initialized. Idempotent and lazy. */
async ensureReady(): Promise<void> {
if (this.ready && this.process && !this.process.killed) return;
// If already initializing, wait for it
if (this.initPromise) return this.initPromise;
this.initPromise = this.doInit();
try {
await this.initPromise;
} catch (err) {
this.initPromise = null;
throw err;
}
}
/** Send a prompt and collect the streamed text response. */
async prompt(text: string): Promise<string> {
await this.ensureReady();
// Set up chunk accumulator
this.activePromptChunks = [];
const result = await this.sendRequest('session/prompt', {
sessionId: this.sessionId,
prompt: [{ type: 'text', text }],
}, this.config.requestTimeoutMs) as { stopReason: string };
const collected = this.activePromptChunks.join('');
this.activePromptChunks = [];
if (result.stopReason === 'refusal') {
throw new Error('Gemini refused to process the prompt');
}
return collected;
}
/** Kill the subprocess and clean up. */
dispose(): void {
this.cleanup();
}
/** Check if the subprocess is alive and initialized. */
get isAlive(): boolean {
return this.ready && this.process !== null && !this.process.killed;
}
// --- Private ---
private async doInit(): Promise<void> {
// Clean up any previous state
this.cleanup();
this.spawnProcess();
this.setupReadline();
// ACP handshake: initialize
await this.sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: 'mcpctl', version: '1.0.0' },
}, this.config.initTimeoutMs);
// ACP handshake: session/new
const sessionResult = await this.sendRequest('session/new', {
cwd: '/tmp',
mcpServers: [],
}, this.config.initTimeoutMs) as { sessionId: string };
this.sessionId = sessionResult.sessionId;
this.ready = true;
}
private spawnProcess(): void {
const spawnFn = this.config.spawn ?? spawn;
this.process = spawnFn(this.config.binaryPath, ['--experimental-acp'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
});
this.process.on('exit', () => {
this.ready = false;
this.initPromise = null;
this.sessionId = null;
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Gemini ACP process exited'));
this.pendingRequests.delete(id);
}
});
this.process.on('error', (err) => {
this.ready = false;
this.initPromise = null;
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(err);
this.pendingRequests.delete(id);
}
});
}
private setupReadline(): void {
if (!this.process?.stdout) return;
this.readline = createInterface({ input: this.process.stdout });
this.readline.on('line', (line) => this.handleLine(line));
}
private handleLine(line: string): void {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line) as Record<string, unknown>;
} catch {
// Skip non-JSON lines (e.g., debug output on stdout)
return;
}
// Response to a pending request (has 'id')
if ('id' in msg && msg.id !== undefined && ('result' in msg || 'error' in msg)) {
this.handleResponse(msg as { id: number; result?: unknown; error?: { code: number; message: string } });
return;
}
// Notification (has 'method', no 'id')
if ('method' in msg && !('id' in msg)) {
this.handleNotification(msg as { method: string; params?: Record<string, unknown> });
return;
}
// Request from agent (has 'method' AND 'id') — agent asking us for something
if ('method' in msg && 'id' in msg) {
this.handleAgentRequest(msg as { id: number; method: string; params?: Record<string, unknown> });
return;
}
}
private handleResponse(msg: { id: number; result?: unknown; error?: { code: number; message: string } }): void {
const pending = this.pendingRequests.get(msg.id);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingRequests.delete(msg.id);
if (msg.error) {
pending.reject(new Error(`ACP error ${msg.error.code}: ${msg.error.message}`));
} else {
pending.resolve(msg.result);
}
}
private handleNotification(msg: { method: string; params?: Record<string, unknown> }): void {
if (msg.method !== 'session/update' || !msg.params) return;
const update = msg.params.update as Record<string, unknown> | undefined;
if (!update) return;
// Collect text from agent_message_chunk
if (update.sessionUpdate === 'agent_message_chunk') {
const content = update.content as Array<{ type: string; text?: string }> | undefined;
if (content) {
for (const block of content) {
if (block.type === 'text' && block.text) {
this.activePromptChunks.push(block.text);
}
}
}
}
}
/** Handle requests from the agent (e.g., session/request_permission). Reject them all. */
private handleAgentRequest(msg: { id: number; method: string; params?: Record<string, unknown> }): void {
if (!this.process?.stdin) return;
if (msg.method === 'session/request_permission') {
// Reject permission requests — we don't want tool use
const response = JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { outcome: { outcome: 'cancelled' } },
});
this.process.stdin.write(response + '\n');
} else {
// Unknown method — return error
const response = JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
error: { code: -32601, message: 'Method not supported' },
});
this.process.stdin.write(response + '\n');
}
}
private sendRequest(method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
if (!this.process?.stdin) {
return Promise.reject(new Error('ACP process not started'));
}
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
// Kill the process on timeout — it's hung
this.cleanup();
reject(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRequests.set(id, { resolve, reject, timer });
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
this.process!.stdin!.write(msg + '\n');
});
}
private cleanup(): void {
this.ready = false;
this.initPromise = null;
this.sessionId = null;
this.activePromptChunks = [];
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('ACP client disposed'));
this.pendingRequests.delete(id);
}
if (this.readline) {
this.readline.close();
this.readline = null;
}
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
}
}
}

View File

@@ -0,0 +1,97 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { LlmProvider, CompletionOptions, CompletionResult } from './types.js';
import { AcpClient } from './acp-client.js';
import type { AcpClientConfig } from './acp-client.js';
const execFileAsync = promisify(execFile);
export interface GeminiAcpConfig {
binaryPath?: string;
defaultModel?: string;
requestTimeoutMs?: number;
initTimeoutMs?: number;
/** Override for testing — passed through to AcpClient */
spawn?: AcpClientConfig['spawn'];
}
/**
* Gemini CLI provider using ACP (Agent Client Protocol) mode.
* Keeps the gemini process alive as a persistent subprocess, eliminating
* the ~10s cold-start per call. Auto-restarts on crash or timeout.
*/
export class GeminiAcpProvider implements LlmProvider {
readonly name = 'gemini-cli';
private client: AcpClient;
private binaryPath: string;
private defaultModel: string;
private queue: Promise<void> = Promise.resolve();
constructor(config?: GeminiAcpConfig) {
this.binaryPath = config?.binaryPath ?? 'gemini';
this.defaultModel = config?.defaultModel ?? 'gemini-2.5-flash';
const acpConfig: AcpClientConfig = {
binaryPath: this.binaryPath,
model: this.defaultModel,
requestTimeoutMs: config?.requestTimeoutMs ?? 60_000,
initTimeoutMs: config?.initTimeoutMs ?? 30_000,
};
if (config?.spawn) acpConfig.spawn = config.spawn;
this.client = new AcpClient(acpConfig);
}
async complete(options: CompletionOptions): Promise<CompletionResult> {
return this.enqueue(() => this.doComplete(options));
}
async listModels(): Promise<string[]> {
return ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'];
}
async isAvailable(): Promise<boolean> {
try {
await execFileAsync(this.binaryPath, ['--version'], { timeout: 5000 });
return true;
} catch {
return false;
}
}
dispose(): void {
this.client.dispose();
}
// --- Private ---
private async doComplete(options: CompletionOptions): Promise<CompletionResult> {
const prompt = options.messages
.map((m) => {
if (m.role === 'system') return `System: ${m.content}`;
if (m.role === 'user') return m.content;
if (m.role === 'assistant') return `Assistant: ${m.content}`;
return m.content;
})
.join('\n\n');
const content = await this.client.prompt(prompt);
return {
content: content.trim(),
toolCalls: [],
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
finishReason: 'stop',
};
}
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = new Promise<T>((resolve, reject) => {
this.queue = this.queue.then(
() => fn().then(resolve, reject),
() => fn().then(resolve, reject),
);
});
return result;
}
}

View File

@@ -9,4 +9,8 @@ export { GeminiCliProvider } from './gemini-cli.js';
export type { GeminiCliConfig } from './gemini-cli.js'; export type { GeminiCliConfig } from './gemini-cli.js';
export { DeepSeekProvider } from './deepseek.js'; export { DeepSeekProvider } from './deepseek.js';
export type { DeepSeekConfig } from './deepseek.js'; export type { DeepSeekConfig } from './deepseek.js';
export { GeminiAcpProvider } from './gemini-acp.js';
export type { GeminiAcpConfig } from './gemini-acp.js';
export { AcpClient } from './acp-client.js';
export type { AcpClientConfig } from './acp-client.js';
export { ProviderRegistry } from './registry.js'; export { ProviderRegistry } from './registry.js';

View File

@@ -45,4 +45,11 @@ export class ProviderRegistry {
getActiveName(): string | null { getActiveName(): string | null {
return this.activeProvider; return this.activeProvider;
} }
/** Dispose all registered providers that have a dispose method. */
disposeAll(): void {
for (const provider of this.providers.values()) {
provider.dispose?.();
}
}
} }

View File

@@ -53,4 +53,6 @@ export interface LlmProvider {
listModels(): Promise<string[]>; listModels(): Promise<string[]>;
/** Check if the provider is configured and reachable */ /** Check if the provider is configured and reachable */
isAvailable(): Promise<boolean>; isAvailable(): Promise<boolean>;
/** Optional cleanup for providers with persistent resources (e.g., subprocesses). */
dispose?(): void;
} }

View File

@@ -0,0 +1,415 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter, Readable } from 'node:stream';
import { AcpClient } from '../src/providers/acp-client.js';
import type { AcpClientConfig } from '../src/providers/acp-client.js';
/**
* Creates a mock child process that speaks ACP protocol.
* Returns the mock process and helpers to send responses.
*/
function createMockProcess() {
const stdin = {
write: vi.fn(),
writable: true,
};
const stdoutEmitter = new EventEmitter();
const stdout = Object.assign(stdoutEmitter, {
readable: true,
// readline needs these
[Symbol.asyncIterator]: undefined,
pause: vi.fn(),
resume: vi.fn(),
isPaused: () => false,
setEncoding: vi.fn(),
read: vi.fn(),
destroy: vi.fn(),
pipe: vi.fn(),
unpipe: vi.fn(),
unshift: vi.fn(),
wrap: vi.fn(),
}) as unknown as Readable;
const proc = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr: new EventEmitter(),
pid: 12345,
killed: false,
kill: vi.fn(function (this: { killed: boolean }) {
this.killed = true;
}),
});
/** Send a line of JSON from the "agent" to our client */
function sendLine(data: unknown) {
stdoutEmitter.emit('data', Buffer.from(JSON.stringify(data) + '\n'));
}
/** Send a JSON-RPC response */
function sendResponse(id: number, result: unknown) {
sendLine({ jsonrpc: '2.0', id, result });
}
/** Send a JSON-RPC error */
function sendError(id: number, code: number, message: string) {
sendLine({ jsonrpc: '2.0', id, error: { code, message } });
}
/** Send a session/update notification with agent_message_chunk */
function sendChunk(sessionId: string, text: string) {
sendLine({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: [{ type: 'text', text }],
},
},
});
}
/** Send a session/request_permission request */
function sendPermissionRequest(id: number, sessionId: string) {
sendLine({
jsonrpc: '2.0',
id,
method: 'session/request_permission',
params: { sessionId },
});
}
return { proc, stdin, stdout: stdoutEmitter, sendLine, sendResponse, sendError, sendChunk, sendPermissionRequest };
}
function createConfig(overrides?: Partial<AcpClientConfig>): AcpClientConfig {
return {
binaryPath: '/usr/bin/gemini',
model: 'gemini-2.5-flash',
requestTimeoutMs: 5000,
initTimeoutMs: 5000,
...overrides,
};
}
describe('AcpClient', () => {
let client: AcpClient;
let mock: ReturnType<typeof createMockProcess>;
beforeEach(() => {
mock = createMockProcess();
});
afterEach(() => {
client?.dispose();
});
function createClient(configOverrides?: Partial<AcpClientConfig>) {
const config = createConfig({
spawn: (() => mock.proc) as unknown as AcpClientConfig['spawn'],
...configOverrides,
});
client = new AcpClient(config);
return client;
}
/** Helper: auto-respond to the initialize + session/new handshake */
function autoHandshake(sessionId = 'test-session') {
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'initialize') {
// Respond async to simulate real behavior
setImmediate(() => mock.sendResponse(msg.id, {
protocolVersion: 1,
agentInfo: { name: 'gemini-cli', version: '1.0.0' },
}));
} else if (msg.method === 'session/new') {
setImmediate(() => mock.sendResponse(msg.id, { sessionId }));
}
});
}
describe('ensureReady', () => {
it('spawns process and completes ACP handshake', async () => {
createClient();
autoHandshake();
await client.ensureReady();
expect(client.isAlive).toBe(true);
// Verify initialize was sent
const calls = mock.stdin.write.mock.calls.map((c) => JSON.parse(c[0] as string));
expect(calls[0].method).toBe('initialize');
expect(calls[0].params.protocolVersion).toBe(1);
expect(calls[0].params.clientInfo.name).toBe('mcpctl');
// Verify session/new was sent
expect(calls[1].method).toBe('session/new');
expect(calls[1].params.cwd).toBe('/tmp');
expect(calls[1].params.mcpServers).toEqual([]);
});
it('is idempotent when already ready', async () => {
createClient();
autoHandshake();
await client.ensureReady();
await client.ensureReady();
// Should only have sent initialize + session/new once
const calls = mock.stdin.write.mock.calls;
expect(calls.length).toBe(2);
});
it('shares init promise for concurrent calls', async () => {
createClient();
autoHandshake();
const p1 = client.ensureReady();
const p2 = client.ensureReady();
await Promise.all([p1, p2]);
expect(mock.stdin.write.mock.calls.length).toBe(2);
});
});
describe('prompt', () => {
it('sends session/prompt and collects agent_message_chunk text', async () => {
createClient();
const sessionId = 'sess-123';
autoHandshake(sessionId);
await client.ensureReady();
// Now set up the prompt response handler
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'session/prompt') {
setImmediate(() => {
mock.sendChunk(sessionId, 'Hello ');
mock.sendChunk(sessionId, 'world!');
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
});
}
});
const result = await client.prompt('Say hello');
expect(result).toBe('Hello world!');
});
it('handles multi-block content in a single chunk', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'session/prompt') {
setImmediate(() => {
mock.sendLine({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: [
{ type: 'text', text: 'Part A' },
{ type: 'text', text: ' Part B' },
],
},
},
});
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
});
}
});
const result = await client.prompt('test');
expect(result).toBe('Part A Part B');
});
it('calls ensureReady automatically (lazy init)', async () => {
createClient();
autoHandshake('sess-auto');
// After handshake, handle prompts
const originalWrite = mock.stdin.write;
let handshakeDone = false;
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'initialize') {
setImmediate(() => mock.sendResponse(msg.id, { protocolVersion: 1 }));
} else if (msg.method === 'session/new') {
setImmediate(() => {
mock.sendResponse(msg.id, { sessionId: 'sess-auto' });
handshakeDone = true;
});
} else if (msg.method === 'session/prompt') {
setImmediate(() => {
mock.sendChunk('sess-auto', 'ok');
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
});
}
});
// Call prompt directly without ensureReady
const result = await client.prompt('test');
expect(result).toBe('ok');
});
});
describe('auto-restart', () => {
it('restarts after process exit', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
expect(client.isAlive).toBe(true);
// Simulate process exit
mock.proc.killed = true;
mock.proc.emit('exit', 1);
expect(client.isAlive).toBe(false);
// Create a new mock for the respawned process
mock = createMockProcess();
// Update the spawn function to return new mock
(client as unknown as { config: { spawn: unknown } }).config.spawn = () => mock.proc;
autoHandshake('sess-2');
await client.ensureReady();
expect(client.isAlive).toBe(true);
});
});
describe('timeout', () => {
it('kills process and rejects on request timeout', async () => {
createClient({ requestTimeoutMs: 50 });
autoHandshake('sess-1');
await client.ensureReady();
// Don't respond to the prompt — let it timeout
mock.stdin.write.mockImplementation(() => {});
await expect(client.prompt('test')).rejects.toThrow('timed out');
expect(client.isAlive).toBe(false);
});
it('rejects on init timeout', async () => {
createClient({ initTimeoutMs: 50 });
// Don't respond to initialize
mock.stdin.write.mockImplementation(() => {});
await expect(client.ensureReady()).rejects.toThrow('timed out');
});
});
describe('error handling', () => {
it('rejects on ACP error response', async () => {
createClient();
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
setImmediate(() => mock.sendError(msg.id, -32603, 'Internal error'));
});
await expect(client.ensureReady()).rejects.toThrow('ACP error -32603: Internal error');
});
it('rejects pending requests on process crash', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
// Override write so prompt sends but gets no response; then crash the process
mock.stdin.write.mockImplementation(() => {
// After the prompt is sent, simulate a process crash
setImmediate(() => {
mock.proc.killed = true;
mock.proc.emit('exit', 1);
});
});
const promptPromise = client.prompt('test');
await expect(promptPromise).rejects.toThrow('process exited');
});
});
describe('permission requests', () => {
it('rejects session/request_permission from agent', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'session/prompt') {
setImmediate(() => {
// Agent asks for permission first
mock.sendPermissionRequest(100, 'sess-1');
// Then provides the actual response
mock.sendChunk('sess-1', 'done');
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
});
}
});
const result = await client.prompt('test');
expect(result).toBe('done');
// Verify we sent a rejection for the permission request
const writes = mock.stdin.write.mock.calls.map((c) => {
try { return JSON.parse(c[0] as string); } catch { return null; }
}).filter(Boolean);
const rejection = writes.find((w: Record<string, unknown>) => w.id === 100);
expect(rejection).toBeTruthy();
expect((rejection as { result: { outcome: { outcome: string } } }).result.outcome.outcome).toBe('cancelled');
});
});
describe('dispose', () => {
it('kills process and rejects pending', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
// Override write so prompt is sent but gets no response; then dispose
mock.stdin.write.mockImplementation(() => {
setImmediate(() => client.dispose());
});
const promptPromise = client.prompt('test');
await expect(promptPromise).rejects.toThrow('disposed');
expect(mock.proc.kill).toHaveBeenCalledWith('SIGTERM');
});
it('is safe to call multiple times', () => {
createClient();
client.dispose();
client.dispose();
// No error thrown
});
});
describe('isAlive', () => {
it('returns false before init', () => {
createClient();
expect(client.isAlive).toBe(false);
});
it('returns true after successful init', async () => {
createClient();
autoHandshake();
await client.ensureReady();
expect(client.isAlive).toBe(true);
});
it('returns false after dispose', async () => {
createClient();
autoHandshake();
await client.ensureReady();
client.dispose();
expect(client.isAlive).toBe(false);
});
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockEnsureReady = vi.fn(async () => {});
const mockPrompt = vi.fn(async () => 'mock response');
const mockDispose = vi.fn();
vi.mock('../src/providers/acp-client.js', () => ({
AcpClient: vi.fn(function (this: Record<string, unknown>) {
this.ensureReady = mockEnsureReady;
this.prompt = mockPrompt;
this.dispose = mockDispose;
}),
}));
// Must import after mock setup
const { GeminiAcpProvider } = await import('../src/providers/gemini-acp.js');
describe('GeminiAcpProvider', () => {
let provider: InstanceType<typeof GeminiAcpProvider>;
beforeEach(() => {
vi.clearAllMocks();
mockPrompt.mockResolvedValue('mock response');
provider = new GeminiAcpProvider({ binaryPath: '/usr/bin/gemini', defaultModel: 'gemini-2.5-flash' });
});
describe('complete', () => {
it('builds prompt from messages and returns CompletionResult', async () => {
mockPrompt.mockResolvedValueOnce('The answer is 42.');
const result = await provider.complete({
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'What is the answer?' },
],
});
expect(result.content).toBe('The answer is 42.');
expect(result.toolCalls).toEqual([]);
expect(result.finishReason).toBe('stop');
const promptText = mockPrompt.mock.calls[0][0] as string;
expect(promptText).toContain('System: You are helpful.');
expect(promptText).toContain('What is the answer?');
});
it('formats assistant messages with prefix', async () => {
mockPrompt.mockResolvedValueOnce('ok');
await provider.complete({
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
],
});
const promptText = mockPrompt.mock.calls[0][0] as string;
expect(promptText).toContain('Assistant: Hi there');
});
it('trims response content', async () => {
mockPrompt.mockResolvedValueOnce(' padded response \n');
const result = await provider.complete({
messages: [{ role: 'user', content: 'test' }],
});
expect(result.content).toBe('padded response');
});
it('serializes concurrent calls', async () => {
const callOrder: number[] = [];
let callCount = 0;
mockPrompt.mockImplementation(async () => {
const myCall = ++callCount;
callOrder.push(myCall);
await new Promise((r) => setTimeout(r, 10));
return `response-${myCall}`;
});
const [r1, r2, r3] = await Promise.all([
provider.complete({ messages: [{ role: 'user', content: 'a' }] }),
provider.complete({ messages: [{ role: 'user', content: 'b' }] }),
provider.complete({ messages: [{ role: 'user', content: 'c' }] }),
]);
expect(r1.content).toBe('response-1');
expect(r2.content).toBe('response-2');
expect(r3.content).toBe('response-3');
expect(callOrder).toEqual([1, 2, 3]);
});
it('continues queue after error', async () => {
mockPrompt
.mockRejectedValueOnce(new Error('first fails'))
.mockResolvedValueOnce('second works');
const results = await Promise.allSettled([
provider.complete({ messages: [{ role: 'user', content: 'a' }] }),
provider.complete({ messages: [{ role: 'user', content: 'b' }] }),
]);
expect(results[0].status).toBe('rejected');
expect(results[1].status).toBe('fulfilled');
if (results[1].status === 'fulfilled') {
expect(results[1].value.content).toBe('second works');
}
});
});
describe('listModels', () => {
it('returns static model list', async () => {
const models = await provider.listModels();
expect(models).toContain('gemini-2.5-flash');
expect(models).toContain('gemini-2.5-pro');
expect(models).toContain('gemini-2.0-flash');
});
});
describe('dispose', () => {
it('delegates to AcpClient', () => {
provider.dispose();
expect(mockDispose).toHaveBeenCalled();
});
});
describe('name', () => {
it('is gemini-cli for config compatibility', () => {
expect(provider.name).toBe('gemini-cli');
});
});
});

View File

@@ -25,7 +25,7 @@ describe('createProviderFromConfig', () => {
expect(registry.getActive()).toBeNull(); expect(registry.getActive()).toBeNull();
}); });
it('creates gemini-cli provider', async () => { it('creates gemini-cli provider using ACP', async () => {
const store = mockSecretStore(); const store = mockSecretStore();
const registry = await createProviderFromConfig( const registry = await createProviderFromConfig(
{ provider: 'gemini-cli', model: 'gemini-2.5-flash', binaryPath: '/usr/bin/gemini' }, { provider: 'gemini-cli', model: 'gemini-2.5-flash', binaryPath: '/usr/bin/gemini' },
@@ -33,6 +33,8 @@ describe('createProviderFromConfig', () => {
); );
expect(registry.getActive()).not.toBeNull(); expect(registry.getActive()).not.toBeNull();
expect(registry.getActive()!.name).toBe('gemini-cli'); expect(registry.getActive()!.name).toBe('gemini-cli');
// ACP provider has dispose method
expect(typeof registry.getActive()!.dispose).toBe('function');
}); });
it('creates ollama provider', async () => { it('creates ollama provider', async () => {