Skip to main content

thndrs_lib/cli/commands/
doctor.rs

1//! `thndrs doctor` command definitions.
2
3use 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/// Options for `thndrs doctor`.
26#[derive(Clone, Debug, Eq, PartialEq, Args)]
27pub struct DoctorCommand {
28    /// Emit machine-readable JSON diagnostics.
29    #[arg(long)]
30    pub json: bool,
31}
32
33/// Run setup diagnostics.
34pub 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
40/// Run `thndrs doctor` with an injected writer.
41pub 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}