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