Skip to main content

agent_runtime/commands/
doctor.rs

1use crate::doctor::{self, DoctorClass, DoctorFinding, DoctorOptions, DoctorSeverity, upgrade};
2use crate::render::manifest::SourceRoot;
3use clap::{Args, ValueEnum};
4use serde::Serialize;
5use std::path::PathBuf;
6
7#[derive(Args, Debug)]
8pub struct DoctorArgs {
9    /// Source root containing `manifests/`, `core/`, `targets/`, `build/`.
10    /// Defaults to the current working directory.
11    #[arg(long)]
12    pub source_root: Option<PathBuf>,
13    /// Product to diagnose (`codex` or `claude`). Required for every class
14    /// except `version-alignment`, which is product-agnostic.
15    #[arg(long)]
16    pub product: Option<String>,
17    /// Absolute path of the runtime home to inspect. Defaults to the
18    /// product's `live_home` from `manifests/runtime-roots.yaml`.
19    #[arg(long)]
20    pub live_home: Option<PathBuf>,
21    /// Absolute path of the state home to inspect. Defaults to the
22    /// product's `state_home` from `manifests/runtime-roots.yaml`.
23    #[arg(long)]
24    pub state_home: Option<PathBuf>,
25    /// Skip the optional `.private/link-map.overrides.yaml` overlay
26    /// merge. Default: merge if file exists, matching install/uninstall.
27    #[arg(long, default_value_t = false)]
28    pub no_overlay: bool,
29    /// Override the overlay file location. When set, the conventional
30    /// `<source-root>/.private/link-map.overrides.yaml` is ignored.
31    #[arg(long, conflicts_with = "no_overlay")]
32    pub overlay_path: Option<PathBuf>,
33    /// Active cli-tools profile to check.
34    #[arg(long, default_value = "recommended")]
35    pub profile: String,
36    /// Print copy-pasteable Homebrew upgrade commands for non-ok probes.
37    #[arg(long, default_value_t = false)]
38    pub suggest_upgrade: bool,
39    /// Inspect a consuming repo's `.agents/scripts/` project-local overlays.
40    #[arg(long)]
41    pub check_project: Option<PathBuf>,
42    /// Run a single doctor class instead of the default host/runtime probes.
43    #[arg(long = "class", value_enum)]
44    pub class: Option<DoctorClassArg>,
45    /// Pin manifest (`<pin-spec>`, YAML or JSON) for `--class version-alignment`.
46    #[arg(long)]
47    pub pin: Option<PathBuf>,
48    /// Output format.
49    #[arg(long, value_enum, default_value = "text")]
50    pub format: OutputFormat,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
54#[clap(rename_all = "kebab-case")]
55pub enum DoctorClassArg {
56    SkillSurface,
57    VersionAlignment,
58}
59
60impl From<DoctorClassArg> for DoctorClass {
61    fn from(value: DoctorClassArg) -> Self {
62        match value {
63            DoctorClassArg::SkillSurface => DoctorClass::SkillSurface,
64            DoctorClassArg::VersionAlignment => DoctorClass::VersionAlignment,
65        }
66    }
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
70#[clap(rename_all = "lower")]
71pub enum OutputFormat {
72    Text,
73    Json,
74}
75
76pub fn run(args: DoctorArgs) -> anyhow::Result<u8> {
77    if let Some(path) = args.live_home.as_deref()
78        && !path.is_absolute()
79    {
80        anyhow::bail!(
81            "agent-runtime doctor: --live-home must be absolute (got: {}); pass an absolute path such as /tmp/claude-sandbox or $HOME/.claude",
82            path.display()
83        );
84    }
85    if let Some(path) = args.state_home.as_deref()
86        && !path.is_absolute()
87    {
88        anyhow::bail!(
89            "agent-runtime doctor: --state-home must be absolute (got: {})",
90            path.display()
91        );
92    }
93
94    let class: Option<DoctorClass> = args.class.map(Into::into);
95
96    // `version-alignment` is product-agnostic; every other class requires a
97    // product. Resolve a placeholder for the former so the shared entrypoint
98    // signature is unchanged.
99    let product = match class {
100        Some(DoctorClass::VersionAlignment) => {
101            args.product.clone().unwrap_or_else(|| "host".to_string())
102        }
103        _ => match args.product.clone() {
104            Some(product) => product,
105            None => anyhow::bail!(
106                "agent-runtime doctor: --product <codex|claude> is required unless --class version-alignment"
107            ),
108        },
109    };
110
111    let root = SourceRoot::from_arg_or_cwd(args.source_root.as_deref())?;
112    let options = DoctorOptions {
113        overlay_enabled: !args.no_overlay,
114        overlay_path: args.overlay_path.clone(),
115        cli_tools_profile: args.profile.clone(),
116        check_project: args.check_project.clone(),
117        class_filter: class,
118        pin_path: args.pin.clone(),
119    };
120    let outcome = doctor::run(
121        &product,
122        root.path(),
123        args.live_home.as_deref(),
124        args.state_home.as_deref(),
125        &options,
126    )?;
127
128    if args.format == OutputFormat::Json {
129        print_json(&outcome, args.suggest_upgrade)?;
130        return Ok(outcome.exit_code());
131    }
132
133    print_text(&outcome, args.suggest_upgrade);
134    Ok(outcome.exit_code())
135}
136
137fn print_text(outcome: &doctor::DoctorOutcome, suggest_upgrade: bool) {
138    if let Some(summary) = outcome.overlay.as_ref() {
139        eprintln!(
140            "agent-runtime doctor: overlay merged (dropped={} replaced={} added={})",
141            summary.dropped, summary.replaced, summary.added,
142        );
143    }
144
145    eprintln!(
146        "agent-runtime doctor: product={} checks={} ok={} warn={} block={}",
147        outcome.product,
148        outcome.total_checks(),
149        outcome.ok,
150        outcome.warn,
151        outcome.block,
152    );
153    for probe in &outcome.version_probes {
154        let parsed = probe.parsed_version.as_deref().unwrap_or("unparseable");
155        eprintln!(
156            "  {} version-probe status={} parsed={} command=`{}`",
157            match probe.severity {
158                DoctorSeverity::Ok => "ok",
159                DoctorSeverity::Warn => "warn",
160                DoctorSeverity::Block => "block",
161            },
162            probe.status.as_str(),
163            parsed,
164            probe.command,
165        );
166    }
167    for probe in &outcome.coverage_probes {
168        let parsed = probe.parsed_version.as_deref().unwrap_or("unknown");
169        let required = probe.required_version.as_deref().unwrap_or("n/a");
170        eprintln!(
171            "  {} {} status={} name={} command=`{}` required={} parsed={}",
172            match probe.severity {
173                DoctorSeverity::Ok => "ok",
174                DoctorSeverity::Warn => "warn",
175                DoctorSeverity::Block => "block",
176            },
177            probe.kind.check(),
178            probe.status.as_str(),
179            probe.name,
180            probe.command,
181            required,
182            parsed,
183        );
184    }
185    for probe in &outcome.project_probes {
186        eprintln!(
187            "  {} project-overlay status={} script={} path={}",
188            match probe.severity {
189                DoctorSeverity::Ok => "ok",
190                DoctorSeverity::Warn => "warn",
191                DoctorSeverity::Block => "block",
192            },
193            probe.status.as_str(),
194            probe.script,
195            probe.path.display(),
196        );
197    }
198    if let Some(report) = outcome.version_alignment.as_ref() {
199        for item in &report.items {
200            eprintln!(
201                "  {} {} target={} expected={} observed={}",
202                match item.severity {
203                    DoctorSeverity::Ok => "ok",
204                    DoctorSeverity::Warn => "warn",
205                    DoctorSeverity::Block => "block",
206                },
207                item.check,
208                item.target,
209                item.expected,
210                item.observed.as_deref().unwrap_or("none"),
211            );
212        }
213    }
214    for finding in &outcome.findings {
215        print_finding(finding);
216    }
217    if suggest_upgrade {
218        for suggestion in upgrade::suggestions(outcome) {
219            println!("{}", suggestion.command);
220        }
221    }
222    if let Some(boundary) = outcome.acceptance_boundary.as_deref() {
223        eprintln!("agent-runtime doctor: acceptance-boundary: {boundary}");
224    }
225}
226
227#[derive(Serialize)]
228struct DoctorJson<'a> {
229    schema_version: &'static str,
230    product: &'a str,
231    checks: usize,
232    ok: usize,
233    warn: usize,
234    block: usize,
235    exit_code: u8,
236    findings: &'a [DoctorFinding],
237    #[serde(skip_serializing_if = "Option::is_none")]
238    skill_surface: Option<&'a doctor::skill_surface::SkillSurfaceReport>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    version_alignment: Option<&'a doctor::version_alignment::VersionAlignmentReport>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    acceptance_boundary: Option<&'a str>,
243    #[serde(skip_serializing_if = "Vec::is_empty")]
244    upgrade_suggestions: Vec<String>,
245}
246
247fn print_json(outcome: &doctor::DoctorOutcome, suggest_upgrade: bool) -> anyhow::Result<()> {
248    let upgrade_suggestions = if suggest_upgrade {
249        upgrade::suggestions(outcome)
250            .into_iter()
251            .map(|suggestion| suggestion.command)
252            .collect()
253    } else {
254        Vec::new()
255    };
256    let envelope = DoctorJson {
257        schema_version: "agent-runtime-cli.doctor.v1",
258        product: &outcome.product,
259        checks: outcome.total_checks(),
260        ok: outcome.ok,
261        warn: outcome.warn,
262        block: outcome.block,
263        exit_code: outcome.exit_code(),
264        findings: &outcome.findings,
265        skill_surface: outcome.skill_surface.as_ref(),
266        version_alignment: outcome.version_alignment.as_ref(),
267        acceptance_boundary: outcome.acceptance_boundary.as_deref(),
268        upgrade_suggestions,
269    };
270    println!("{}", serde_json::to_string_pretty(&envelope)?);
271    Ok(())
272}
273
274fn print_finding(finding: &DoctorFinding) {
275    let severity = match finding.severity {
276        DoctorSeverity::Ok => "ok",
277        DoctorSeverity::Warn => "warn",
278        DoctorSeverity::Block => "block",
279    };
280    let path = finding
281        .path
282        .as_ref()
283        .map(|p| format!(" {}", p.display()))
284        .unwrap_or_default();
285    let entry = finding
286        .entry_id
287        .as_ref()
288        .map(|id| format!(" ({id})"))
289        .unwrap_or_default();
290    eprintln!(
291        "  {severity} {}{}{}: {}",
292        finding.check, entry, path, finding.message,
293    );
294}