1use 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
35pub 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}