diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index b55c020..ae3433b 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,12 +1,221 @@ import type { Command } from 'commander'; +import type { Config } from '../config/index.js'; +import { getConfigPath, getDataDir, formatStatus } from './shared.js'; +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { resolve, join } from 'path'; +import { parse } from 'yaml'; +import { configSchema } from '../config/schema.js'; + +export interface CheckResult { + status: 'pass' | 'fail' | 'warn' | 'skip'; + label: string; + detail?: string; +} + +export interface DoctorContext { + configPath: string; + dataDir: string; + config?: Config; +} + +type Check = (ctx: DoctorContext) => Promise; + +const checkConfigExists: Check = async (ctx) => { + if (existsSync(ctx.configPath)) { + return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` }; + } + return { status: 'fail', label: 'Config file exists', detail: `not found at ${ctx.configPath}` }; +}; + +const checkConfigParses: Check = async (ctx) => { + if (!existsSync(ctx.configPath)) { + return { status: 'skip', label: 'Config parses', detail: '(no config file)' }; + } + try { + const raw = readFileSync(ctx.configPath, 'utf-8'); + parse(raw); + return { status: 'pass', label: 'Config parses', detail: '(valid YAML)' }; + } catch (err) { + return { status: 'fail', label: 'Config parses', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const checkConfigValidates: Check = async (ctx) => { + if (!existsSync(ctx.configPath)) { + return { status: 'skip', label: 'Config validates', detail: '(no config file)' }; + } + try { + const raw = readFileSync(ctx.configPath, 'utf-8'); + const parsed = parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { status: 'fail', label: 'Config validates', detail: 'YAML did not produce an object' }; + } + const config = configSchema.parse(parsed); + ctx.config = config; + return { status: 'pass', label: 'Config validates', detail: '(schema valid)' }; + } catch (err) { + return { status: 'fail', label: 'Config validates', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const checkEnvVars: Check = async (ctx) => { + if (!existsSync(ctx.configPath)) { + return { status: 'skip', label: 'Env vars resolved', detail: '(no config file)' }; + } + try { + const raw = readFileSync(ctx.configPath, 'utf-8'); + const envVarPattern = /\$\{([^}]+)\}/g; + const unresolved: string[] = []; + let match; + while ((match = envVarPattern.exec(raw)) !== null) { + if (!process.env[match[1]]) { + unresolved.push(match[1]); + } + } + if (unresolved.length > 0) { + return { status: 'fail', label: 'Env vars resolved', detail: `unset: ${unresolved.join(', ')}` }; + } + return { status: 'pass', label: 'Env vars resolved' }; + } catch { + return { status: 'skip', label: 'Env vars resolved', detail: '(could not read config)' }; + } +}; + +const checkDataDir: Check = async (ctx) => { + try { + const { mkdirSync } = await import('fs'); + mkdirSync(ctx.dataDir, { recursive: true }); + + // Test write access + const testFile = join(ctx.dataDir, '.doctor-check'); + writeFileSync(testFile, 'ok'); + unlinkSync(testFile); + return { status: 'pass', label: 'Data directory writable', detail: `(${ctx.dataDir})` }; + } catch { + return { status: 'fail', label: 'Data directory writable', detail: `cannot write to ${ctx.dataDir}` }; + } +}; + +const checkSessionDb: Check = async (ctx) => { + try { + const { mkdirSync } = await import('fs'); + mkdirSync(ctx.dataDir, { recursive: true }); + + const dbPath = resolve(ctx.dataDir, 'sessions.db'); + const { SessionStore } = await import('../session/index.js'); + const store = new SessionStore(dbPath); + store.listSessions(); // Quick query to verify DB works + store.close(); + return { status: 'pass', label: 'Session DB accessible', detail: '(sessions.db)' }; + } catch (err) { + return { status: 'fail', label: 'Session DB accessible', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const checkModelConnectivity: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Model connectivity', detail: '(config invalid)' }; + } + // Skip actual API call in doctor — just verify config looks complete + const model = ctx.config.models.default; + if (!model.model) { + return { status: 'fail', label: 'Model connectivity', detail: 'no default model configured' }; + } + return { status: 'pass', label: 'Model connectivity', detail: `(${model.provider}: ${model.model})` }; +}; + +const checkTelegram: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Telegram bot configured', detail: '(config invalid)' }; + } + if (!ctx.config.telegram.bot_token || ctx.config.telegram.bot_token.length < 10) { + return { status: 'warn', label: 'Telegram bot configured', detail: 'token looks too short' }; + } + return { status: 'pass', label: 'Telegram bot configured', detail: `(${ctx.config.telegram.allowed_chat_ids.length} allowed chat(s))` }; +}; + +const checkMcpServers: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'MCP servers configured', detail: '(config invalid)' }; + } + const servers = ctx.config.mcp.servers; + if (servers.length === 0) { + return { status: 'skip', label: 'MCP servers configured', detail: '(none configured)' }; + } + return { status: 'pass', label: 'MCP servers configured', detail: `(${servers.length} server(s))` }; +}; + +const checkSkills: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Skills loaded', detail: '(config invalid)' }; + } + try { + const { loadAllSkills } = await import('../skills/index.js'); + const skills = loadAllSkills({ + bundledDir: ctx.config.skills.bundled_dir, + managedDir: ctx.config.skills.managed_dir, + workspaceDir: ctx.config.skills.workspace_dir, + }); + return { status: 'pass', label: 'Skills loaded', detail: `(${skills.length} skill(s))` }; + } catch (err) { + return { status: 'fail', label: 'Skills loaded', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const allChecks: Check[] = [ + checkConfigExists, + checkConfigParses, + checkConfigValidates, + checkEnvVars, + checkDataDir, + checkSessionDb, + checkModelConnectivity, + checkTelegram, + checkMcpServers, + checkSkills, +]; + +/** Run all doctor checks in order. Exported for testing. */ +export async function runChecks(ctx: DoctorContext): Promise { + const results: CheckResult[] = []; + for (const check of allChecks) { + const result = await check(ctx); + results.push(result); + } + return results; +} export function registerDoctorCommand(program: Command): void { program .command('doctor') .description('Validate configuration and check system health') .option('-c, --config ', 'Config file path') - .action(async (_opts: { config?: string }) => { - console.error('Not yet implemented'); - process.exit(1); + .action(async (opts: { config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + const dataDir = getDataDir(); + + console.log('Flynn Doctor'); + console.log('============'); + console.log(''); + + const ctx: DoctorContext = { configPath, dataDir }; + const results = await runChecks(ctx); + + for (const result of results) { + console.log(formatStatus(result.status, result.label, result.detail)); + } + + console.log(''); + + const counts = { + pass: results.filter(r => r.status === 'pass').length, + fail: results.filter(r => r.status === 'fail').length, + warn: results.filter(r => r.status === 'warn').length, + skip: results.filter(r => r.status === 'skip').length, + }; + + console.log(`Results: ${counts.pass} passed, ${counts.fail} failed, ${counts.warn} warnings, ${counts.skip} skipped`); + + process.exit(counts.fail > 0 ? 1 : 0); }); }