Skip to main content

kaizen/shell/
doctor.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen doctor` — workspace health (config, DB, hooks).
3
4use crate::core::config;
5use crate::shell::cli::workspace_path;
6use crate::shell::init;
7use crate::store::Store;
8use anyhow::Result;
9use std::fmt::Write;
10use std::io::IsTerminal;
11use std::path::Path;
12
13/// Runs checks; returns exit code (0 = ok, 1 = hard failure) and text for stdout.
14pub fn doctor_text(workspace: Option<&Path>) -> Result<(i32, String)> {
15    let ws = workspace_path(workspace)?;
16    let mut hard_fail = false;
17    let mut out = String::new();
18
19    writeln!(&mut out, "kaizen {} (doctor)", env!("CARGO_PKG_VERSION")).unwrap();
20    writeln!(&mut out, "workspace: {}", ws.display()).unwrap();
21    writeln!(&mut out).unwrap();
22
23    let wcfg = ws.join(".kaizen/config.toml");
24    let wcfg_ex = wcfg.exists();
25    writeln!(
26        &mut out,
27        ".kaizen/config.toml: {}",
28        if wcfg_ex { "present" } else { "absent" }
29    )
30    .unwrap();
31    if let Ok(home) = std::env::var("HOME") {
32        let p = Path::new(&home).join(".kaizen/config.toml");
33        writeln!(
34            &mut out,
35            "~/.kaizen/config.toml: {}",
36            if p.exists() { "present" } else { "absent" }
37        )
38        .unwrap();
39    } else {
40        writeln!(&mut out, "~/.kaizen/config.toml: (HOME unset, skipped)").unwrap();
41    }
42    match crate::core::machine_registry::status() {
43        Ok(None) => writeln!(
44            &mut out,
45            "machine registry: (KAIZEN_HOME / HOME unset, skipped)"
46        )
47        .unwrap(),
48        Ok(Some((ref path, n))) => writeln!(
49            &mut out,
50            "machine registry: OK ({}; {} project(s))",
51            path.display(),
52            n
53        )
54        .unwrap(),
55        Err(e) => {
56            hard_fail = true;
57            writeln!(&mut out, "machine registry: ERROR: {e}").unwrap();
58        }
59    }
60    writeln!(&mut out).unwrap();
61
62    let cfg = match config::load(&ws) {
63        Ok(c) => c,
64        Err(e) => {
65            writeln!(&mut out, "config load: ERROR: {e}").unwrap();
66            return Ok((1, out));
67        }
68    };
69
70    writeln!(&mut out, "config (merged, no secrets):").unwrap();
71    writeln!(&mut out, "  scan.roots: {} entries", cfg.scan.roots.len()).unwrap();
72    for (i, r) in cfg.scan.roots.iter().take(3).enumerate() {
73        let exp = crate::shell::cli::expand_home(r);
74        let exists = Path::new(&exp).exists();
75        writeln!(&mut out, "    [{}] {} → exists={}", i + 1, r, exists).unwrap();
76    }
77    if cfg.scan.roots.len() > 3 {
78        writeln!(&mut out, "    …").unwrap();
79    }
80    writeln!(
81        &mut out,
82        "  sources.cursor: enabled={} glob={}",
83        cfg.sources.cursor.enabled, cfg.sources.cursor.transcript_glob
84    )
85    .unwrap();
86    let t = &cfg.sources.tail;
87    writeln!(
88        &mut out,
89        "  sources.tail: goose={} opencode={} copilot_cli={} copilot_vscode={}",
90        t.goose, t.opencode, t.copilot_cli, t.copilot_vscode
91    )
92    .unwrap();
93    let sync_on = !cfg.sync.endpoint.is_empty() && !cfg.sync.team_id.is_empty();
94    writeln!(&mut out, "  sync: endpoint configured: {}", sync_on).unwrap();
95    writeln!(&mut out).unwrap();
96
97    let db = ws.join(".kaizen/kaizen.db");
98    let ws_key = ws.to_string_lossy().to_string();
99    match Store::open(&db) {
100        Ok(store) => {
101            writeln!(&mut out, "store: OK ({})", db.display()).unwrap();
102            if let Ok(sessions) = store.list_sessions(&ws_key) {
103                writeln!(
104                    &mut out,
105                    "  sessions in store (this workspace key): {}",
106                    sessions.len()
107                )
108                .unwrap();
109            }
110            if let Ok(query) = crate::store::query::QueryStore::open(&ws.join(".kaizen"))
111                && let Ok(stats) = query.summary_stats(&store, &ws_key)
112                && crate::shell::cli::summary_needs_cost_rollup_note(
113                    stats.session_count,
114                    stats.total_cost_usd_e6,
115                )
116            {
117                writeln!(
118                    &mut out,
119                    "  {}",
120                    crate::shell::cli::cost_rollup_zero_doctor_hint()
121                )
122                .unwrap();
123            }
124        }
125        Err(e) => {
126            hard_fail = true;
127            writeln!(&mut out, "store: ERROR: {e}").unwrap();
128        }
129    }
130    if let Some(parent) = db.parent() {
131        if !parent.exists() {
132            writeln!(
133                &mut out,
134                ".kaizen/ directory: missing (will be created on first open)"
135            )
136            .unwrap();
137        } else {
138            let probe = parent.join(".kaizen_write_probe");
139            let ok = std::fs::File::create(&probe).is_ok();
140            if ok {
141                let _ = std::fs::remove_file(&probe);
142            }
143            writeln!(
144                &mut out,
145                ".kaizen/ writable: {}",
146                if ok { "yes" } else { "no" }
147            )
148            .unwrap();
149            if !ok {
150                hard_fail = true;
151            }
152        }
153    }
154    writeln!(&mut out).unwrap();
155
156    let cursor = init::cursor_kaizen_hook_wiring(&ws);
157    match &cursor {
158        Ok(None) => writeln!(
159            &mut out,
160            "hooks: .cursor/hooks.json — absent (run `kaizen init` to wire Cursor)"
161        )
162        .unwrap(),
163        Ok(Some(true)) => writeln!(
164            &mut out,
165            "hooks: .cursor/hooks.json — kaizen command on all events"
166        )
167        .unwrap(),
168        Ok(Some(false)) => {
169            writeln!(&mut out, "hooks: .cursor/hooks.json — present but not fully wired to kaizen (run: kaizen init)").unwrap();
170        }
171        Err(e) => writeln!(&mut out, "hooks: .cursor/hooks.json — read error: {e}").unwrap(),
172    }
173    let claude = init::claude_kaizen_hook_wiring(&ws);
174    match &claude {
175        Ok(None) => writeln!(
176            &mut out,
177            "hooks: .claude/settings.json — absent (run `kaizen init` to wire Claude Code)"
178        )
179        .unwrap(),
180        Ok(Some(true)) => writeln!(
181            &mut out,
182            "hooks: .claude/settings.json — kaizen hooks on all events"
183        )
184        .unwrap(),
185        Ok(Some(false)) => {
186            writeln!(
187                &mut out,
188                "hooks: .claude/settings.json — present but not fully wired (run: kaizen init)"
189            )
190            .unwrap();
191        }
192        Err(e) => writeln!(&mut out, "hooks: .claude/settings.json — read error: {e}").unwrap(),
193    }
194    let openclaw = init::openclaw_kaizen_hook_wiring(&ws);
195    match &openclaw {
196        Ok(None) => writeln!(
197            &mut out,
198            "hooks: ~/.openclaw/hooks/kaizen-events — absent (run `kaizen init` to wire OpenClaw)"
199        )
200        .unwrap(),
201        Ok(Some(true)) => {
202            writeln!(&mut out, "hooks: ~/.openclaw/hooks/kaizen-events — wired").unwrap()
203        }
204        Ok(Some(false)) => writeln!(
205            &mut out,
206            "hooks: ~/.openclaw/hooks/kaizen-events — present but partial (run: kaizen init)"
207        )
208        .unwrap(),
209        Err(e) => writeln!(
210            &mut out,
211            "hooks: ~/.openclaw/hooks/kaizen-events — read error: {e}"
212        )
213        .unwrap(),
214    }
215    writeln!(&mut out).unwrap();
216    if std::io::stdout().is_terminal() {
217        writeln!(&mut out, "If sessions list is empty, run a short agent session in this repo and `kaizen sessions list` again; see https://github.com/marquesds/kaizen/blob/main/docs/config.md#sources.").unwrap();
218    } else {
219        writeln!(&mut out, "If sessions list is empty, see docs/config.md (sources) and `kaizen doctor` from a TTY for tips.").unwrap();
220    }
221    if hard_fail {
222        Ok((1, out))
223    } else {
224        Ok((0, out))
225    }
226}
227
228pub fn cmd_doctor(workspace: Option<&Path>) -> Result<i32> {
229    let (code, s) = doctor_text(workspace)?;
230    print!("{s}");
231    Ok(code)
232}