1use std::collections::BTreeMap;
4use std::io::{self, IsTerminal, Write};
5use std::path::Path;
6use std::process::{Command, Stdio};
7
8use crate::context;
9use serde_json::to_string_pretty;
10
11use crate::acp::config::provider_label;
12use crate::cli::Cli;
13use crate::mcp;
14use crate::session;
15use crate::thndrs_core::auth;
16use crate::thndrs_core::diagnostics::{
17 DoctorAcpStatus, DoctorConfigFile, DoctorCredential, DoctorMcpStatus, DoctorReport, DoctorSession,
18 DoctorTerminalSummary, DoctorToolAvailability,
19};
20use crate::tools::shell::redact_secrets;
21use clap::Args;
22
23const DOCTOR_DOCS_URL: &str = "https://thndrs.stormlightlabs.org/docs/";
24
25#[derive(Clone, Debug, Eq, PartialEq, Args)]
27pub struct DoctorCommand {
28 #[arg(long)]
30 pub json: bool,
31}
32
33pub fn run(cli: &Cli, command: &DoctorCommand) -> io::Result<()> {
35 let stdout = io::stdout();
36 let mut lock = stdout.lock();
37 run_with_writer(cli, command, &mut lock)
38}
39
40pub fn run_with_writer<W: Write>(cli: &Cli, command: &DoctorCommand, writer: &mut W) -> io::Result<()> {
42 let report = gather_doctor_report(cli);
43 if command.json {
44 writeln!(writer, "{}", to_string_pretty(&report).map_err(io::Error::other)?)?;
45 } else {
46 render_human_report(&report, writer)?;
47 }
48
49 if !report.blocking_issues.is_empty() {
50 return Err(io::Error::new(
51 io::ErrorKind::PermissionDenied,
52 report
53 .setup_hint
54 .clone()
55 .unwrap_or_else(|| String::from("setup required for selected provider")),
56 ));
57 }
58
59 if report.mcp.failed > 0 {
60 return Err(io::Error::new(
61 io::ErrorKind::InvalidInput,
62 "MCP configuration is invalid; check diagnostics",
63 ));
64 }
65
66 Ok(())
67}
68
69fn gather_doctor_report(cli: &Cli) -> DoctorReport {
70 let workspace = context::discover_workspace_root(&cli.cwd);
71 let model = cli.model.clone();
72 let provider = (!model.trim().is_empty()).then(|| provider_label(&model).to_string());
73 let config_diagnostics = cli.config_diagnostics.iter().map(|line| redact_secrets(line)).collect();
74
75 let config_files = cli
76 .config_layers
77 .iter()
78 .map(|layer| DoctorConfigFile {
79 source: layer.source.as_str().to_string(),
80 path: layer.display_path.clone().unwrap_or_else(|| String::from("<none>")),
81 sha256: layer.hash.clone(),
82 })
83 .collect();
84
85 let credentials = collect_credential_statuses(&workspace);
86 let tools = DoctorToolAvailability { rg: executable_available("rg"), fd: executable_available("fd") };
87 let session_path = cli
88 .session_dir
89 .clone()
90 .unwrap_or_else(|| session::sessions_dir(&workspace));
91 let (writable, reason) = check_session_dir_writable(&session_path);
92 let mcp = collect_mcp_status(&workspace);
93 let acp = collect_acp_status(&cli.acp_agents);
94 let terminal = collect_terminal_summary();
95
96 let mut blocking_issues = Vec::new();
97 let mut setup_hint = None;
98
99 match provider.as_deref() {
100 None => {
101 blocking_issues.push(String::from("no model provider selected"));
102 setup_hint = Some(String::from("run `thndrs setup` to choose a provider and model"));
103 }
104 Some(provider) => {
105 if let Some(env_var) = required_credential_env_for_provider(provider)
106 && auth::credential_source(env_var, &workspace).is_none()
107 {
108 let hint = format!("run `thndrs setup --provider {provider}` or `thndrs login {provider}`");
109 blocking_issues.push(format!("missing credential for model provider {provider}: {env_var}"));
110 setup_hint = Some(hint);
111 }
112 }
113 }
114
115 DoctorReport {
116 app_version: env!("CARGO_PKG_VERSION").to_string(),
117 workspace: workspace.display().to_string(),
118 model,
119 provider,
120 config_files,
121 config_diagnostics,
122 credentials,
123 tools,
124 session: DoctorSession { path: session_path.display().to_string(), writable, reason },
125 mcp,
126 acp,
127 terminal,
128 blocking_issues,
129 docs_url: DOCTOR_DOCS_URL.to_string(),
130 setup_hint,
131 }
132}
133
134fn collect_credential_statuses(workspace: &Path) -> Vec<DoctorCredential> {
135 [
136 ("umans", auth::UMANS_API_KEY_ENV),
137 ("opencode-go", auth::OPENCODE_GO_KEY_ENV),
138 ("opencode-zen", auth::OPENCODE_ZEN_KEY_ENV),
139 ]
140 .into_iter()
141 .map(|(provider, env_var)| DoctorCredential {
142 provider: provider.to_string(),
143 source: auth::credential_source(env_var, workspace)
144 .map(|source| source.label().to_string())
145 .unwrap_or_else(|| String::from("missing")),
146 })
147 .collect()
148}
149
150fn required_credential_env_for_provider(provider: &str) -> Option<&'static str> {
151 match provider {
152 "umans" => Some(auth::UMANS_API_KEY_ENV),
153 "opencode-go" => Some(auth::OPENCODE_GO_KEY_ENV),
154 "opencode-zen" => Some(auth::OPENCODE_ZEN_KEY_ENV),
155 _ => None,
156 }
157}
158
159fn collect_mcp_status(workspace: &Path) -> DoctorMcpStatus {
160 let env_vars: Vec<(String, String)> = std::env::vars().collect();
161 match mcp::config::load_effective_mcp(workspace, &env_vars) {
162 Ok(effective) => {
163 let skipped = count_mcp_skipped(&effective.diagnostics);
164 let configured = effective.config.servers.len() + skipped;
165 let ready = effective
166 .config
167 .servers
168 .values()
169 .filter(|server| server.enabled)
170 .count();
171 DoctorMcpStatus {
172 configured,
173 ready,
174 skipped,
175 failed: 0,
176 diagnostics: effective
177 .diagnostics
178 .into_iter()
179 .map(|diagnostic| redact_secrets(&diagnostic))
180 .collect(),
181 }
182 }
183 Err(err) => {
184 let diagnostic = redact_secrets(&format!("failed to load MCP config: {err}"));
185 DoctorMcpStatus { configured: 0, ready: 0, skipped: 0, failed: 1, diagnostics: vec![diagnostic] }
186 }
187 }
188}
189
190fn collect_acp_status(acp_agents: &BTreeMap<String, crate::config::AcpAgentConfig>) -> DoctorAcpStatus {
191 let configured = acp_agents.len();
192 let enabled = acp_agents.values().filter(|agent| agent.enabled).count();
193 DoctorAcpStatus { configured, enabled, disabled: configured.saturating_sub(enabled) }
194}
195
196fn collect_terminal_summary() -> DoctorTerminalSummary {
197 let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
198 let term_env = std::env::var("TERM").ok().filter(|value| !value.trim().is_empty());
199 let no_color = std::env::var_os("NO_COLOR").is_some();
200
201 DoctorTerminalSummary { tty: io::stdout().is_terminal(), width, height, term_env, no_color }
202}
203
204fn executable_available(name: &str) -> bool {
205 Command::new(name)
206 .arg("--version")
207 .stdout(Stdio::null())
208 .stderr(Stdio::null())
209 .status()
210 .is_ok()
211}
212
213fn check_session_dir_writable(path: &Path) -> (bool, Option<String>) {
214 if path.exists() {
215 if !path.is_dir() {
216 return (false, Some(format!("{} is not a directory", path.display())));
217 }
218 return match check_directory_file_writable(path) {
219 Ok(()) => (true, None),
220 Err(error) => (false, Some(format!("session directory is not writable: {error}"))),
221 };
222 }
223
224 let parent = match path.parent() {
225 Some(parent) => parent,
226 None => return (false, Some(format!("{path:?} is not a valid path"))),
227 };
228
229 if !parent.exists() {
230 return (
231 false,
232 Some(format!("parent directory does not exist: {}", parent.display())),
233 );
234 }
235
236 match check_directory_file_writable(parent) {
237 Ok(()) => {
238 let warning = String::from("session directory does not exist and will be created on first write");
239 (true, Some(warning))
240 }
241 Err(error) => (
242 false,
243 Some(format!("session directory parent is not writable: {error}")),
244 ),
245 }
246}
247
248fn check_directory_file_writable(dir: &Path) -> io::Result<()> {
249 let marker = dir.join(format!(".thndrs-doctor-write-check-{}", std::process::id()));
250 std::fs::File::create_new(&marker)?;
251 std::fs::remove_file(&marker).map_err(io::Error::other)
252}
253
254fn count_mcp_skipped(diagnostics: &[String]) -> usize {
255 diagnostics
256 .iter()
257 .filter(|message| {
258 message.starts_with("mcp server `") && message.contains("` skipped: unresolved environment variable ")
259 })
260 .count()
261}
262
263fn render_human_report<W: Write>(report: &DoctorReport, writer: &mut W) -> io::Result<()> {
264 writeln!(writer, "thndrs doctor")?;
265 writeln!(writer, "app_version: {}", report.app_version)?;
266 writeln!(writer, "workspace: {}", report.workspace)?;
267 writeln!(writer, "model: {}", report.model)?;
268 writeln!(writer, "provider: {}", report.provider.as_deref().unwrap_or("<none>"))?;
269
270 writeln!(writer, "config_files:")?;
271 if report.config_files.is_empty() {
272 writeln!(writer, " <none>")?;
273 } else {
274 for file in &report.config_files {
275 if let Some(hash) = &file.sha256 {
276 writeln!(writer, " {}: {} ({hash})", file.source, file.path)?;
277 } else {
278 writeln!(writer, " {}: {}", file.source, file.path)?;
279 }
280 }
281 }
282
283 writeln!(writer, "config_diagnostics:")?;
284 if report.config_diagnostics.is_empty() {
285 writeln!(writer, " <none>")?;
286 } else {
287 for diagnostic in &report.config_diagnostics {
288 writeln!(writer, " - {diagnostic}")?;
289 }
290 }
291
292 writeln!(writer, "credentials:")?;
293 for credential in &report.credentials {
294 writeln!(writer, " {}: {}", credential.provider, credential.source)?;
295 }
296
297 writeln!(writer, "tools:")?;
298 writeln!(
299 writer,
300 " rg: {} (required for search_text)",
301 bool_to_status(report.tools.rg)
302 )?;
303 writeln!(
304 writer,
305 " fd: {} (optional; file discovery falls back to find)",
306 bool_to_status(report.tools.fd)
307 )?;
308
309 writeln!(
310 writer,
311 "session_dir: {} [{}]",
312 report.session.path,
313 if report.session.writable { "writable" } else { "read-only" }
314 )?;
315 if let Some(reason) = &report.session.reason {
316 writeln!(writer, "session_dir_detail: {reason}")?;
317 }
318
319 writeln!(
320 writer,
321 "mcp: configured={} ready={} skipped={} failed={}",
322 report.mcp.configured, report.mcp.ready, report.mcp.skipped, report.mcp.failed
323 )?;
324 for diagnostic in &report.mcp.diagnostics {
325 writeln!(writer, " - mcp: {diagnostic}")?;
326 }
327
328 writeln!(
329 writer,
330 "acp: configured={} enabled={} disabled={}",
331 report.acp.configured, report.acp.enabled, report.acp.disabled
332 )?;
333
334 writeln!(
335 writer,
336 "terminal: tty={} size={}x{} no_color={}",
337 bool_to_status(report.terminal.tty),
338 report.terminal.width,
339 report.terminal.height,
340 report.terminal.no_color
341 )?;
342 if let Some(term_env) = &report.terminal.term_env {
343 writeln!(writer, "terminal.term: {term_env}")?;
344 }
345
346 writeln!(writer, "docs: {}", report.docs_url)?;
347 if !report.blocking_issues.is_empty() {
348 for issue in &report.blocking_issues {
349 writeln!(writer, "issue: {issue}")?;
350 }
351 }
352 if let Some(setup_hint) = &report.setup_hint {
353 writeln!(writer, "setup: {setup_hint}")?;
354 }
355
356 Ok(())
357}
358
359fn bool_to_status(value: bool) -> &'static str {
360 if value { "available" } else { "missing" }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::cli::Cli;
367 use crate::cli::Command;
368 use crate::config;
369 use std::ffi::OsString;
370 use std::io::Cursor;
371
372 fn with_env_vars<R>(vars: &[(&str, Option<&str>)], action: impl FnOnce() -> R) -> R {
373 let _guard = crate::test_env::lock();
374
375 let previous: Vec<(String, Option<OsString>)> = vars
376 .iter()
377 .map(|&(key, _)| (key.to_string(), std::env::var_os(key)))
378 .collect();
379
380 for (key, _) in &previous {
381 unsafe {
382 std::env::remove_var(key);
383 }
384 }
385 for (key, value) in vars {
386 match value {
387 Some(value) => unsafe {
388 std::env::set_var(key, value);
389 },
390 None => unsafe {
391 std::env::remove_var(key);
392 },
393 }
394 }
395
396 let result = action();
397
398 for (key, value) in previous {
399 match value {
400 Some(value) => unsafe {
401 std::env::set_var(key, value);
402 },
403 None => unsafe {
404 std::env::remove_var(key);
405 },
406 }
407 }
408
409 result
410 }
411
412 fn run_with_output(args: &[&str]) -> (String, Option<io::ErrorKind>) {
413 let (cli, matches) = Cli::try_parse_matches_from(args).expect("parse doctor");
414 let env_vars = std::env::vars().collect::<Vec<_>>();
415 let workspace = cli.cwd.clone();
416 let cli = cli.with_effective(
417 config::load_effective(&workspace, &env_vars).expect("load config"),
418 &matches,
419 );
420 let mut output = Cursor::new(Vec::new());
421 let command = match &cli.command {
422 Some(Command::Doctor(command)) => command,
423 _ => panic!("expected doctor command"),
424 };
425
426 let error = run_with_writer(&cli, command, &mut output).err().map(|err| err.kind());
427 (
428 String::from_utf8(output.into_inner()).expect("output is not UTF-8"),
429 error,
430 )
431 }
432
433 fn workspace_with_doctor_config() -> tempfile::TempDir {
434 let tmp = tempfile::tempdir().expect("temp workspace");
435 let workspace = tmp.path();
436 std::fs::create_dir_all(workspace.join(".thndrs")).expect("create .thndrs");
437 std::fs::create_dir_all(workspace.join(".thndrs/sessions")).expect("create sessions");
438
439 let config_toml = r#"
440 [acp_agents.local]
441 command = "agent"
442 args = ["--stdio"]
443 enabled = true
444 "#;
445 let mcp_toml = r#"
446 [servers.test]
447 command = "mcp-server"
448 args = ["--stdio"]
449 enabled = true
450 "#;
451
452 std::fs::write(workspace.join(".thndrs/config.toml"), config_toml).expect("write config");
453 std::fs::write(workspace.join(".thndrs/mcp.toml"), mcp_toml).expect("write mcp config");
454
455 tmp
456 }
457
458 #[test]
459 fn doctor_reports_json_and_does_not_leak_secrets() {
460 let workspace = workspace_with_doctor_config();
461 let workspace_path = workspace.path();
462 let workspace_path = workspace_path.to_str().expect("workspace path");
463
464 let (output, error_kind) = with_env_vars(
465 &[
466 ("HOME", Some(workspace_path)),
467 ("UMANS_API_KEY", Some("sk-very-secret-umans")),
468 ],
469 || {
470 run_with_output(&[
471 "thndrs",
472 "--cwd",
473 workspace_path,
474 "--model",
475 "umans-coder",
476 "doctor",
477 "--json",
478 ])
479 },
480 );
481
482 assert_eq!(error_kind, None);
483 assert!(!output.contains("sk-very-secret-umans"));
484 let report: DoctorReport = serde_json::from_str(&output).expect("valid JSON");
485 assert_eq!(report.model, "umans-coder");
486 assert_eq!(report.provider.as_deref(), Some("umans"));
487 assert_eq!(report.credentials[0].provider, "umans");
488 assert!(matches!(
489 report.credentials[0].source.as_str(),
490 "environment" | "project credentials" | "global credentials"
491 ));
492 assert!(!report.config_files.is_empty());
493 }
494
495 #[test]
496 fn doctor_human_output_reports_incomplete_setup_without_assuming_umans() {
497 let workspace = workspace_with_doctor_config();
498 let workspace_path = workspace.path().to_str().expect("workspace path");
499
500 let (output, error_kind) = with_env_vars(
501 &[
502 ("HOME", Some(workspace_path)),
503 ("NO_COLOR", None),
504 ("TERM", Some("xterm")),
505 ],
506 || run_with_output(&["thndrs", "--cwd", workspace_path, "doctor"]),
507 );
508
509 assert!(output.contains("thndrs doctor"));
510 assert!(output.contains("model: \nprovider: <none>"));
511 assert!(output.contains("issue: no model provider selected"));
512 assert!(output.contains("setup: run `thndrs setup` to choose a provider and model"));
513 assert!(!output.contains("missing credential for model provider umans"));
514 assert!(!output.contains("UMANS_API_KEY"));
515 assert_eq!(error_kind, Some(io::ErrorKind::PermissionDenied));
516 }
517
518 #[test]
519 fn doctor_json_reports_no_provider_for_incomplete_setup() {
520 let workspace = workspace_with_doctor_config();
521 let workspace_path = workspace.path().to_str().expect("workspace path");
522
523 let (output, error_kind) = with_env_vars(&[("HOME", Some(workspace_path))], || {
524 run_with_output(&["thndrs", "--cwd", workspace_path, "doctor", "--json"])
525 });
526
527 let report: DoctorReport = serde_json::from_str(&output).expect("doctor json");
528 assert_eq!(report.model, "");
529 assert_eq!(report.provider, None);
530 assert_eq!(report.blocking_issues, vec!["no model provider selected"]);
531 assert_eq!(
532 report.setup_hint.as_deref(),
533 Some("run `thndrs setup` to choose a provider and model")
534 );
535 assert!(!output.contains("UMANS_API_KEY"));
536 assert_eq!(error_kind, Some(io::ErrorKind::PermissionDenied));
537 }
538
539 #[test]
540 fn doctor_keeps_provider_specific_diagnostics_after_model_selection() {
541 let workspace = workspace_with_doctor_config();
542 let workspace_path = workspace.path().to_str().expect("workspace path");
543
544 let (output, error_kind) = with_env_vars(&[("HOME", Some(workspace_path)), ("UMANS_API_KEY", None)], || {
545 run_with_output(&[
546 "thndrs",
547 "--cwd",
548 workspace_path,
549 "--model",
550 "umans-coder",
551 "doctor",
552 "--json",
553 ])
554 });
555
556 let report: DoctorReport = serde_json::from_str(&output).expect("doctor json");
557 assert_eq!(report.provider.as_deref(), Some("umans"));
558 assert_eq!(
559 report.blocking_issues,
560 vec!["missing credential for model provider umans: UMANS_API_KEY"]
561 );
562 assert_eq!(
563 report.setup_hint.as_deref(),
564 Some("run `thndrs setup --provider umans` or `thndrs login umans`")
565 );
566 assert_eq!(error_kind, Some(io::ErrorKind::PermissionDenied));
567 }
568
569 #[test]
570 fn doctor_json_does_not_include_secret_credentials() {
571 let workspace = workspace_with_doctor_config();
572 let workspace_path = workspace.path().to_str().expect("workspace path");
573
574 let (output, error_kind) = with_env_vars(
575 &[
576 ("HOME", Some(workspace_path)),
577 ("UMANS_API_KEY", Some("sk-secret-umans")),
578 ("OPENCODE_GO_KEY", Some("op-secret")),
579 ("OPENCODE_ZEN_KEY", Some("zen-secret")),
580 ],
581 || {
582 run_with_output(&[
583 "thndrs",
584 "--cwd",
585 workspace_path,
586 "--model",
587 "umans-coder",
588 "doctor",
589 "--json",
590 ])
591 },
592 );
593
594 assert_eq!(error_kind, None);
595 assert!(!output.contains("sk-secret-umans"));
596 assert!(!output.contains("op-secret"));
597 assert!(!output.contains("zen-secret"));
598 let report: DoctorReport = serde_json::from_str(&output).expect("doctor json");
599 assert!(report.credentials.iter().all(|cred| cred.source != "environment"
600 || cred.provider == "umans"
601 || cred.provider == "opencode-go"
602 || cred.provider == "opencode-zen"));
603 assert!(
604 !report
605 .config_diagnostics
606 .iter()
607 .any(|item| item.contains("sk-secret-umans")
608 || item.contains("op-secret")
609 || item.contains("zen-secret"))
610 );
611 }
612
613 #[test]
614 fn doctor_reports_path_and_counts_for_acp_and_mcp() {
615 let workspace = workspace_with_doctor_config();
616 let workspace_path = workspace.path().to_str().expect("workspace path");
617
618 let (output, error_kind) = with_env_vars(
619 &[
620 ("HOME", Some(workspace_path)),
621 ("UMANS_API_KEY", Some("sk-umans")),
622 ("OPENCODE_GO_KEY", Some("op-secret")),
623 ("OPENCODE_ZEN_KEY", Some("zen-secret")),
624 ],
625 || {
626 run_with_output(&[
627 "thndrs",
628 "--cwd",
629 workspace_path,
630 "--model",
631 "acp:local",
632 "doctor",
633 "--json",
634 ])
635 },
636 );
637 assert_eq!(error_kind, None);
638 let report: DoctorReport = serde_json::from_str(&output).expect("parse report");
639 assert_eq!(report.provider.as_deref(), Some("acp"));
640 assert!(
641 report.session.path.ends_with(".thndrs/sessions"),
642 "unexpected session path: {}",
643 report.session.path
644 );
645 assert_eq!(report.acp.configured, 1);
646 assert!(report.acp.enabled >= 1);
647 assert!(report.mcp.configured >= 1);
648 }
649
650 #[test]
651 fn doctor_reports_mcp_invalid_config_as_cli_error() {
652 let workspace = tempfile::tempdir().expect("temp workspace");
653 let workspace_path = workspace.path().to_str().expect("workspace path");
654 std::fs::create_dir_all(workspace.path().join(".thndrs")).expect("create .thndrs");
655 std::fs::create_dir_all(workspace.path().join(".thndrs").join("sessions")).expect("create sessions");
656 let invalid_mcp = r#"
657 [servers.bad]
658 transport = "stdio"
659 command = ""
660 enabled = true
661 "#;
662 std::fs::write(workspace.path().join(".thndrs/mcp.toml"), invalid_mcp).expect("write invalid mcp");
663
664 let (_, error_kind) = with_env_vars(
665 &[("HOME", Some(workspace_path)), ("UMANS_API_KEY", Some("sk-umans"))],
666 || {
667 run_with_output(&[
668 "thndrs",
669 "--cwd",
670 workspace_path,
671 "--model",
672 "umans-coder",
673 "doctor",
674 "--json",
675 ])
676 },
677 );
678
679 assert_eq!(error_kind, Some(io::ErrorKind::InvalidInput));
680 }
681
682 #[test]
683 fn check_mcp_skipped_count_parses_known_messages() {
684 assert_eq!(
685 count_mcp_skipped(&[String::from(
686 "mcp server `docs` skipped: unresolved environment variable DOCS_TOKEN"
687 )]),
688 1
689 );
690 assert_eq!(count_mcp_skipped(&[String::from("failed to load MCP config: bad")]), 0);
691 }
692}