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 data_dir = crate::core::paths::project_data_dir(&ws).ok();
24    let wcfg_ex = data_dir
25        .as_ref()
26        .is_some_and(|d| d.join("config.toml").exists());
27    writeln!(
28        &mut out,
29        "project config.toml: {}",
30        if wcfg_ex { "present" } else { "absent" }
31    )
32    .unwrap();
33    match crate::core::paths::kaizen_dir() {
34        Some(kd) => writeln!(
35            &mut out,
36            "~/.kaizen/config.toml: {}",
37            if kd.join("config.toml").exists() {
38                "present"
39            } else {
40                "absent"
41            }
42        )
43        .unwrap(),
44        None => writeln!(
45            &mut out,
46            "~/.kaizen/config.toml: (KAIZEN_HOME / HOME unset, skipped)"
47        )
48        .unwrap(),
49    }
50    match crate::core::machine_registry::status() {
51        Ok(None) => writeln!(
52            &mut out,
53            "machine registry: (KAIZEN_HOME / HOME unset, skipped)"
54        )
55        .unwrap(),
56        Ok(Some((ref path, n))) => writeln!(
57            &mut out,
58            "machine registry: OK ({}; {} project(s))",
59            path.display(),
60            n
61        )
62        .unwrap(),
63        Err(e) => {
64            hard_fail = true;
65            writeln!(&mut out, "machine registry: ERROR: {e}").unwrap();
66        }
67    }
68    writeln!(&mut out).unwrap();
69
70    let cfg = match config::load(&ws) {
71        Ok(c) => c,
72        Err(e) => {
73            writeln!(&mut out, "config load: ERROR: {e}").unwrap();
74            return Ok((1, out));
75        }
76    };
77
78    writeln!(&mut out, "config (merged, no secrets):").unwrap();
79    writeln!(&mut out, "  scan.roots: {} entries", cfg.scan.roots.len()).unwrap();
80    for (i, r) in cfg.scan.roots.iter().take(3).enumerate() {
81        let exp = crate::shell::cli::expand_home(r);
82        let exists = Path::new(&exp).exists();
83        writeln!(&mut out, "    [{}] {} → exists={}", i + 1, r, exists).unwrap();
84    }
85    if cfg.scan.roots.len() > 3 {
86        writeln!(&mut out, "    …").unwrap();
87    }
88    writeln!(
89        &mut out,
90        "  sources.cursor: enabled={} glob={}",
91        cfg.sources.cursor.enabled, cfg.sources.cursor.transcript_glob
92    )
93    .unwrap();
94    let t = &cfg.sources.tail;
95    writeln!(
96        &mut out,
97        "  sources.tail: goose={} opencode={} copilot_cli={} copilot_vscode={}",
98        t.goose, t.opencode, t.copilot_cli, t.copilot_vscode
99    )
100    .unwrap();
101    let sync_on = !cfg.sync.endpoint.is_empty() && !cfg.sync.team_id.is_empty();
102    writeln!(&mut out, "  sync: endpoint configured: {}", sync_on).unwrap();
103    writeln!(&mut out).unwrap();
104
105    let db_result = crate::core::workspace::db_path(&ws);
106    let ws_key = ws.to_string_lossy().to_string();
107    match db_result.and_then(|db| Store::open(&db).map(|s| (db, s))) {
108        Ok((db, store)) => {
109            writeln!(&mut out, "store: OK ({})", db.display()).unwrap();
110            if let Ok(sessions) = store.list_sessions(&ws_key) {
111                writeln!(
112                    &mut out,
113                    "  sessions in store (this workspace key): {}",
114                    sessions.len()
115                )
116                .unwrap();
117            }
118            if let Ok(data_dir) = crate::core::paths::project_data_dir(&ws)
119                && let Ok(query) = crate::store::query::QueryStore::open(&data_dir)
120                && let Ok(stats) = query.summary_stats(&store, &ws_key)
121                && crate::shell::cli::summary_needs_cost_rollup_note(
122                    stats.session_count,
123                    stats.total_cost_usd_e6,
124                )
125            {
126                writeln!(
127                    &mut out,
128                    "  {}",
129                    crate::shell::cli::cost_rollup_zero_doctor_hint()
130                )
131                .unwrap();
132            }
133            let probe = db.parent().map(|p| p.join(".kaizen_write_probe"));
134            if let Some(probe) = probe {
135                let ok = std::fs::File::create(&probe).is_ok();
136                if ok {
137                    let _ = std::fs::remove_file(&probe);
138                }
139                writeln!(
140                    &mut out,
141                    "project data dir writable: {}",
142                    if ok { "yes" } else { "no" }
143                )
144                .unwrap();
145                if !ok {
146                    hard_fail = true;
147                }
148            }
149        }
150        Err(e) => {
151            hard_fail = true;
152            writeln!(&mut out, "store: ERROR: {e}").unwrap();
153        }
154    }
155    writeln!(&mut out).unwrap();
156
157    let cursor = init::cursor_kaizen_hook_wiring(&ws);
158    match &cursor {
159        Ok(None) => writeln!(
160            &mut out,
161            "hooks: ~/.cursor/hooks.json — absent (run `kaizen init` to wire Cursor)"
162        )
163        .unwrap(),
164        Ok(Some(true)) => writeln!(
165            &mut out,
166            "hooks: ~/.cursor/hooks.json — kaizen command on all events"
167        )
168        .unwrap(),
169        Ok(Some(false)) => {
170            writeln!(&mut out, "hooks: ~/.cursor/hooks.json — present but not fully wired to kaizen (run: kaizen init)").unwrap();
171        }
172        Err(e) => writeln!(&mut out, "hooks: ~/.cursor/hooks.json — read error: {e}").unwrap(),
173    }
174    let claude = init::claude_kaizen_hook_wiring(&ws);
175    match &claude {
176        Ok(None) => writeln!(
177            &mut out,
178            "hooks: ~/.claude/settings.json — absent (run `kaizen init` to wire Claude Code)"
179        )
180        .unwrap(),
181        Ok(Some(true)) => writeln!(
182            &mut out,
183            "hooks: ~/.claude/settings.json — kaizen hooks on all events"
184        )
185        .unwrap(),
186        Ok(Some(false)) => {
187            writeln!(
188                &mut out,
189                "hooks: ~/.claude/settings.json — present but not fully wired (run: kaizen init)"
190            )
191            .unwrap();
192        }
193        Err(e) => {
194            writeln!(&mut out, "hooks: ~/.claude/settings.json — read error: {e}").unwrap();
195        }
196    }
197    for path in init::detect_legacy_wiring(&ws) {
198        writeln!(
199            &mut out,
200            "hooks: legacy local wiring at {} — safe to remove (kaizen now wires globally)",
201            path.display()
202        )
203        .unwrap();
204    }
205    let openclaw = init::openclaw_kaizen_hook_wiring(&ws);
206    match &openclaw {
207        Ok(None) => writeln!(
208            &mut out,
209            "hooks: ~/.openclaw/hooks/kaizen-events — absent (run `kaizen init` to wire OpenClaw)"
210        )
211        .unwrap(),
212        Ok(Some(true)) => {
213            writeln!(&mut out, "hooks: ~/.openclaw/hooks/kaizen-events — wired").unwrap()
214        }
215        Ok(Some(false)) => writeln!(
216            &mut out,
217            "hooks: ~/.openclaw/hooks/kaizen-events — present but partial (run: kaizen init)"
218        )
219        .unwrap(),
220        Err(e) => writeln!(
221            &mut out,
222            "hooks: ~/.openclaw/hooks/kaizen-events — read error: {e}"
223        )
224        .unwrap(),
225    }
226    writeln!(&mut out).unwrap();
227    if std::io::stdout().is_terminal() {
228        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();
229    } else {
230        writeln!(&mut out, "If sessions list is empty, see docs/config.md (sources) and `kaizen doctor` from a TTY for tips.").unwrap();
231    }
232    if hard_fail {
233        Ok((1, out))
234    } else {
235        Ok((0, out))
236    }
237}
238
239pub fn cmd_doctor(workspace: Option<&Path>) -> Result<i32> {
240    let (code, s) = doctor_text(workspace)?;
241    print!("{s}");
242    Ok(code)
243}