import { Command } from 'commander'; import http from 'node:http'; import https from 'node:https'; /** Pick the http or https driver based on the URL scheme. */ function httpDriverFor(url: string): typeof http | typeof https { return new URL(url).protocol === 'https:' ? https : http; } import { loadConfig } from '../config/index.js'; import type { ConfigLoaderDeps } from '../config/index.js'; import { loadCredentials } from '../auth/index.js'; import type { CredentialsDeps } from '../auth/index.js'; import { formatJson, formatYaml } from '../formatters/index.js'; import { APP_VERSION } from '@mcpctl/shared'; // ANSI helpers const GREEN = '\x1b[32m'; const RED = '\x1b[31m'; const YELLOW = '\x1b[33m'; const DIM = '\x1b[2m'; const RESET = '\x1b[0m'; const CLEAR_LINE = '\x1b[2K\r'; interface ProviderDetail { managed: boolean; state?: string; lastError?: string; } interface ProvidersInfo { providers: string[]; tiers: { fast: string[]; heavy: string[] }; health: Record; details?: Record; } export interface StatusCommandDeps { configDeps: Partial; credentialsDeps: Partial; log: (...args: string[]) => void; write: (text: string) => void; checkHealth: (url: string) => Promise; /** Check LLM health via mcplocal's /llm/health endpoint */ checkLlm: (mcplocalUrl: string) => Promise; /** Fetch available models from mcplocal's /llm/models endpoint */ fetchModels: (mcplocalUrl: string) => Promise; /** Fetch provider tier info from mcplocal's /llm/providers endpoint */ fetchProviders: (mcplocalUrl: string) => Promise; isTTY: boolean; } function defaultCheckHealth(url: string): Promise { return new Promise((resolve) => { let req: http.ClientRequest; try { req = httpDriverFor(url).get(`${url}/health`, { timeout: 3000 }, (res) => { resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400); res.resume(); }); } catch { resolve(false); return; } req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } /** * Check LLM health by querying mcplocal's /llm/health endpoint. * This tests the actual provider running inside the daemon (uses persistent ACP for gemini, etc.) */ function defaultCheckLlm(mcplocalUrl: string): Promise { return new Promise((resolve) => { let req: http.ClientRequest; try { req = httpDriverFor(mcplocalUrl).get(`${mcplocalUrl}/llm/health`, { timeout: 45000 }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { try { const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string }; if (body.status === 'ok') { resolve('ok'); } else if (body.status === 'not configured') { resolve('not configured'); } else if (body.error) { resolve(body.error.slice(0, 80)); } else { resolve(body.status); } } catch { resolve('invalid response'); } }); }); } catch { resolve('mcplocal unreachable'); return; } req.on('error', () => resolve('mcplocal unreachable')); req.on('timeout', () => { req.destroy(); resolve('timeout'); }); }); } function defaultFetchModels(mcplocalUrl: string): Promise { return new Promise((resolve) => { let req: http.ClientRequest; try { req = httpDriverFor(mcplocalUrl).get(`${mcplocalUrl}/llm/models`, { timeout: 5000 }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { try { const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { models?: string[] }; resolve(body.models ?? []); } catch { resolve([]); } }); }); } catch { resolve([]); return; } req.on('error', () => resolve([])); req.on('timeout', () => { req.destroy(); resolve([]); }); }); } function defaultFetchProviders(mcplocalUrl: string): Promise { return new Promise((resolve) => { let req: http.ClientRequest; try { req = httpDriverFor(mcplocalUrl).get(`${mcplocalUrl}/llm/providers`, { timeout: 5000 }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { try { const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as ProvidersInfo; resolve(body); } catch { resolve(null); } }); }); } catch { resolve(null); return; } req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); }); } const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; const defaultDeps: StatusCommandDeps = { configDeps: {}, credentialsDeps: {}, log: (...args) => console.log(...args), write: (text) => process.stdout.write(text), checkHealth: defaultCheckHealth, checkLlm: defaultCheckLlm, fetchModels: defaultFetchModels, fetchProviders: defaultFetchProviders, isTTY: process.stdout.isTTY ?? false, }; /** Determine LLM label from config (handles both legacy and multi-provider formats). */ function getLlmLabel(llm: unknown): string | null { if (!llm || typeof llm !== 'object') return null; // Legacy format: { provider, model } if ('provider' in llm) { const legacy = llm as { provider: string; model?: string }; if (legacy.provider === 'none') return null; return `${legacy.provider}${legacy.model ? ` / ${legacy.model}` : ''}`; } // Multi-provider format: { providers: [...] } if ('providers' in llm) { const multi = llm as { providers: Array<{ name: string; type: string; tier?: string }> }; if (multi.providers.length === 0) return null; return multi.providers.map((p) => `${p.name}${p.tier ? ` (${p.tier})` : ''}`).join(', '); } return null; } /** Check if config uses multi-provider format. */ function isMultiProvider(llm: unknown): boolean { return !!llm && typeof llm === 'object' && 'providers' in llm; } /** * Format a single provider's status string for display. * Managed providers show lifecycle state; regular providers show health check result. */ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean): string { const detail = info.details?.[name]; if (detail?.managed) { switch (detail.state) { case 'running': return ansi ? `${name} ${GREEN}✓ running${RESET}` : `${name} ✓ running`; case 'stopped': return ansi ? `${name} ${DIM}○ stopped (auto-starts on demand)${RESET}` : `${name} ○ stopped (auto-starts on demand)`; case 'starting': return ansi ? `${name} ${YELLOW}⟳ starting...${RESET}` : `${name} ⟳ starting...`; case 'error': return ansi ? `${name} ${RED}✗ error: ${detail.lastError ?? 'unknown'}${RESET}` : `${name} ✗ error: ${detail.lastError ?? 'unknown'}`; default: { const ok = info.health[name]; return ansi ? ok ? `${name} ${GREEN}✓${RESET}` : `${name} ${RED}✗${RESET}` : ok ? `${name} ✓` : `${name} ✗`; } } } const ok = info.health[name]; return ansi ? ok ? `${name} ${GREEN}✓${RESET}` : `${name} ${RED}✗${RESET}` : ok ? `${name} ✓` : `${name} ✗`; } export function createStatusCommand(deps?: Partial): Command { const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') .option('-o, --output ', 'output format (table, json, yaml)', 'table') .action(async (opts: { output: string }) => { const config = loadConfig(configDeps); const creds = loadCredentials(credentialsDeps); const llmLabel = getLlmLabel(config.llm); const multiProvider = isMultiProvider(config.llm); if (opts.output !== 'table') { // JSON/YAML: run everything in parallel, wait, output at once const [mcplocalReachable, mcpdReachable, llmStatus, providersInfo] = await Promise.all([ checkHealth(config.mcplocalUrl), checkHealth(config.mcpdUrl), llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null), multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null), ]); const llm = llmLabel ? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})` : null; const status = { version: APP_VERSION, mcplocalUrl: config.mcplocalUrl, mcplocalReachable, mcpdUrl: config.mcpdUrl, mcpdReachable, auth: creds ? { user: creds.user } : null, registries: config.registries, outputFormat: config.outputFormat, llm, llmStatus, ...(providersInfo ? { providers: providersInfo } : {}), }; log(opts.output === 'json' ? formatJson(status) : formatYaml(status)); return; } // 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 + models + providers fetch in parallel const llmPromise = checkLlm(config.mcplocalUrl); const modelsPromise = fetchModels(config.mcplocalUrl); const providersPromise = multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null); if (isTTY) { let frame = 0; const interval = setInterval(() => { write(`${CLEAR_LINE}LLM: ${DIM}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} checking...${RESET}`); frame++; }, 80); const [llmStatus, models, providersInfo] = await Promise.all([llmPromise, modelsPromise, providersPromise]); clearInterval(interval); if (providersInfo && (providersInfo.tiers.fast.length > 0 || providersInfo.tiers.heavy.length > 0)) { // Tiered display with per-provider health write(`${CLEAR_LINE}`); for (const tier of ['fast', 'heavy'] as const) { const names = providersInfo.tiers[tier]; if (names.length === 0) continue; const label = tier === 'fast' ? 'LLM (fast): ' : 'LLM (heavy):'; const parts = names.map((n) => formatProviderStatus(n, providersInfo, true)); log(`${label} ${parts.join(', ')}`); } } else { // Legacy single provider display 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`); } } if (models.length > 0) { log(`${DIM} Available: ${models.join(', ')}${RESET}`); } } else { // Non-TTY: no spinner, just wait and print const [llmStatus, models, providersInfo] = await Promise.all([llmPromise, modelsPromise, providersPromise]); if (providersInfo && (providersInfo.tiers.fast.length > 0 || providersInfo.tiers.heavy.length > 0)) { for (const tier of ['fast', 'heavy'] as const) { const names = providersInfo.tiers[tier]; if (names.length === 0) continue; const label = tier === 'fast' ? 'LLM (fast): ' : 'LLM (heavy):'; const parts = names.map((n) => formatProviderStatus(n, providersInfo, false)); log(`${label} ${parts.join(', ')}`); } } else { if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') { log(`LLM: ${llmLabel} ✓ ${llmStatus}`); } else { log(`LLM: ${llmLabel} ✗ ${llmStatus}`); } } if (models.length > 0) { log(`${DIM} Available: ${models.join(', ')}${RESET}`); } } }); }