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 #[arg(long)]
12 pub source_root: Option<PathBuf>,
13 #[arg(long)]
16 pub product: Option<String>,
17 #[arg(long)]
20 pub live_home: Option<PathBuf>,
21 #[arg(long)]
24 pub state_home: Option<PathBuf>,
25 #[arg(long, default_value_t = false)]
28 pub no_overlay: bool,
29 #[arg(long, conflicts_with = "no_overlay")]
32 pub overlay_path: Option<PathBuf>,
33 #[arg(long, default_value = "recommended")]
35 pub profile: String,
36 #[arg(long, default_value_t = false)]
38 pub suggest_upgrade: bool,
39 #[arg(long)]
41 pub check_project: Option<PathBuf>,
42 #[arg(long = "class", value_enum)]
44 pub class: Option<DoctorClassArg>,
45 #[arg(long)]
47 pub pin: Option<PathBuf>,
48 #[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 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}