1use std::net::TcpListener;
4use std::path::PathBuf;
5
6use chrono::Utc;
7use serde::Serialize;
8
9const GREEN: &str = "\x1b[32m";
10const RED: &str = "\x1b[31m";
11const BOLD: &str = "\x1b[1m";
12const RST: &str = "\x1b[0m";
13const DIM: &str = "\x1b[2m";
14const WHITE: &str = "\x1b[97m";
15const YELLOW: &str = "\x1b[33m";
16
17struct Outcome {
18 ok: bool,
19 line: String,
20}
21
22fn print_check(outcome: &Outcome) {
23 let mark = if outcome.ok {
24 format!("{GREEN}✓{RST}")
25 } else {
26 format!("{RED}✗{RST}")
27 };
28 println!(" {mark} {}", outcome.line);
29}
30
31fn path_in_path_env() -> bool {
32 if let Ok(path) = std::env::var("PATH") {
33 for dir in std::env::split_paths(&path) {
34 if dir.join("lean-ctx").is_file() {
35 return true;
36 }
37 if cfg!(windows)
38 && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
39 {
40 return true;
41 }
42 }
43 }
44 false
45}
46
47fn resolve_lean_ctx_binary() -> Option<PathBuf> {
48 if let Ok(path) = std::env::var("PATH") {
49 for dir in std::env::split_paths(&path) {
50 if cfg!(windows) {
51 let exe = dir.join("lean-ctx.exe");
52 if exe.is_file() {
53 return Some(exe);
54 }
55 let cmd = dir.join("lean-ctx.cmd");
56 if cmd.is_file() {
57 return Some(cmd);
58 }
59 } else {
60 let bin = dir.join("lean-ctx");
61 if bin.is_file() {
62 return Some(bin);
63 }
64 }
65 }
66 }
67 None
68}
69
70fn lean_ctx_version_from_path() -> Outcome {
71 let resolved = resolve_lean_ctx_binary();
72 let bin = resolved
73 .clone()
74 .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
75
76 let v = env!("CARGO_PKG_VERSION");
77 let note = match std::env::current_exe() {
78 Ok(exe) if exe == bin => format!("{DIM}(this binary){RST}"),
79 Ok(_) | Err(_) => format!("{DIM}(resolved: {}){RST}", bin.display()),
80 };
81 Outcome {
82 ok: true,
83 line: format!("{BOLD}lean-ctx version{RST} {WHITE}lean-ctx {v}{RST} {note}"),
84 }
85}
86
87fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
88 match std::fs::read_to_string(path) {
89 Ok(s) => s.contains("lean-ctx"),
90 Err(_) => false,
91 }
92}
93
94fn has_pipe_guard_in_content(content: &str) -> bool {
95 content.contains("! -t 1")
96 || content.contains("isatty stdout")
97 || content.contains("IsOutputRedirected")
98}
99
100fn rc_references_shell_hook(content: &str) -> bool {
101 content.contains("lean-ctx/shell-hook.") || content.contains("lean-ctx\\shell-hook.")
102}
103
104fn rc_has_pipe_guard(path: &PathBuf) -> bool {
105 match std::fs::read_to_string(path) {
106 Ok(s) => {
107 if has_pipe_guard_in_content(&s) {
108 return true;
109 }
110 if rc_references_shell_hook(&s) {
111 let dirs_to_check = hook_dirs();
112 for dir in &dirs_to_check {
113 for ext in &["zsh", "bash", "fish", "ps1"] {
114 let hook = dir.join(format!("shell-hook.{ext}"));
115 if let Ok(h) = std::fs::read_to_string(&hook) {
116 if has_pipe_guard_in_content(&h) {
117 return true;
118 }
119 }
120 }
121 }
122 }
123 false
124 }
125 Err(_) => false,
126 }
127}
128
129fn hook_dirs() -> Vec<std::path::PathBuf> {
130 let mut dirs = Vec::new();
131 if let Ok(d) = crate::core::data_dir::lean_ctx_data_dir() {
132 dirs.push(d);
133 }
134 if let Some(home) = dirs::home_dir() {
135 let legacy = home.join(".lean-ctx");
136 if !dirs.iter().any(|d| d == &legacy) {
137 dirs.push(legacy);
138 }
139 let xdg = home.join(".config").join("lean-ctx");
140 if !dirs.iter().any(|d| d == &xdg) {
141 dirs.push(xdg);
142 }
143 }
144 dirs
145}
146
147fn is_active_shell(rc_name: &str) -> bool {
148 let shell = std::env::var("SHELL").unwrap_or_default();
149 match rc_name {
150 "~/.zshrc" => shell.contains("zsh"),
151 "~/.bashrc" => shell.contains("bash") || shell.is_empty(),
152 "~/.config/fish/config.fish" => shell.contains("fish"),
153 _ => true,
154 }
155}
156
157fn shell_aliases_outcome() -> Outcome {
158 let Some(home) = dirs::home_dir() else {
159 return Outcome {
160 ok: false,
161 line: format!("{BOLD}Shell aliases{RST} {RED}could not resolve home directory{RST}"),
162 };
163 };
164
165 let mut parts = Vec::new();
166 let mut needs_update = Vec::new();
167
168 let zsh = home.join(".zshrc");
169 if rc_contains_lean_ctx(&zsh) {
170 parts.push(format!("{DIM}~/.zshrc{RST}"));
171 if !rc_has_pipe_guard(&zsh) && is_active_shell("~/.zshrc") {
172 needs_update.push("~/.zshrc");
173 }
174 }
175 let bash = home.join(".bashrc");
176 if rc_contains_lean_ctx(&bash) {
177 parts.push(format!("{DIM}~/.bashrc{RST}"));
178 if !rc_has_pipe_guard(&bash) && is_active_shell("~/.bashrc") {
179 needs_update.push("~/.bashrc");
180 }
181 }
182
183 let fish = home.join(".config").join("fish").join("config.fish");
184 if rc_contains_lean_ctx(&fish) {
185 parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
186 if !rc_has_pipe_guard(&fish) && is_active_shell("~/.config/fish/config.fish") {
187 needs_update.push("~/.config/fish/config.fish");
188 }
189 }
190
191 #[cfg(windows)]
192 {
193 let ps_profile = home
194 .join("Documents")
195 .join("PowerShell")
196 .join("Microsoft.PowerShell_profile.ps1");
197 let ps_profile_legacy = home
198 .join("Documents")
199 .join("WindowsPowerShell")
200 .join("Microsoft.PowerShell_profile.ps1");
201 if rc_contains_lean_ctx(&ps_profile) {
202 parts.push(format!("{DIM}PowerShell profile{RST}"));
203 if !rc_has_pipe_guard(&ps_profile) {
204 needs_update.push("PowerShell profile");
205 }
206 } else if rc_contains_lean_ctx(&ps_profile_legacy) {
207 parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
208 if !rc_has_pipe_guard(&ps_profile_legacy) {
209 needs_update.push("WindowsPowerShell profile");
210 }
211 }
212 }
213
214 if parts.is_empty() {
215 let hint = if cfg!(windows) {
216 "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
217 } else {
218 "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
219 };
220 Outcome {
221 ok: false,
222 line: format!("{BOLD}Shell aliases{RST} {RED}{hint}{RST}"),
223 }
224 } else if !needs_update.is_empty() {
225 Outcome {
226 ok: false,
227 line: format!(
228 "{BOLD}Shell aliases{RST} {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
229 needs_update.join(", ")
230 ),
231 }
232 } else {
233 Outcome {
234 ok: true,
235 line: format!(
236 "{BOLD}Shell aliases{RST} {GREEN}lean-ctx referenced in {}{RST}",
237 parts.join(", ")
238 ),
239 }
240 }
241}
242
243struct McpLocation {
244 name: &'static str,
245 display: String,
246 path: PathBuf,
247}
248
249fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
250 let mut locations = vec![
251 McpLocation {
252 name: "Cursor",
253 display: "~/.cursor/mcp.json".into(),
254 path: home.join(".cursor").join("mcp.json"),
255 },
256 McpLocation {
257 name: "Claude Code",
258 display: format!(
259 "{}",
260 crate::core::editor_registry::claude_mcp_json_path(home).display()
261 ),
262 path: crate::core::editor_registry::claude_mcp_json_path(home),
263 },
264 McpLocation {
265 name: "Windsurf",
266 display: "~/.codeium/windsurf/mcp_config.json".into(),
267 path: home
268 .join(".codeium")
269 .join("windsurf")
270 .join("mcp_config.json"),
271 },
272 McpLocation {
273 name: "Codex",
274 display: "~/.codex/config.toml".into(),
275 path: home.join(".codex").join("config.toml"),
276 },
277 McpLocation {
278 name: "Gemini CLI",
279 display: "~/.gemini/settings.json".into(),
280 path: home.join(".gemini").join("settings.json"),
281 },
282 McpLocation {
283 name: "Antigravity",
284 display: "~/.gemini/antigravity/mcp_config.json".into(),
285 path: home
286 .join(".gemini")
287 .join("antigravity")
288 .join("mcp_config.json"),
289 },
290 ];
291
292 #[cfg(unix)]
293 {
294 let zed_cfg = home.join(".config").join("zed").join("settings.json");
295 locations.push(McpLocation {
296 name: "Zed",
297 display: "~/.config/zed/settings.json".into(),
298 path: zed_cfg,
299 });
300 }
301
302 locations.push(McpLocation {
303 name: "Qwen Code",
304 display: "~/.qwen/settings.json".into(),
305 path: home.join(".qwen").join("settings.json"),
306 });
307 locations.push(McpLocation {
308 name: "Trae",
309 display: "~/.trae/mcp.json".into(),
310 path: home.join(".trae").join("mcp.json"),
311 });
312 locations.push(McpLocation {
313 name: "Amazon Q",
314 display: "~/.aws/amazonq/default.json".into(),
315 path: home.join(".aws").join("amazonq").join("default.json"),
316 });
317 locations.push(McpLocation {
318 name: "JetBrains",
319 display: "~/.jb-mcp.json".into(),
320 path: home.join(".jb-mcp.json"),
321 });
322 locations.push(McpLocation {
323 name: "AWS Kiro",
324 display: "~/.kiro/settings/mcp.json".into(),
325 path: home.join(".kiro").join("settings").join("mcp.json"),
326 });
327 locations.push(McpLocation {
328 name: "Verdent",
329 display: "~/.verdent/mcp.json".into(),
330 path: home.join(".verdent").join("mcp.json"),
331 });
332 locations.push(McpLocation {
333 name: "Crush",
334 display: "~/.config/crush/crush.json".into(),
335 path: home.join(".config").join("crush").join("crush.json"),
336 });
337 locations.push(McpLocation {
338 name: "Pi",
339 display: "~/.pi/agent/mcp.json".into(),
340 path: home.join(".pi").join("agent").join("mcp.json"),
341 });
342 locations.push(McpLocation {
343 name: "Amp",
344 display: "~/.config/amp/settings.json".into(),
345 path: home.join(".config").join("amp").join("settings.json"),
346 });
347
348 {
349 #[cfg(unix)]
350 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
351 #[cfg(unix)]
352 let opencode_display = "~/.config/opencode/opencode.json";
353
354 #[cfg(windows)]
355 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
356 std::path::PathBuf::from(appdata)
357 .join("opencode")
358 .join("opencode.json")
359 } else {
360 home.join(".config").join("opencode").join("opencode.json")
361 };
362 #[cfg(windows)]
363 let opencode_display = "%APPDATA%/opencode/opencode.json";
364
365 locations.push(McpLocation {
366 name: "OpenCode",
367 display: opencode_display.into(),
368 path: opencode_cfg,
369 });
370 }
371
372 #[cfg(target_os = "macos")]
373 {
374 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
375 locations.push(McpLocation {
376 name: "VS Code / Copilot",
377 display: "~/Library/Application Support/Code/User/mcp.json".into(),
378 path: vscode_mcp,
379 });
380 }
381 #[cfg(target_os = "linux")]
382 {
383 let vscode_mcp = home.join(".config/Code/User/mcp.json");
384 locations.push(McpLocation {
385 name: "VS Code / Copilot",
386 display: "~/.config/Code/User/mcp.json".into(),
387 path: vscode_mcp,
388 });
389 }
390 #[cfg(target_os = "windows")]
391 {
392 if let Ok(appdata) = std::env::var("APPDATA") {
393 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
394 locations.push(McpLocation {
395 name: "VS Code / Copilot",
396 display: "%APPDATA%/Code/User/mcp.json".into(),
397 path: vscode_mcp,
398 });
399 }
400 }
401
402 locations.push(McpLocation {
403 name: "Hermes Agent",
404 display: "~/.hermes/config.yaml".into(),
405 path: home.join(".hermes").join("config.yaml"),
406 });
407
408 {
409 let cline_path = crate::core::editor_registry::cline_mcp_path();
410 if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
411 locations.push(McpLocation {
412 name: "Cline",
413 display: cline_path.display().to_string(),
414 path: cline_path,
415 });
416 }
417 }
418 {
419 let roo_path = crate::core::editor_registry::roo_mcp_path();
420 if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
421 locations.push(McpLocation {
422 name: "Roo Code",
423 display: roo_path.display().to_string(),
424 path: roo_path,
425 });
426 }
427 }
428
429 locations
430}
431
432fn mcp_config_outcome() -> Outcome {
433 let Some(home) = dirs::home_dir() else {
434 return Outcome {
435 ok: false,
436 line: format!("{BOLD}MCP config{RST} {RED}could not resolve home directory{RST}"),
437 };
438 };
439
440 let locations = mcp_config_locations(&home);
441 let mut found: Vec<String> = Vec::new();
442 let mut exists_no_ref: Vec<String> = Vec::new();
443
444 for loc in &locations {
445 if let Ok(content) = std::fs::read_to_string(&loc.path) {
446 if has_lean_ctx_mcp_entry(&content) {
447 found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
448 } else {
449 exists_no_ref.push(loc.name.to_string());
450 }
451 }
452 }
453
454 found.sort();
455 found.dedup();
456 exists_no_ref.sort();
457 exists_no_ref.dedup();
458
459 if !found.is_empty() {
460 Outcome {
461 ok: true,
462 line: format!(
463 "{BOLD}MCP config{RST} {GREEN}lean-ctx found in: {}{RST}",
464 found.join(", ")
465 ),
466 }
467 } else if !exists_no_ref.is_empty() {
468 let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
469 let cause = if has_claude {
470 format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
471 } else {
472 String::new()
473 };
474 let hint = if has_claude {
475 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
476 } else {
477 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
478 };
479 Outcome {
480 ok: false,
481 line: format!(
482 "{BOLD}MCP config{RST} {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST} {cause} {hint}",
483 exists_no_ref.join(", "),
484 ),
485 }
486 } else {
487 Outcome {
488 ok: false,
489 line: format!(
490 "{BOLD}MCP config{RST} {YELLOW}no MCP config found{RST} {DIM}(run: lean-ctx setup){RST}"
491 ),
492 }
493 }
494}
495
496fn has_lean_ctx_mcp_entry(content: &str) -> bool {
497 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
498 if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
499 return servers.contains_key("lean-ctx");
500 }
501 if let Some(servers) = json
502 .get("mcp")
503 .and_then(|v| v.get("servers"))
504 .and_then(|v| v.as_object())
505 {
506 return servers.contains_key("lean-ctx");
507 }
508 }
509 content.contains("lean-ctx")
510}
511
512fn port_3333_outcome() -> Outcome {
513 match TcpListener::bind("127.0.0.1:3333") {
514 Ok(_listener) => Outcome {
515 ok: true,
516 line: format!("{BOLD}Dashboard port 3333{RST} {GREEN}available on 127.0.0.1{RST}"),
517 },
518 Err(e) => Outcome {
519 ok: false,
520 line: format!("{BOLD}Dashboard port 3333{RST} {RED}not available: {e}{RST}"),
521 },
522 }
523}
524
525fn pi_outcome() -> Option<Outcome> {
526 let pi_result = std::process::Command::new("pi").arg("--version").output();
527
528 match pi_result {
529 Ok(output) if output.status.success() => {
530 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
531 let has_plugin = std::process::Command::new("pi")
532 .args(["list"])
533 .output()
534 .is_ok_and(|o| {
535 o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
536 });
537
538 let has_mcp = dirs::home_dir()
539 .map(|h| h.join(".pi/agent/mcp.json"))
540 .and_then(|p| std::fs::read_to_string(p).ok())
541 .is_some_and(|c| c.contains("lean-ctx"));
542
543 if has_plugin && has_mcp {
544 Some(Outcome {
545 ok: true,
546 line: format!(
547 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
548 ),
549 })
550 } else if has_plugin {
551 Some(Outcome {
552 ok: true,
553 line: format!(
554 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx installed{RST} {DIM}(MCP not configured — embedded bridge active){RST}"
555 ),
556 })
557 } else {
558 Some(Outcome {
559 ok: false,
560 line: format!(
561 "{BOLD}Pi Coding Agent{RST} {YELLOW}{version}, but pi-lean-ctx not installed{RST} {DIM}(run: pi install npm:pi-lean-ctx){RST}"
562 ),
563 })
564 }
565 }
566 _ => None,
567 }
568}
569
570fn session_state_outcome() -> Outcome {
571 use crate::core::session::SessionState;
572
573 match SessionState::load_latest() {
574 Some(session) => {
575 let root = session
576 .project_root
577 .as_deref()
578 .unwrap_or("(not set)");
579 let cwd = session
580 .shell_cwd
581 .as_deref()
582 .unwrap_or("(not tracked)");
583 Outcome {
584 ok: true,
585 line: format!(
586 "{BOLD}Session state{RST} {GREEN}active{RST} {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
587 session.version
588 ),
589 }
590 }
591 None => Outcome {
592 ok: true,
593 line: format!(
594 "{BOLD}Session state{RST} {YELLOW}no active session{RST} {DIM}(will be created on first tool call){RST}"
595 ),
596 },
597 }
598}
599
600fn docker_env_outcomes() -> Vec<Outcome> {
601 if !crate::shell::is_container() {
602 return vec![];
603 }
604 let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
605 |_| "/root/.lean-ctx/env.sh".to_string(),
606 |d| d.join("env.sh").to_string_lossy().to_string(),
607 );
608
609 let mut outcomes = vec![];
610
611 let shell_name = std::env::var("SHELL").unwrap_or_default();
612 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
613
614 if is_bash {
615 let has_bash_env = std::env::var("BASH_ENV").is_ok();
616 outcomes.push(if has_bash_env {
617 Outcome {
618 ok: true,
619 line: format!(
620 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
621 std::env::var("BASH_ENV").unwrap_or_default()
622 ),
623 }
624 } else {
625 Outcome {
626 ok: false,
627 line: format!(
628 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
629 ),
630 }
631 });
632 }
633
634 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
635 outcomes.push(if has_claude_env {
636 Outcome {
637 ok: true,
638 line: format!(
639 "{BOLD}CLAUDE_ENV_FILE{RST} {GREEN}set{RST} {DIM}({}){RST}",
640 std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
641 ),
642 }
643 } else {
644 Outcome {
645 ok: false,
646 line: format!(
647 "{BOLD}CLAUDE_ENV_FILE{RST} {RED}not set{RST} {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
648 ),
649 }
650 });
651
652 outcomes
653}
654
655pub fn run() {
657 let mut passed = 0u32;
658 let total = 9u32;
659
660 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
661
662 let path_bin = resolve_lean_ctx_binary();
664 let also_in_path_dirs = path_in_path_env();
665 let bin_ok = path_bin.is_some() || also_in_path_dirs;
666 if bin_ok {
667 passed += 1;
668 }
669 let bin_line = if let Some(p) = path_bin {
670 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
671 } else if also_in_path_dirs {
672 format!(
673 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
674 )
675 } else {
676 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
677 };
678 print_check(&Outcome {
679 ok: bin_ok,
680 line: bin_line,
681 });
682
683 let ver = if bin_ok {
685 lean_ctx_version_from_path()
686 } else {
687 Outcome {
688 ok: false,
689 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
690 }
691 };
692 if ver.ok {
693 passed += 1;
694 }
695 print_check(&ver);
696
697 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
699 let dir_outcome = match &lean_dir {
700 Some(p) if p.is_dir() => {
701 passed += 1;
702 Outcome {
703 ok: true,
704 line: format!(
705 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
706 p.display()
707 ),
708 }
709 }
710 Some(p) => Outcome {
711 ok: false,
712 line: format!(
713 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
714 p.display()
715 ),
716 },
717 None => Outcome {
718 ok: false,
719 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
720 },
721 };
722 print_check(&dir_outcome);
723
724 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
726 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
727 Some(m) if m.is_file() => {
728 passed += 1;
729 let size = m.len();
730 let path_display = if let Some(p) = stats_path.as_ref() {
731 p.display().to_string()
732 } else {
733 String::new()
734 };
735 Outcome {
736 ok: true,
737 line: format!(
738 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{path_display}{RST}",
739 ),
740 }
741 }
742 Some(_m) => {
743 let path_display = if let Some(p) = stats_path.as_ref() {
744 p.display().to_string()
745 } else {
746 String::new()
747 };
748 Outcome {
749 ok: false,
750 line: format!(
751 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{path_display}{RST}",
752 ),
753 }
754 }
755 None => {
756 passed += 1;
757 Outcome {
758 ok: true,
759 line: match &stats_path {
760 Some(p) => format!(
761 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
762 p.display()
763 ),
764 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
765 },
766 }
767 }
768 };
769 print_check(&stats_outcome);
770
771 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
773 let config_outcome = match &config_path {
774 Some(p) => match std::fs::metadata(p) {
775 Ok(m) if m.is_file() => {
776 passed += 1;
777 Outcome {
778 ok: true,
779 line: format!(
780 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
781 p.display()
782 ),
783 }
784 }
785 Ok(_) => Outcome {
786 ok: false,
787 line: format!(
788 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
789 p.display()
790 ),
791 },
792 Err(_) => {
793 passed += 1;
794 Outcome {
795 ok: true,
796 line: format!(
797 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
798 p.display()
799 ),
800 }
801 }
802 },
803 None => Outcome {
804 ok: false,
805 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
806 },
807 };
808 print_check(&config_outcome);
809
810 let aliases = shell_aliases_outcome();
812 if aliases.ok {
813 passed += 1;
814 }
815 print_check(&aliases);
816
817 let mcp = mcp_config_outcome();
819 if mcp.ok {
820 passed += 1;
821 }
822 print_check(&mcp);
823
824 let skill = skill_files_outcome();
826 if skill.ok {
827 passed += 1;
828 }
829 print_check(&skill);
830
831 let port = port_3333_outcome();
833 if port.ok {
834 passed += 1;
835 }
836 print_check(&port);
837
838 #[cfg(unix)]
840 let daemon_outcome = if crate::daemon::is_daemon_running() {
841 let pid_path = crate::daemon::daemon_pid_path();
842 let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
843 Outcome {
844 ok: true,
845 line: format!(
846 "{BOLD}Daemon{RST} {GREEN}running (PID {}){RST}",
847 pid_str.trim()
848 ),
849 }
850 } else {
851 Outcome {
852 ok: true,
853 line: format!(
854 "{BOLD}Daemon{RST} {YELLOW}not running{RST} {DIM}(run: lean-ctx serve -d){RST}"
855 ),
856 }
857 };
858 #[cfg(not(unix))]
859 let daemon_outcome = Outcome {
860 ok: true,
861 line: format!("{BOLD}Daemon{RST} {DIM}not supported on this platform{RST}"),
862 };
863 if daemon_outcome.ok {
864 passed += 1;
865 }
866 print_check(&daemon_outcome);
867
868 let session_outcome = session_state_outcome();
870 if session_outcome.ok {
871 passed += 1;
872 }
873 print_check(&session_outcome);
874
875 let docker_outcomes = docker_env_outcomes();
877 for docker_check in &docker_outcomes {
878 if docker_check.ok {
879 passed += 1;
880 }
881 print_check(docker_check);
882 }
883
884 let pi = pi_outcome();
886 if let Some(ref pi_check) = pi {
887 if pi_check.ok {
888 passed += 1;
889 }
890 print_check(pi_check);
891 }
892
893 let integrity = crate::core::integrity::check();
895 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
896 if integrity_ok {
897 passed += 1;
898 }
899 let integrity_line = if integrity_ok {
900 format!(
901 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
902 integrity.repo
903 )
904 } else {
905 format!(
906 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
907 integrity.pkg_name, integrity.repo
908 )
909 };
910 print_check(&Outcome {
911 ok: integrity_ok,
912 line: integrity_line,
913 });
914
915 let cache_safety = cache_safety_outcome();
917 if cache_safety.ok {
918 passed += 1;
919 }
920 print_check(&cache_safety);
921
922 let claude_truncation = claude_truncation_outcome();
924 if let Some(ref ct) = claude_truncation {
925 if ct.ok {
926 passed += 1;
927 }
928 print_check(ct);
929 }
930
931 let mut effective_total = total + 3; effective_total += docker_outcomes.len() as u32;
933 if pi.is_some() {
934 effective_total += 1;
935 }
936 if claude_truncation.is_some() {
937 effective_total += 1;
938 }
939 println!();
940 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
941 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
942}
943
944fn skill_files_outcome() -> Outcome {
945 let Some(home) = dirs::home_dir() else {
946 return Outcome {
947 ok: false,
948 line: format!("{BOLD}SKILL.md{RST} {RED}could not resolve home directory{RST}"),
949 };
950 };
951
952 let candidates = [
953 ("Claude Code", home.join(".claude/skills/lean-ctx/SKILL.md")),
954 ("Cursor", home.join(".cursor/skills/lean-ctx/SKILL.md")),
955 ("Codex CLI", home.join(".codex/skills/lean-ctx/SKILL.md")),
956 (
957 "GitHub Copilot",
958 home.join(".vscode/skills/lean-ctx/SKILL.md"),
959 ),
960 ];
961
962 let mut found: Vec<&str> = Vec::new();
963 for (name, path) in &candidates {
964 if path.exists() {
965 found.push(name);
966 }
967 }
968
969 if found.is_empty() {
970 Outcome {
971 ok: false,
972 line: format!(
973 "{BOLD}SKILL.md{RST} {YELLOW}not installed{RST} {DIM}(run: lean-ctx setup){RST}"
974 ),
975 }
976 } else {
977 Outcome {
978 ok: true,
979 line: format!(
980 "{BOLD}SKILL.md{RST} {GREEN}installed for {}{RST}",
981 found.join(", ")
982 ),
983 }
984 }
985}
986
987fn cache_safety_outcome() -> Outcome {
988 use crate::core::neural::cache_alignment::CacheAlignedOutput;
989 use crate::core::provider_cache::ProviderCacheState;
990
991 let mut issues = Vec::new();
992
993 let mut aligned = CacheAlignedOutput::new();
994 aligned.add_stable_block("test", "stable content".into(), 1);
995 aligned.add_variable_block("test_var", "variable content".into(), 1);
996 let rendered = aligned.render();
997 if rendered.find("stable content").unwrap_or(usize::MAX)
998 > rendered.find("variable content").unwrap_or(0)
999 {
1000 issues.push("cache_alignment: stable blocks not ordered first");
1001 }
1002
1003 let mut state = ProviderCacheState::new();
1004 let section = crate::core::provider_cache::CacheableSection::new(
1005 "doctor_test",
1006 "test content".into(),
1007 crate::core::provider_cache::SectionPriority::System,
1008 true,
1009 );
1010 state.mark_sent(§ion);
1011 if state.needs_update(§ion) {
1012 issues.push("provider_cache: hash tracking broken");
1013 }
1014
1015 if issues.is_empty() {
1016 Outcome {
1017 ok: true,
1018 line: format!(
1019 "{BOLD}Cache safety{RST} {GREEN}cache_alignment + provider_cache operational{RST}"
1020 ),
1021 }
1022 } else {
1023 Outcome {
1024 ok: false,
1025 line: format!("{BOLD}Cache safety{RST} {RED}{}{RST}", issues.join("; ")),
1026 }
1027 }
1028}
1029
1030fn claude_binary_exists() -> bool {
1031 #[cfg(unix)]
1032 {
1033 std::process::Command::new("which")
1034 .arg("claude")
1035 .output()
1036 .is_ok_and(|o| o.status.success())
1037 }
1038 #[cfg(windows)]
1039 {
1040 std::process::Command::new("where")
1041 .arg("claude")
1042 .output()
1043 .is_ok_and(|o| o.status.success())
1044 }
1045}
1046
1047fn claude_truncation_outcome() -> Option<Outcome> {
1048 let home = dirs::home_dir()?;
1049 let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1050 || crate::core::editor_registry::claude_state_dir(&home).exists()
1051 || claude_binary_exists();
1052
1053 if !claude_detected {
1054 return None;
1055 }
1056
1057 let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1058 let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1059
1060 let has_rules = rules_path.exists();
1061 let has_skill = skill_path.exists();
1062
1063 if has_rules && has_skill {
1064 Some(Outcome {
1065 ok: true,
1066 line: format!(
1067 "{BOLD}Claude Code instructions{RST} {GREEN}rules + skill installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1068 ),
1069 })
1070 } else if has_rules {
1071 Some(Outcome {
1072 ok: true,
1073 line: format!(
1074 "{BOLD}Claude Code instructions{RST} {GREEN}rules file installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1075 ),
1076 })
1077 } else {
1078 Some(Outcome {
1079 ok: false,
1080 line: format!(
1081 "{BOLD}Claude Code instructions{RST} {YELLOW}MCP instructions truncated at 2048 chars, no rules file found{RST} {DIM}(run: lean-ctx init --agent claude){RST}"
1082 ),
1083 })
1084 }
1085}
1086
1087pub fn run_compact() {
1088 let (passed, total) = compact_score();
1089 print_compact_status(passed, total);
1090}
1091
1092pub fn run_cli(args: &[String]) -> i32 {
1093 let (sub, rest) = match args.first().map(String::as_str) {
1094 Some("integrations") => ("integrations", &args[1..]),
1095 _ => ("", args),
1096 };
1097
1098 let fix = rest.iter().any(|a| a == "--fix");
1099 let json = rest.iter().any(|a| a == "--json");
1100 let help = rest.iter().any(|a| a == "--help" || a == "-h");
1101
1102 if help {
1103 println!("Usage:");
1104 println!(" lean-ctx doctor");
1105 println!(" lean-ctx doctor integrations [--json]");
1106 println!(" lean-ctx doctor --fix [--json]");
1107 return 0;
1108 }
1109
1110 if sub == "integrations" {
1111 if fix {
1112 let _ = run_fix(&DoctorFixOptions { json: false });
1113 }
1114 return run_integrations(&IntegrationsOptions { json });
1115 }
1116
1117 if !fix {
1118 run();
1119 return 0;
1120 }
1121
1122 match run_fix(&DoctorFixOptions { json }) {
1123 Ok(code) => code,
1124 Err(e) => {
1125 tracing::error!("doctor --fix failed: {e}");
1126 2
1127 }
1128 }
1129}
1130
1131#[derive(Debug, Clone, Copy)]
1132struct IntegrationsOptions {
1133 json: bool,
1134}
1135
1136#[derive(Debug, Serialize)]
1137#[serde(rename_all = "camelCase")]
1138struct IntegrationCheckReport {
1139 schema_version: u32,
1140 created_at: String,
1141 binary: String,
1142 integrations: Vec<IntegrationStatus>,
1143 ok: bool,
1144 repair_command: String,
1145}
1146
1147#[derive(Debug, Serialize)]
1148#[serde(rename_all = "camelCase")]
1149struct IntegrationStatus {
1150 name: String,
1151 detected: bool,
1152 checks: Vec<NamedCheck>,
1153 ok: bool,
1154}
1155
1156#[derive(Debug, Serialize)]
1157#[serde(rename_all = "camelCase")]
1158struct NamedCheck {
1159 name: String,
1160 ok: bool,
1161 detail: String,
1162}
1163
1164fn run_integrations(opts: &IntegrationsOptions) -> i32 {
1165 let Some(home) = dirs::home_dir() else {
1166 eprintln!("Cannot determine home directory");
1167 return 2;
1168 };
1169 let binary = crate::core::portable_binary::resolve_portable_binary();
1170 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1171 .map(|d| d.to_string_lossy().to_string())
1172 .unwrap_or_default();
1173
1174 let mut integrations = vec![
1175 integration_cursor(&home, &binary, &data_dir),
1176 integration_claude(&home, &binary, &data_dir),
1177 ];
1178 for t in crate::core::editor_registry::build_targets(&home) {
1179 if matches!(t.name, "Cursor" | "Claude Code") {
1180 continue;
1181 }
1182 integrations.push(integration_generic(&home, &binary, &data_dir, &t));
1183 }
1184 let ok = integrations.iter().all(|i| !i.detected || i.ok);
1185
1186 let report = IntegrationCheckReport {
1187 schema_version: 1,
1188 created_at: Utc::now().to_rfc3339(),
1189 binary: binary.clone(),
1190 integrations,
1191 ok,
1192 repair_command: "lean-ctx setup --fix".to_string(),
1193 };
1194
1195 if opts.json {
1196 println!(
1197 "{}",
1198 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
1199 );
1200 } else {
1201 println!();
1202 println!(" {BOLD}{WHITE}Integration health:{RST}");
1203 for i in &report.integrations {
1204 if !i.detected {
1205 continue;
1206 }
1207 let mark = if i.ok {
1208 format!("{GREEN}✓{RST}")
1209 } else {
1210 format!("{YELLOW}✗{RST}")
1211 };
1212 println!(" {mark} {BOLD}{}{RST}", i.name);
1213 for c in &i.checks {
1214 let m = if c.ok {
1215 format!("{GREEN}✓{RST}")
1216 } else {
1217 format!("{YELLOW}✗{RST}")
1218 };
1219 println!(" {m} {} {DIM}{}{RST}", c.name, c.detail);
1220 }
1221 }
1222 if !report.ok {
1223 println!();
1224 println!(
1225 " {YELLOW}Repair:{RST} run {BOLD}{}{RST}",
1226 report.repair_command
1227 );
1228 }
1229 }
1230
1231 i32::from(!report.ok)
1232}
1233
1234fn integration_generic(
1235 home: &std::path::Path,
1236 binary: &str,
1237 data_dir: &str,
1238 target: &crate::core::editor_registry::types::EditorTarget,
1239) -> IntegrationStatus {
1240 let detected = target.detect_path.exists() || target.config_path.exists();
1241 if !detected {
1242 return IntegrationStatus {
1243 name: target.name.to_string(),
1244 detected: false,
1245 checks: Vec::new(),
1246 ok: true,
1247 };
1248 }
1249
1250 let mut checks = Vec::new();
1251 match target.config_type {
1252 crate::core::editor_registry::types::ConfigType::McpJson
1253 | crate::core::editor_registry::types::ConfigType::JetBrains
1254 | crate::core::editor_registry::types::ConfigType::QoderSettings => {
1255 checks.push(check_mcp_json(&target.config_path, binary, data_dir));
1256 }
1257 crate::core::editor_registry::types::ConfigType::Zed => {
1258 checks.push(check_zed_settings(&target.config_path, binary));
1259 }
1260 crate::core::editor_registry::types::ConfigType::Codex => {
1261 checks.push(check_codex_toml(&target.config_path, binary));
1262 checks.push(check_codex_hooks_enabled(home));
1263 checks.push(check_codex_hooks_json(home));
1264 }
1265 crate::core::editor_registry::types::ConfigType::VsCodeMcp => {
1266 checks.push(check_vscode_mcp(&target.config_path, binary, data_dir));
1267 }
1268 crate::core::editor_registry::types::ConfigType::OpenCode => {
1269 checks.push(check_opencode_config(&target.config_path, binary, data_dir));
1270 }
1271 crate::core::editor_registry::types::ConfigType::Crush => {
1272 checks.push(check_crush_config(&target.config_path, binary, data_dir));
1273 }
1274 crate::core::editor_registry::types::ConfigType::Amp => {
1275 checks.push(check_amp_config(&target.config_path, binary, data_dir));
1276 }
1277 crate::core::editor_registry::types::ConfigType::HermesYaml => {
1278 checks.push(check_hermes_yaml(&target.config_path, binary, data_dir));
1279 }
1280 crate::core::editor_registry::types::ConfigType::GeminiSettings => {
1281 checks.push(check_mcp_json(&target.config_path, binary, data_dir));
1282 checks.push(check_gemini_trust_and_hooks(home, binary));
1283 }
1284 }
1285
1286 if let Some(rules_path) = rules_path_for(target.name, home) {
1288 checks.push(check_rules_file(&rules_path));
1289 }
1290
1291 let ok = checks.iter().all(|c| c.ok);
1292 IntegrationStatus {
1293 name: target.name.to_string(),
1294 detected: true,
1295 checks,
1296 ok,
1297 }
1298}
1299
1300fn integration_cursor(home: &std::path::Path, binary: &str, data_dir: &str) -> IntegrationStatus {
1301 let cursor_dir = home.join(".cursor");
1302 if !cursor_dir.exists() {
1303 return IntegrationStatus {
1304 name: "Cursor".to_string(),
1305 detected: false,
1306 checks: Vec::new(),
1307 ok: true,
1308 };
1309 }
1310
1311 let mut checks = Vec::new();
1312 let mcp_path = cursor_dir.join("mcp.json");
1313 checks.push(check_mcp_json(&mcp_path, binary, data_dir));
1314
1315 let hooks_path = cursor_dir.join("hooks.json");
1316 checks.push(check_cursor_hooks(&hooks_path));
1317
1318 let ok = checks.iter().all(|c| c.ok);
1319 IntegrationStatus {
1320 name: "Cursor".to_string(),
1321 detected: true,
1322 checks,
1323 ok,
1324 }
1325}
1326
1327fn integration_claude(home: &std::path::Path, binary: &str, data_dir: &str) -> IntegrationStatus {
1328 let target = crate::core::editor_registry::build_targets(home)
1329 .into_iter()
1330 .find(|t| t.agent_key == "claude");
1331 let detected = target.as_ref().is_some_and(|t| t.detect_path.exists())
1332 || crate::core::editor_registry::claude_state_dir(home).exists()
1333 || claude_binary_exists();
1334
1335 if !detected {
1336 return IntegrationStatus {
1337 name: "Claude Code".to_string(),
1338 detected: false,
1339 checks: Vec::new(),
1340 ok: true,
1341 };
1342 }
1343
1344 let mut checks = Vec::new();
1345 let mcp_path = crate::core::editor_registry::claude_mcp_json_path(home);
1346 checks.push(check_mcp_json(&mcp_path, binary, data_dir));
1347
1348 let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
1349 checks.push(check_claude_hooks(&settings_path));
1350
1351 let rules_path = crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md");
1352 let has_rules = rules_path.exists();
1353 checks.push(NamedCheck {
1354 name: "Rules file".to_string(),
1355 ok: has_rules,
1356 detail: if has_rules {
1357 rules_path.display().to_string()
1358 } else {
1359 format!("missing ({})", rules_path.display())
1360 },
1361 });
1362
1363 let ok = checks.iter().all(|c| c.ok);
1364 IntegrationStatus {
1365 name: "Claude Code".to_string(),
1366 detected: true,
1367 checks,
1368 ok,
1369 }
1370}
1371
1372fn check_mcp_json(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1373 if !path.exists() {
1374 return NamedCheck {
1375 name: "MCP config".to_string(),
1376 ok: false,
1377 detail: format!("missing ({})", path.display()),
1378 };
1379 }
1380 let content = std::fs::read_to_string(path).unwrap_or_default();
1381 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1382
1383 let Some(v) = parsed else {
1384 return NamedCheck {
1385 name: "MCP config".to_string(),
1386 ok: false,
1387 detail: format!("invalid JSON ({})", path.display()),
1388 };
1389 };
1390
1391 let entry = v
1392 .get("mcpServers")
1393 .and_then(|m| m.get("lean-ctx"))
1394 .cloned()
1395 .or_else(|| {
1396 v.get("mcp")
1397 .and_then(|m| m.get("servers"))
1398 .and_then(|m| m.get("lean-ctx"))
1399 .cloned()
1400 });
1401
1402 let Some(e) = entry else {
1403 return NamedCheck {
1404 name: "MCP config".to_string(),
1405 ok: false,
1406 detail: format!("lean-ctx missing ({})", path.display()),
1407 };
1408 };
1409
1410 let cmd_ok = e
1411 .get("command")
1412 .and_then(|c| c.as_str())
1413 .is_some_and(|c| cmd_matches_expected(c, binary));
1414 let env_ok = e
1415 .get("env")
1416 .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1417 .and_then(|d| d.as_str())
1418 .is_some_and(|d| d.trim() == data_dir.trim());
1419
1420 let ok = cmd_ok && env_ok;
1421 let detail = if ok {
1422 format!("ok ({})", path.display())
1423 } else {
1424 format!("drift ({})", path.display())
1425 };
1426 NamedCheck {
1427 name: "MCP config".to_string(),
1428 ok,
1429 detail,
1430 }
1431}
1432
1433fn cmd_matches_expected(cmd: &str, portable: &str) -> bool {
1434 let cmd = cmd.trim();
1435 if cmd == portable.trim() {
1436 return true;
1437 }
1438 if cmd == "lean-ctx" {
1439 return true;
1440 }
1441 if let Some(resolved) = resolve_lean_ctx_binary() {
1442 if cmd == resolved.to_string_lossy().trim() {
1443 return true;
1444 }
1445 }
1446 false
1447}
1448
1449fn check_rules_file(path: &std::path::Path) -> NamedCheck {
1450 let ok = path.exists();
1451 NamedCheck {
1452 name: "Rules file".to_string(),
1453 ok,
1454 detail: if ok {
1455 path.display().to_string()
1456 } else {
1457 format!("missing ({})", path.display())
1458 },
1459 }
1460}
1461
1462fn rules_path_for(name: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
1463 match name {
1464 "Windsurf" => Some(home.join(".codeium/windsurf/rules/lean-ctx.md")),
1465 "Cline" => Some(home.join(".cline/rules/lean-ctx.md")),
1466 "Roo Code" => Some(home.join(".roo/rules/lean-ctx.md")),
1467 "OpenCode" => Some(home.join(".config/opencode/rules/lean-ctx.md")),
1468 "AWS Kiro" => Some(home.join(".kiro/steering/lean-ctx.md")),
1469 "Verdent" => Some(home.join(".verdent/rules/lean-ctx.md")),
1470 "Trae" => Some(home.join(".trae/rules/lean-ctx.md")),
1471 "Qwen Code" => Some(home.join(".qwen/rules/lean-ctx.md")),
1472 "Amazon Q Developer" => Some(home.join(".aws/amazonq/rules/lean-ctx.md")),
1473 "JetBrains IDEs" => Some(home.join(".jb-rules/lean-ctx.md")),
1474 "Antigravity" => Some(home.join(".gemini/antigravity/rules/lean-ctx.md")),
1475 "Pi Coding Agent" => Some(home.join(".pi/rules/lean-ctx.md")),
1476 "Crush" => Some(home.join(".config/crush/rules/lean-ctx.md")),
1477 _ => None,
1478 }
1479}
1480
1481fn check_zed_settings(path: &std::path::Path, binary: &str) -> NamedCheck {
1482 if !path.exists() {
1483 return NamedCheck {
1484 name: "Zed config".to_string(),
1485 ok: false,
1486 detail: format!("missing ({})", path.display()),
1487 };
1488 }
1489 let content = std::fs::read_to_string(path).unwrap_or_default();
1490 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1491 let Some(v) = parsed else {
1492 return NamedCheck {
1493 name: "Zed config".to_string(),
1494 ok: false,
1495 detail: format!("invalid JSON ({})", path.display()),
1496 };
1497 };
1498 let entry = v
1499 .get("context_servers")
1500 .and_then(|m| m.get("lean-ctx"))
1501 .cloned();
1502 let Some(e) = entry else {
1503 return NamedCheck {
1504 name: "Zed config".to_string(),
1505 ok: false,
1506 detail: format!("lean-ctx missing ({})", path.display()),
1507 };
1508 };
1509
1510 let cmd_ok = e
1511 .get("command")
1512 .and_then(|c| c.as_str())
1513 .is_some_and(|c| cmd_matches_expected(c, binary));
1514
1515 NamedCheck {
1516 name: "Zed config".to_string(),
1517 ok: cmd_ok,
1518 detail: if cmd_ok {
1519 format!("ok ({})", path.display())
1520 } else {
1521 format!("drift ({})", path.display())
1522 },
1523 }
1524}
1525
1526fn check_vscode_mcp(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1527 if !path.exists() {
1528 return NamedCheck {
1529 name: "VS Code MCP".to_string(),
1530 ok: false,
1531 detail: format!("missing ({})", path.display()),
1532 };
1533 }
1534 let content = std::fs::read_to_string(path).unwrap_or_default();
1535 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1536 let Some(v) = parsed else {
1537 return NamedCheck {
1538 name: "VS Code MCP".to_string(),
1539 ok: false,
1540 detail: format!("invalid JSON ({})", path.display()),
1541 };
1542 };
1543 let Some(e) = v.get("servers").and_then(|m| m.get("lean-ctx")) else {
1544 return NamedCheck {
1545 name: "VS Code MCP".to_string(),
1546 ok: false,
1547 detail: format!("lean-ctx missing ({})", path.display()),
1548 };
1549 };
1550
1551 let ty_ok = e.get("type").and_then(|t| t.as_str()) == Some("stdio");
1552 let cmd_ok = e
1553 .get("command")
1554 .and_then(|c| c.as_str())
1555 .is_some_and(|c| cmd_matches_expected(c, binary));
1556 let env_ok = e
1557 .get("env")
1558 .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1559 .and_then(|d| d.as_str())
1560 .is_some_and(|d| d.trim() == data_dir.trim());
1561
1562 let ok = ty_ok && cmd_ok && env_ok;
1563 NamedCheck {
1564 name: "VS Code MCP".to_string(),
1565 ok,
1566 detail: if ok {
1567 format!("ok ({})", path.display())
1568 } else {
1569 format!("drift ({})", path.display())
1570 },
1571 }
1572}
1573
1574fn check_opencode_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1575 if !path.exists() {
1576 return NamedCheck {
1577 name: "OpenCode MCP".to_string(),
1578 ok: false,
1579 detail: format!("missing ({})", path.display()),
1580 };
1581 }
1582 let content = std::fs::read_to_string(path).unwrap_or_default();
1583 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1584 let Some(v) = parsed else {
1585 return NamedCheck {
1586 name: "OpenCode MCP".to_string(),
1587 ok: false,
1588 detail: format!("invalid JSON ({})", path.display()),
1589 };
1590 };
1591 let Some(e) = v.get("mcp").and_then(|m| m.get("lean-ctx")) else {
1592 return NamedCheck {
1593 name: "OpenCode MCP".to_string(),
1594 ok: false,
1595 detail: format!("lean-ctx missing ({})", path.display()),
1596 };
1597 };
1598
1599 let cmd = e
1600 .get("command")
1601 .and_then(|c| c.as_array())
1602 .and_then(|a| a.first())
1603 .and_then(|x| x.as_str());
1604 let cmd_ok = cmd.is_some_and(|c| cmd_matches_expected(c, binary));
1605 let env_ok = e
1606 .get("environment")
1607 .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1608 .and_then(|d| d.as_str())
1609 .is_some_and(|d| d.trim() == data_dir.trim());
1610 let ok = cmd_ok && env_ok;
1611 NamedCheck {
1612 name: "OpenCode MCP".to_string(),
1613 ok,
1614 detail: if ok {
1615 format!("ok ({})", path.display())
1616 } else {
1617 format!("drift ({})", path.display())
1618 },
1619 }
1620}
1621
1622fn check_crush_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1623 if !path.exists() {
1624 return NamedCheck {
1625 name: "Crush MCP".to_string(),
1626 ok: false,
1627 detail: format!("missing ({})", path.display()),
1628 };
1629 }
1630 let content = std::fs::read_to_string(path).unwrap_or_default();
1631 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1632 let Some(v) = parsed else {
1633 return NamedCheck {
1634 name: "Crush MCP".to_string(),
1635 ok: false,
1636 detail: format!("invalid JSON ({})", path.display()),
1637 };
1638 };
1639 let Some(e) = v.get("mcp").and_then(|m| m.get("lean-ctx")) else {
1640 return NamedCheck {
1641 name: "Crush MCP".to_string(),
1642 ok: false,
1643 detail: format!("lean-ctx missing ({})", path.display()),
1644 };
1645 };
1646
1647 let cmd_ok = e
1648 .get("command")
1649 .and_then(|c| c.as_str())
1650 .is_some_and(|c| cmd_matches_expected(c, binary));
1651 let env_ok = e
1652 .get("env")
1653 .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1654 .and_then(|d| d.as_str())
1655 .is_some_and(|d| d.trim() == data_dir.trim());
1656 let ok = cmd_ok && env_ok;
1657 NamedCheck {
1658 name: "Crush MCP".to_string(),
1659 ok,
1660 detail: if ok {
1661 format!("ok ({})", path.display())
1662 } else {
1663 format!("drift ({})", path.display())
1664 },
1665 }
1666}
1667
1668fn check_amp_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1669 if !path.exists() {
1670 return NamedCheck {
1671 name: "Amp MCP".to_string(),
1672 ok: false,
1673 detail: format!("missing ({})", path.display()),
1674 };
1675 }
1676 let content = std::fs::read_to_string(path).unwrap_or_default();
1677 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1678 let Some(v) = parsed else {
1679 return NamedCheck {
1680 name: "Amp MCP".to_string(),
1681 ok: false,
1682 detail: format!("invalid JSON ({})", path.display()),
1683 };
1684 };
1685 let Some(e) = v.get("amp.mcpServers").and_then(|m| m.get("lean-ctx")) else {
1686 return NamedCheck {
1687 name: "Amp MCP".to_string(),
1688 ok: false,
1689 detail: format!("lean-ctx missing ({})", path.display()),
1690 };
1691 };
1692
1693 let cmd_ok = e
1694 .get("command")
1695 .and_then(|c| c.as_str())
1696 .is_some_and(|c| cmd_matches_expected(c, binary));
1697 let env_ok = e
1698 .get("env")
1699 .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1700 .and_then(|d| d.as_str())
1701 .is_some_and(|d| d.trim() == data_dir.trim());
1702 let ok = cmd_ok && env_ok;
1703 NamedCheck {
1704 name: "Amp MCP".to_string(),
1705 ok,
1706 detail: if ok {
1707 format!("ok ({})", path.display())
1708 } else {
1709 format!("drift ({})", path.display())
1710 },
1711 }
1712}
1713
1714fn check_codex_toml(path: &std::path::Path, binary: &str) -> NamedCheck {
1715 if !path.exists() {
1716 return NamedCheck {
1717 name: "Codex MCP".to_string(),
1718 ok: false,
1719 detail: format!("missing ({})", path.display()),
1720 };
1721 }
1722 let content = std::fs::read_to_string(path).unwrap_or_default();
1723 let parsed: Result<toml::Value, _> = toml::from_str(&content);
1724 let Ok(v) = parsed else {
1725 return NamedCheck {
1726 name: "Codex MCP".to_string(),
1727 ok: false,
1728 detail: format!("invalid TOML ({})", path.display()),
1729 };
1730 };
1731 let cmd = v
1732 .get("mcp_servers")
1733 .and_then(|t| t.get("lean-ctx"))
1734 .and_then(|t| t.get("command"))
1735 .and_then(|c| c.as_str());
1736 let ok = cmd.is_some_and(|c| cmd_matches_expected(c, binary));
1737 NamedCheck {
1738 name: "Codex MCP".to_string(),
1739 ok,
1740 detail: if ok {
1741 format!("ok ({})", path.display())
1742 } else {
1743 format!("drift ({})", path.display())
1744 },
1745 }
1746}
1747
1748fn check_codex_hooks_enabled(home: &std::path::Path) -> NamedCheck {
1749 let path = home.join(".codex").join("config.toml");
1750 if !path.exists() {
1751 return NamedCheck {
1752 name: "Codex hooks".to_string(),
1753 ok: false,
1754 detail: format!("missing ({})", path.display()),
1755 };
1756 }
1757 let content = std::fs::read_to_string(&path).unwrap_or_default();
1758 let parsed: Result<toml::Value, _> = toml::from_str(&content);
1759 let Ok(v) = parsed else {
1760 return NamedCheck {
1761 name: "Codex hooks".to_string(),
1762 ok: false,
1763 detail: format!("invalid TOML ({})", path.display()),
1764 };
1765 };
1766 let ok = v
1767 .get("features")
1768 .and_then(|t| t.get("codex_hooks"))
1769 .and_then(toml::Value::as_bool)
1770 == Some(true);
1771 NamedCheck {
1772 name: "Codex hooks".to_string(),
1773 ok,
1774 detail: if ok {
1775 format!("enabled ({})", path.display())
1776 } else {
1777 format!("disabled ({})", path.display())
1778 },
1779 }
1780}
1781
1782fn check_codex_hooks_json(home: &std::path::Path) -> NamedCheck {
1783 let path = home.join(".codex").join("hooks.json");
1784 if !path.exists() {
1785 return NamedCheck {
1786 name: "Codex hooks.json".to_string(),
1787 ok: false,
1788 detail: format!("missing ({})", path.display()),
1789 };
1790 }
1791 let content = std::fs::read_to_string(&path).unwrap_or_default();
1792 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1793 let Some(v) = parsed else {
1794 return NamedCheck {
1795 name: "Codex hooks.json".to_string(),
1796 ok: false,
1797 detail: format!("invalid JSON ({})", path.display()),
1798 };
1799 };
1800 let hooks = v.get("hooks");
1801 let mut saw_session_start = false;
1802 let mut saw_pretool = false;
1803 if let Some(h) = hooks {
1804 for event in ["SessionStart", "PreToolUse"] {
1805 if let Some(arr) = h.get(event).and_then(|x| x.as_array()) {
1806 for entry in arr {
1807 let Some(hooks_arr) = entry.get("hooks").and_then(|x| x.as_array()) else {
1808 continue;
1809 };
1810 for he in hooks_arr {
1811 let Some(cmd) = he.get("command").and_then(|c| c.as_str()) else {
1812 continue;
1813 };
1814 if cmd.contains("hook codex-session-start") {
1815 saw_session_start = true;
1816 }
1817 if cmd.contains("hook codex-pretooluse") {
1818 saw_pretool = true;
1819 }
1820 }
1821 }
1822 }
1823 }
1824 }
1825 let ok = saw_session_start && saw_pretool;
1826 NamedCheck {
1827 name: "Codex hooks.json".to_string(),
1828 ok,
1829 detail: if ok {
1830 format!("ok ({})", path.display())
1831 } else {
1832 format!("missing managed entries ({})", path.display())
1833 },
1834 }
1835}
1836
1837fn check_hermes_yaml(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1838 if !path.exists() {
1839 return NamedCheck {
1840 name: "Hermes MCP".to_string(),
1841 ok: false,
1842 detail: format!("missing ({})", path.display()),
1843 };
1844 }
1845 let content = std::fs::read_to_string(path).unwrap_or_default();
1846 let has_mcp = content.contains("mcp_servers:") && content.contains("lean-ctx:");
1847 let has_cmd =
1848 content.contains("command:") && (content.contains(binary) || content.contains("lean-ctx"));
1849 let has_env = content.contains("LEAN_CTX_DATA_DIR") && content.contains(data_dir);
1850 let ok = has_mcp && has_cmd && has_env;
1851 NamedCheck {
1852 name: "Hermes MCP".to_string(),
1853 ok,
1854 detail: if ok {
1855 format!("ok ({})", path.display())
1856 } else {
1857 format!("drift ({})", path.display())
1858 },
1859 }
1860}
1861
1862fn check_gemini_trust_and_hooks(home: &std::path::Path, binary: &str) -> NamedCheck {
1863 let settings = home.join(".gemini").join("settings.json");
1864 if !settings.exists() {
1865 return NamedCheck {
1866 name: "Gemini hooks".to_string(),
1867 ok: false,
1868 detail: format!("missing ({})", settings.display()),
1869 };
1870 }
1871 let content = std::fs::read_to_string(&settings).unwrap_or_default();
1872 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1873 let Some(v) = parsed else {
1874 return NamedCheck {
1875 name: "Gemini hooks".to_string(),
1876 ok: false,
1877 detail: format!("invalid JSON ({})", settings.display()),
1878 };
1879 };
1880
1881 let trust_ok = v
1882 .get("mcpServers")
1883 .and_then(|m| m.get("lean-ctx"))
1884 .and_then(|e| e.get("trust"))
1885 .and_then(serde_json::Value::as_bool)
1886 == Some(true);
1887
1888 let hooks_ok = v
1889 .get("hooks")
1890 .and_then(|h| h.get("BeforeTool"))
1891 .and_then(|x| x.as_array())
1892 .is_some_and(|arr| {
1893 let mut saw_rewrite = false;
1894 let mut saw_redirect = false;
1895 for entry in arr {
1896 let hooks = entry
1897 .get("hooks")
1898 .and_then(|x| x.as_array())
1899 .cloned()
1900 .unwrap_or_default();
1901 for h in hooks {
1902 let cmd = h
1903 .get("command")
1904 .and_then(|c| c.as_str())
1905 .unwrap_or_default();
1906 let first = cmd.split_whitespace().next().unwrap_or_default();
1907 if cmd.contains("hook rewrite") && cmd_matches_expected(first, binary) {
1908 saw_rewrite = true;
1909 }
1910 if cmd.contains("hook redirect") && cmd_matches_expected(first, binary) {
1911 saw_redirect = true;
1912 }
1913 }
1914 }
1915 saw_rewrite && saw_redirect
1916 });
1917
1918 let scripts_ok = home
1919 .join(".gemini")
1920 .join("hooks")
1921 .join("lean-ctx-rewrite-gemini.sh")
1922 .exists()
1923 && home
1924 .join(".gemini")
1925 .join("hooks")
1926 .join("lean-ctx-redirect-gemini.sh")
1927 .exists();
1928
1929 let ok = trust_ok && hooks_ok && scripts_ok;
1930 NamedCheck {
1931 name: "Gemini hooks".to_string(),
1932 ok,
1933 detail: if ok {
1934 format!("ok ({})", settings.display())
1935 } else {
1936 "drift (hooks/trust/scripts)".to_string()
1937 },
1938 }
1939}
1940
1941fn check_cursor_hooks(path: &std::path::Path) -> NamedCheck {
1942 if !path.exists() {
1943 return NamedCheck {
1944 name: "Hooks".to_string(),
1945 ok: false,
1946 detail: format!("missing ({})", path.display()),
1947 };
1948 }
1949 let content = std::fs::read_to_string(path).unwrap_or_default();
1950 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1951 let Some(v) = parsed else {
1952 return NamedCheck {
1953 name: "Hooks".to_string(),
1954 ok: false,
1955 detail: format!("invalid JSON ({})", path.display()),
1956 };
1957 };
1958 let pre = v
1959 .get("hooks")
1960 .and_then(|h| h.get("preToolUse"))
1961 .and_then(|x| x.as_array())
1962 .cloned()
1963 .unwrap_or_default();
1964 let has_rewrite = pre.iter().any(|e| {
1965 e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
1966 && e.get("command")
1967 .and_then(|c| c.as_str())
1968 .is_some_and(|c| c.contains(" hook rewrite"))
1969 });
1970 let has_redirect = pre.iter().any(|e| {
1971 matches!(
1972 e.get("matcher").and_then(|m| m.as_str()),
1973 Some("Read|Grep" | "Read" | "Grep")
1974 ) && e
1975 .get("command")
1976 .and_then(|c| c.as_str())
1977 .is_some_and(|c| c.contains(" hook redirect"))
1978 });
1979 NamedCheck {
1980 name: "Hooks".to_string(),
1981 ok: has_rewrite && has_redirect,
1982 detail: if has_rewrite && has_redirect {
1983 format!("ok ({})", path.display())
1984 } else {
1985 format!("drift ({})", path.display())
1986 },
1987 }
1988}
1989
1990fn check_claude_hooks(path: &std::path::Path) -> NamedCheck {
1991 if !path.exists() {
1992 return NamedCheck {
1993 name: "Hooks".to_string(),
1994 ok: false,
1995 detail: format!("missing ({})", path.display()),
1996 };
1997 }
1998 let content = std::fs::read_to_string(path).unwrap_or_default();
1999 let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
2000 let Some(v) = parsed else {
2001 return NamedCheck {
2002 name: "Hooks".to_string(),
2003 ok: false,
2004 detail: format!("invalid JSON ({})", path.display()),
2005 };
2006 };
2007 let pre = v
2008 .get("hooks")
2009 .and_then(|h| h.get("PreToolUse"))
2010 .and_then(|x| x.as_array())
2011 .cloned()
2012 .unwrap_or_default();
2013 let joined = serde_json::to_string(&pre).unwrap_or_default();
2014 let ok = joined.contains(" hook rewrite") && joined.contains(" hook redirect");
2015 NamedCheck {
2016 name: "Hooks".to_string(),
2017 ok,
2018 detail: if ok {
2019 format!("ok ({})", path.display())
2020 } else {
2021 format!("drift ({})", path.display())
2022 },
2023 }
2024}
2025
2026struct DoctorFixOptions {
2027 json: bool,
2028}
2029
2030fn run_fix(opts: &DoctorFixOptions) -> Result<i32, String> {
2031 use crate::core::setup_report::{
2032 doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
2033 };
2034
2035 let _quiet_guard = opts
2036 .json
2037 .then(|| crate::setup::EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
2038 let started_at = Utc::now();
2039 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
2040
2041 let mut steps: Vec<SetupStepReport> = Vec::new();
2042
2043 let mut shell_step = SetupStepReport {
2045 name: "shell_hook".to_string(),
2046 ok: true,
2047 items: Vec::new(),
2048 warnings: Vec::new(),
2049 errors: Vec::new(),
2050 };
2051 let before = shell_aliases_outcome();
2052 if before.ok {
2053 shell_step.items.push(SetupItem {
2054 name: "init --global".to_string(),
2055 status: "already".to_string(),
2056 path: None,
2057 note: None,
2058 });
2059 } else {
2060 if opts.json {
2061 crate::cli::cmd_init_quiet(&["--global".to_string()]);
2062 } else {
2063 crate::cli::cmd_init(&["--global".to_string()]);
2064 }
2065 let after = shell_aliases_outcome();
2066 shell_step.ok = after.ok;
2067 shell_step.items.push(SetupItem {
2068 name: "init --global".to_string(),
2069 status: if after.ok {
2070 "fixed".to_string()
2071 } else {
2072 "failed".to_string()
2073 },
2074 path: None,
2075 note: if after.ok {
2076 None
2077 } else {
2078 Some("shell hook still not detected by doctor checks".to_string())
2079 },
2080 });
2081 if !after.ok {
2082 shell_step
2083 .warnings
2084 .push("shell hook not detected after init --global".to_string());
2085 }
2086 }
2087 steps.push(shell_step);
2088
2089 let mut mcp_step = SetupStepReport {
2091 name: "mcp_config".to_string(),
2092 ok: true,
2093 items: Vec::new(),
2094 warnings: Vec::new(),
2095 errors: Vec::new(),
2096 };
2097 let binary = crate::core::portable_binary::resolve_portable_binary();
2098 let targets = crate::core::editor_registry::build_targets(&home);
2099 for t in &targets {
2100 if !t.detect_path.exists() {
2101 continue;
2102 }
2103 let short = t.config_path.to_string_lossy().to_string();
2104
2105 let mode = if t.agent_key.is_empty() {
2106 crate::hooks::HookMode::Mcp
2107 } else {
2108 crate::hooks::recommend_hook_mode(&t.agent_key)
2109 };
2110
2111 let res = if mode == crate::hooks::HookMode::CliRedirect {
2112 crate::core::editor_registry::remove_lean_ctx_server(
2113 t,
2114 crate::core::editor_registry::WriteOptions {
2115 overwrite_invalid: true,
2116 },
2117 )
2118 } else {
2119 crate::core::editor_registry::write_config_with_options(
2120 t,
2121 &binary,
2122 crate::core::editor_registry::WriteOptions {
2123 overwrite_invalid: true,
2124 },
2125 )
2126 };
2127
2128 match res {
2129 Ok(r) => {
2130 let status = match r.action {
2131 crate::core::editor_registry::WriteAction::Created => "created",
2132 crate::core::editor_registry::WriteAction::Updated => "updated",
2133 crate::core::editor_registry::WriteAction::Already => "already",
2134 };
2135 let note_parts: Vec<String> = [Some(format!("mode={mode}")), r.note]
2136 .into_iter()
2137 .flatten()
2138 .collect();
2139 mcp_step.items.push(SetupItem {
2140 name: t.name.to_string(),
2141 status: status.to_string(),
2142 path: Some(short),
2143 note: Some(note_parts.join("; ")),
2144 });
2145 }
2146 Err(e) => {
2147 mcp_step.ok = false;
2148 mcp_step.items.push(SetupItem {
2149 name: t.name.to_string(),
2150 status: "error".to_string(),
2151 path: Some(short),
2152 note: Some(e.clone()),
2153 });
2154 mcp_step.errors.push(format!("{}: {e}", t.name));
2155 }
2156 }
2157 }
2158 if mcp_step.items.is_empty() {
2159 mcp_step
2160 .warnings
2161 .push("no supported AI tools detected; skipped MCP config repair".to_string());
2162 }
2163 steps.push(mcp_step);
2164
2165 let mut rules_step = SetupStepReport {
2167 name: "agent_rules".to_string(),
2168 ok: true,
2169 items: Vec::new(),
2170 warnings: Vec::new(),
2171 errors: Vec::new(),
2172 };
2173 let inj = crate::rules_inject::inject_all_rules(&home);
2174 if !inj.injected.is_empty() {
2175 rules_step.items.push(SetupItem {
2176 name: "injected".to_string(),
2177 status: inj.injected.len().to_string(),
2178 path: None,
2179 note: Some(inj.injected.join(", ")),
2180 });
2181 }
2182 if !inj.updated.is_empty() {
2183 rules_step.items.push(SetupItem {
2184 name: "updated".to_string(),
2185 status: inj.updated.len().to_string(),
2186 path: None,
2187 note: Some(inj.updated.join(", ")),
2188 });
2189 }
2190 if !inj.already.is_empty() {
2191 rules_step.items.push(SetupItem {
2192 name: "already".to_string(),
2193 status: inj.already.len().to_string(),
2194 path: None,
2195 note: Some(inj.already.join(", ")),
2196 });
2197 }
2198 if !inj.errors.is_empty() {
2199 rules_step.ok = false;
2200 rules_step.errors.extend(inj.errors.clone());
2201 }
2202 steps.push(rules_step);
2203
2204 let mut hooks_step = SetupStepReport {
2206 name: "agent_hooks".to_string(),
2207 ok: true,
2208 items: Vec::new(),
2209 warnings: Vec::new(),
2210 errors: Vec::new(),
2211 };
2212 let targets = crate::core::editor_registry::build_targets(&home);
2213 for t in &targets {
2214 if !t.detect_path.exists() || t.agent_key.trim().is_empty() {
2215 continue;
2216 }
2217 let mode = crate::hooks::recommend_hook_mode(&t.agent_key);
2218 crate::hooks::install_agent_hook_with_mode(&t.agent_key, true, mode);
2219 hooks_step.items.push(SetupItem {
2220 name: format!("{} hooks", t.name),
2221 status: "installed".to_string(),
2222 path: Some(t.detect_path.to_string_lossy().to_string()),
2223 note: Some(format!("mode={mode}; merge-based install/repair")),
2224 });
2225 }
2226 if !hooks_step.items.is_empty() {
2227 steps.push(hooks_step);
2228 }
2229
2230 let mut skill_step = SetupStepReport {
2232 name: "skill_files".to_string(),
2233 ok: true,
2234 items: Vec::new(),
2235 warnings: Vec::new(),
2236 errors: Vec::new(),
2237 };
2238 let skill_result = crate::setup::install_skill_files(&home);
2239 for (name, installed) in &skill_result {
2240 skill_step.items.push(SetupItem {
2241 name: name.clone(),
2242 status: if *installed {
2243 "installed".to_string()
2244 } else {
2245 "already".to_string()
2246 },
2247 path: None,
2248 note: Some("SKILL.md".to_string()),
2249 });
2250 }
2251 if !skill_result.is_empty() {
2252 steps.push(skill_step);
2253 }
2254
2255 let mut verify_step = SetupStepReport {
2257 name: "verify".to_string(),
2258 ok: true,
2259 items: Vec::new(),
2260 warnings: Vec::new(),
2261 errors: Vec::new(),
2262 };
2263 let (passed, total) = compact_score();
2264 verify_step.items.push(SetupItem {
2265 name: "doctor_compact".to_string(),
2266 status: format!("{passed}/{total}"),
2267 path: None,
2268 note: None,
2269 });
2270 if passed != total {
2271 verify_step.warnings.push(format!(
2272 "doctor compact not fully passing: {passed}/{total}"
2273 ));
2274 }
2275 steps.push(verify_step);
2276
2277 let finished_at = Utc::now();
2278 let success = steps.iter().all(|s| s.ok);
2279
2280 let report = SetupReport {
2281 schema_version: 1,
2282 started_at,
2283 finished_at,
2284 success,
2285 platform: PlatformInfo {
2286 os: std::env::consts::OS.to_string(),
2287 arch: std::env::consts::ARCH.to_string(),
2288 },
2289 steps,
2290 warnings: Vec::new(),
2291 errors: Vec::new(),
2292 };
2293
2294 let path = doctor_report_path()?;
2295 let json_text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
2296 crate::config_io::write_atomic_with_backup(&path, &json_text)?;
2297
2298 if opts.json {
2299 println!("{json_text}");
2300 } else {
2301 let (passed, total) = compact_score();
2302 print_compact_status(passed, total);
2303 println!(" {DIM}report saved:{RST} {}", path.display());
2304 }
2305
2306 Ok(i32::from(!report.success))
2307}
2308
2309pub fn compact_score() -> (u32, u32) {
2310 let mut passed = 0u32;
2311 let total = 6u32;
2312
2313 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
2314 passed += 1;
2315 }
2316 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
2317 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
2318 passed += 1;
2319 }
2320 if lean_dir
2321 .as_ref()
2322 .map(|d| d.join("stats.json"))
2323 .and_then(|p| std::fs::metadata(p).ok())
2324 .is_some_and(|m| m.is_file())
2325 {
2326 passed += 1;
2327 }
2328 if shell_aliases_outcome().ok {
2329 passed += 1;
2330 }
2331 if mcp_config_outcome().ok {
2332 passed += 1;
2333 }
2334 if skill_files_outcome().ok {
2335 passed += 1;
2336 }
2337
2338 (passed, total)
2339}
2340
2341fn print_compact_status(passed: u32, total: u32) {
2342 let status = if passed == total {
2343 format!("{GREEN}✓ All {total} checks passed{RST}")
2344 } else {
2345 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
2346 };
2347 println!(" {status}");
2348}