import * as p from "@clack/prompts"; import type { SpinnerFactory } from "@/cli-io"; import { runDiagnostics } from "@/lib/diagnostics"; import type { FixResult, RemediationDependencies } from "@/lib/diagnostics/remediation"; import { applyFixes as defaultApplyFixes, isFixable } from "@/lib/diagnostics/remediation"; import type { DiagnosticCategory, DiagnosticItem, DiagnosticsReport, } from "@/lib/diagnostics/types"; import { theme } from "@/terminal/theme"; interface DoctorRuntime { runDiagnostics: (context: "doctor") => Promise; applyFixes: ( items: DiagnosticItem[], deps?: Partial ) => Promise; intro: (message: string) => void; note: (message: string, title: string) => void; spinner: SpinnerFactory; log: { error: (message: string) => void; warn: (message: string) => void; success: (message: string) => void; info: (message: string) => void; step: (message: string) => void; }; exit: (code: number) => void; } interface DoctorRuntimeOverrides extends Partial> { log?: Partial; } const CATEGORY_ORDER: readonly DiagnosticCategory[] = [ "environment", "agents", "config", "git", "tmux", ]; const CATEGORY_LABELS: Record = { environment: "Environment", agents: "Coding Agents", config: "Config", git: "Git", tmux: "Tmux", }; // alphabetically ordered list of agents const AGENT_ORDER = ["claude", "codex", "droid", "gemini", "opencode", "pi"] as const; const MAX_FIX_PASSES = 3; function formatSectionTitle(title: string): string { return theme.heading(title); } function getSeverityLabel(severity: DiagnosticItem["severity"]): string { switch (severity) { case "ok": return theme.success("OK"); case "warning": return theme.warn("WARN"); case "error": return theme.error("ERROR"); } } function formatRemediationStep(step: string): string { const commandMatch = /^(Run: |Then run: )(.+)$/.exec(step); if (!commandMatch) { return step; } const prefix = commandMatch[1] ?? ""; const command = commandMatch[2] ?? ""; return `${prefix}${theme.command(command)}`; } function formatDoctorItem(item: DiagnosticItem): string { const fixTag = item.severity !== "ok" && isFixable(item.id) ? ` ${theme.option("[fixable]")}` : ""; const lines = [`${getSeverityLabel(item.severity)} ${item.summary}${fixTag}`]; if (item.details) { lines.push(` ${theme.muted(item.details)}`); } for (const step of item.remediation) { lines.push(` -> ${formatRemediationStep(step)}`); } return lines.join("\n"); } function getFixableItems(report: DiagnosticsReport): DiagnosticItem[] { return report.items.filter((item) => item.severity !== "ok" && isFixable(item.id)); } function toIdSet(items: DiagnosticItem[]): Set { return new Set(items.map((item) => item.id)); } function setEquals(a: Set, b: Set): boolean { if (a.size !== b.size) { return false; } for (const value of a) { if (!b.has(value)) { return false; } } return true; } function collectNextActions(report: DiagnosticsReport, results: FixResult[]): string[] { const actions: string[] = []; const seen = new Set(); function addUnique(steps: string[]): void { for (const step of steps) { if (!seen.has(step)) { seen.add(step); actions.push(step); } } } const unresolvedErrorIds = new Set( report.items.filter((item) => item.severity === "error").map((item) => item.id) ); for (const item of report.items) { if (item.severity === "error") { addUnique(item.remediation); } } for (const result of results) { if (!result.success && result.nextActions && unresolvedErrorIds.has(result.id)) { addUnique(result.nextActions); } } return actions; } function parseAgentBinaryItem(item: DiagnosticItem): { agent: string; installed: boolean } | null { const contextAgent = item.context?.agent; const contextInstalled = item.context?.installed; if (typeof contextAgent !== "string" || typeof contextInstalled !== "boolean") { return null; } return { agent: contextAgent, installed: contextInstalled, }; } function renderAgentsSection(items: DiagnosticItem[], note: DoctorRuntime["note"]): void { const lines: string[] = []; const binaryItems = new Map(); for (const item of items) { const parsed = parseAgentBinaryItem(item); if (!parsed) { continue; } binaryItems.set(parsed.agent, { installed: parsed.installed }); } for (const agent of AGENT_ORDER) { const entry = binaryItems.get(agent); if (!entry) { continue; } if (entry.installed) { lines.push(`${theme.success("OK")} ${agent}`); } else { lines.push(`${theme.muted("MISSING")} ${agent}`); } } const countItem = items.find((item) => item.id === "agents-installed-count"); if (countItem) { if (lines.length > 0) { lines.push(""); } lines.push( countItem.severity === "ok" ? theme.muted(countItem.summary) : `${getSeverityLabel(countItem.severity)} ${countItem.summary}` ); if (countItem.severity === "error") { for (const remediation of countItem.remediation) { lines.push(`-> ${formatRemediationStep(remediation)}`); } } } const supplementalItems = items.filter((item) => { if (item.id === "agents-installed-count") { return false; } return !item.id.match(/^agent-(.+)-binary$/); }); if (supplementalItems.length > 0) { if (lines.length > 0) { lines.push(""); } for (const item of supplementalItems) { lines.push(formatDoctorItem(item)); lines.push(""); } if (lines[lines.length - 1] === "") { lines.pop(); } } note(lines.join("\n"), formatSectionTitle(CATEGORY_LABELS.agents)); } function renderDoctorReport(report: DiagnosticsReport, note: DoctorRuntime["note"]): void { for (const category of CATEGORY_ORDER) { const items = report.items.filter((item) => item.category === category); if (items.length === 0) { continue; } if (category === "agents") { renderAgentsSection(items, note); continue; } const sectionBody = items.map((item) => formatDoctorItem(item)).join("\n\n"); note(sectionBody, formatSectionTitle(CATEGORY_LABELS[category])); } const errors = report.items.filter((item) => item.severity === "error").length; const warnings = report.items.filter((item) => item.severity === "warning").length; if (errors > 0 || warnings > 0) { const summaryLines: string[] = []; if (errors > 0) { summaryLines.push(`${theme.error("ERROR")} Errors: ${errors}`); } if (warnings > 0) { summaryLines.push(`${theme.warn("WARN")} Warnings: ${warnings}`); } note(summaryLines.join("\n"), formatSectionTitle("Summary")); } } function createDoctorRuntime(overrides: DoctorRuntimeOverrides = {}): DoctorRuntime { return { runDiagnostics: overrides.runDiagnostics ?? runDiagnostics, applyFixes: overrides.applyFixes ?? defaultApplyFixes, intro: overrides.intro ?? p.intro, note: overrides.note ?? p.note, spinner: overrides.spinner ?? p.spinner, log: { error: overrides.log?.error ?? p.log.error, warn: overrides.log?.warn ?? p.log.warn, success: overrides.log?.success ?? p.log.success, info: overrides.log?.info ?? p.log.info, step: overrides.log?.step ?? p.log.step, }, exit: overrides.exit ?? process.exit, }; } export async function runDoctor( args: string[] = [], runtimeOverrides: DoctorRuntimeOverrides = {} ): Promise { const runtime = createDoctorRuntime(runtimeOverrides); const fix = args.includes("--fix"); runtime.intro("Ralph Review Doctor"); const spinner = runtime.spinner(); spinner.start("Running diagnostics..."); let report: DiagnosticsReport; try { report = await runtime.runDiagnostics("doctor"); } finally { spinner.stop("Diagnostics complete."); } renderDoctorReport(report, runtime.note); if (fix) { const initialFixableItems = getFixableItems(report); if (initialFixableItems.length > 0) { runtime.log.info( `Found ${initialFixableItems.length} fixable issue${initialFixableItems.length === 1 ? "" : "s"}. Attempting guided remediation...` ); const allFixResults: FixResult[] = []; for (let pass = 1; pass <= MAX_FIX_PASSES; pass++) { const fixableItems = getFixableItems(report); if (fixableItems.length === 0) { break; } runtime.log.step(`Remediation pass ${pass}/${MAX_FIX_PASSES}`); const unresolvedBefore = toIdSet(fixableItems); const results = await runtime.applyFixes(fixableItems); allFixResults.push(...results); for (const result of results) { if (result.success) { runtime.log.success(`Fixed this pass: ${result.message}`); } else { runtime.log.warn(`Could not fix: ${result.message}`); } } const reSpinner = runtime.spinner(); reSpinner.start("Re-running diagnostics..."); try { report = await runtime.runDiagnostics("doctor"); } finally { reSpinner.stop("Diagnostics complete."); } runtime.note( `Post-fix diagnostic results (pass ${pass}/${MAX_FIX_PASSES}):`, formatSectionTitle("Re-diagnosis") ); renderDoctorReport(report, runtime.note); const unresolvedAfter = toIdSet(getFixableItems(report)); if (unresolvedAfter.size === 0) { break; } if (pass === MAX_FIX_PASSES) { runtime.log.info("Reached remediation pass limit."); break; } if (setEquals(unresolvedBefore, unresolvedAfter)) { runtime.log.info( "No remediation progress detected for remaining fixable issues. Stopping auto-fix loop." ); break; } } if (report.hasErrors) { const actions = collectNextActions(report, allFixResults); if (actions.length > 0) { const lines = actions.map( (action, index) => `${index + 1}. ${formatRemediationStep(action)}` ); runtime.note(lines.join("\n"), formatSectionTitle("Next actions")); } } } else { runtime.log.info("No auto-fixable issues found."); } } if (report.hasErrors) { runtime.log.error("Doctor found blocking issues."); runtime.exit(1); return; } if (report.hasWarnings) { runtime.log.warn("Doctor completed with warnings."); return; } runtime.log.success("Doctor completed. Environment is ready."); }