1use std::net::TcpListener;
4use std::path::PathBuf;
5
6use chrono::Utc;
7
8const GREEN: &str = "\x1b[32m";
9const RED: &str = "\x1b[31m";
10const BOLD: &str = "\x1b[1m";
11const RST: &str = "\x1b[0m";
12const DIM: &str = "\x1b[2m";
13const WHITE: &str = "\x1b[97m";
14const YELLOW: &str = "\x1b[33m";
15
16struct Outcome {
17 ok: bool,
18 line: String,
19}
20
21fn print_check(outcome: &Outcome) {
22 let mark = if outcome.ok {
23 format!("{GREEN}✓{RST}")
24 } else {
25 format!("{RED}✗{RST}")
26 };
27 println!(" {mark} {}", outcome.line);
28}
29
30fn path_in_path_env() -> bool {
31 if let Ok(path) = std::env::var("PATH") {
32 for dir in std::env::split_paths(&path) {
33 if dir.join("lean-ctx").is_file() {
34 return true;
35 }
36 if cfg!(windows)
37 && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
38 {
39 return true;
40 }
41 }
42 }
43 false
44}
45
46fn resolve_lean_ctx_binary() -> Option<PathBuf> {
47 if let Ok(path) = std::env::var("PATH") {
48 for dir in std::env::split_paths(&path) {
49 if cfg!(windows) {
50 let exe = dir.join("lean-ctx.exe");
51 if exe.is_file() {
52 return Some(exe);
53 }
54 let cmd = dir.join("lean-ctx.cmd");
55 if cmd.is_file() {
56 return Some(cmd);
57 }
58 } else {
59 let bin = dir.join("lean-ctx");
60 if bin.is_file() {
61 return Some(bin);
62 }
63 }
64 }
65 }
66 None
67}
68
69fn lean_ctx_version_from_path() -> Outcome {
70 let resolved = resolve_lean_ctx_binary();
71 let bin = resolved
72 .clone()
73 .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
74
75 let v = env!("CARGO_PKG_VERSION");
76 let note = match std::env::current_exe() {
77 Ok(exe) if exe == bin => format!("{DIM}(this binary){RST}"),
78 Ok(_) => format!("{DIM}(resolved: {}){RST}", bin.display()),
79 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_has_pipe_guard(path: &PathBuf) -> bool {
101 match std::fs::read_to_string(path) {
102 Ok(s) => {
103 if has_pipe_guard_in_content(&s) {
104 return true;
105 }
106 if s.contains(".lean-ctx/shell-hook.") {
107 if let Some(home) = dirs::home_dir() {
108 for ext in &["zsh", "bash", "fish", "ps1"] {
109 let hook = home.join(format!(".lean-ctx/shell-hook.{ext}"));
110 if let Ok(h) = std::fs::read_to_string(&hook) {
111 if has_pipe_guard_in_content(&h) {
112 return true;
113 }
114 }
115 }
116 }
117 }
118 false
119 }
120 Err(_) => false,
121 }
122}
123
124fn is_active_shell(rc_name: &str) -> bool {
125 let shell = std::env::var("SHELL").unwrap_or_default();
126 match rc_name {
127 "~/.zshrc" => shell.contains("zsh"),
128 "~/.bashrc" => shell.contains("bash") || shell.is_empty(),
129 "~/.config/fish/config.fish" => shell.contains("fish"),
130 _ => true,
131 }
132}
133
134fn shell_aliases_outcome() -> Outcome {
135 let home = match dirs::home_dir() {
136 Some(h) => h,
137 None => {
138 return Outcome {
139 ok: false,
140 line: format!(
141 "{BOLD}Shell aliases{RST} {RED}could not resolve home directory{RST}"
142 ),
143 };
144 }
145 };
146
147 let mut parts = Vec::new();
148 let mut needs_update = Vec::new();
149
150 let zsh = home.join(".zshrc");
151 if rc_contains_lean_ctx(&zsh) {
152 parts.push(format!("{DIM}~/.zshrc{RST}"));
153 if !rc_has_pipe_guard(&zsh) && is_active_shell("~/.zshrc") {
154 needs_update.push("~/.zshrc");
155 }
156 }
157 let bash = home.join(".bashrc");
158 if rc_contains_lean_ctx(&bash) {
159 parts.push(format!("{DIM}~/.bashrc{RST}"));
160 if !rc_has_pipe_guard(&bash) && is_active_shell("~/.bashrc") {
161 needs_update.push("~/.bashrc");
162 }
163 }
164
165 let fish = home.join(".config").join("fish").join("config.fish");
166 if rc_contains_lean_ctx(&fish) {
167 parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
168 if !rc_has_pipe_guard(&fish) && is_active_shell("~/.config/fish/config.fish") {
169 needs_update.push("~/.config/fish/config.fish");
170 }
171 }
172
173 #[cfg(windows)]
174 {
175 let ps_profile = home
176 .join("Documents")
177 .join("PowerShell")
178 .join("Microsoft.PowerShell_profile.ps1");
179 let ps_profile_legacy = home
180 .join("Documents")
181 .join("WindowsPowerShell")
182 .join("Microsoft.PowerShell_profile.ps1");
183 if rc_contains_lean_ctx(&ps_profile) {
184 parts.push(format!("{DIM}PowerShell profile{RST}"));
185 if !rc_has_pipe_guard(&ps_profile) {
186 needs_update.push("PowerShell profile");
187 }
188 } else if rc_contains_lean_ctx(&ps_profile_legacy) {
189 parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
190 if !rc_has_pipe_guard(&ps_profile_legacy) {
191 needs_update.push("WindowsPowerShell profile");
192 }
193 }
194 }
195
196 if parts.is_empty() {
197 let hint = if cfg!(windows) {
198 "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
199 } else {
200 "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
201 };
202 Outcome {
203 ok: false,
204 line: format!("{BOLD}Shell aliases{RST} {RED}{hint}{RST}"),
205 }
206 } else if !needs_update.is_empty() {
207 Outcome {
208 ok: false,
209 line: format!(
210 "{BOLD}Shell aliases{RST} {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
211 needs_update.join(", ")
212 ),
213 }
214 } else {
215 Outcome {
216 ok: true,
217 line: format!(
218 "{BOLD}Shell aliases{RST} {GREEN}lean-ctx referenced in {}{RST}",
219 parts.join(", ")
220 ),
221 }
222 }
223}
224
225struct McpLocation {
226 name: &'static str,
227 display: String,
228 path: PathBuf,
229}
230
231fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
232 let mut locations = vec![
233 McpLocation {
234 name: "Cursor",
235 display: "~/.cursor/mcp.json".into(),
236 path: home.join(".cursor").join("mcp.json"),
237 },
238 McpLocation {
239 name: "Claude Code",
240 display: format!(
241 "{}",
242 crate::core::editor_registry::claude_mcp_json_path(home).display()
243 ),
244 path: crate::core::editor_registry::claude_mcp_json_path(home),
245 },
246 McpLocation {
247 name: "Windsurf",
248 display: "~/.codeium/windsurf/mcp_config.json".into(),
249 path: home
250 .join(".codeium")
251 .join("windsurf")
252 .join("mcp_config.json"),
253 },
254 McpLocation {
255 name: "Codex",
256 display: "~/.codex/config.toml".into(),
257 path: home.join(".codex").join("config.toml"),
258 },
259 McpLocation {
260 name: "Gemini CLI",
261 display: "~/.gemini/settings/mcp.json".into(),
262 path: home.join(".gemini").join("settings").join("mcp.json"),
263 },
264 McpLocation {
265 name: "Antigravity",
266 display: "~/.gemini/antigravity/mcp_config.json".into(),
267 path: home
268 .join(".gemini")
269 .join("antigravity")
270 .join("mcp_config.json"),
271 },
272 ];
273
274 #[cfg(unix)]
275 {
276 let zed_cfg = home.join(".config").join("zed").join("settings.json");
277 locations.push(McpLocation {
278 name: "Zed",
279 display: "~/.config/zed/settings.json".into(),
280 path: zed_cfg,
281 });
282 }
283
284 locations.push(McpLocation {
285 name: "Qwen Code",
286 display: "~/.qwen/mcp.json".into(),
287 path: home.join(".qwen").join("mcp.json"),
288 });
289 locations.push(McpLocation {
290 name: "Trae",
291 display: "~/.trae/mcp.json".into(),
292 path: home.join(".trae").join("mcp.json"),
293 });
294 locations.push(McpLocation {
295 name: "Amazon Q",
296 display: "~/.aws/amazonq/mcp.json".into(),
297 path: home.join(".aws").join("amazonq").join("mcp.json"),
298 });
299 locations.push(McpLocation {
300 name: "JetBrains",
301 display: "~/.jb-mcp.json".into(),
302 path: home.join(".jb-mcp.json"),
303 });
304 locations.push(McpLocation {
305 name: "AWS Kiro",
306 display: "~/.kiro/settings/mcp.json".into(),
307 path: home.join(".kiro").join("settings").join("mcp.json"),
308 });
309 locations.push(McpLocation {
310 name: "Verdent",
311 display: "~/.verdent/mcp.json".into(),
312 path: home.join(".verdent").join("mcp.json"),
313 });
314 locations.push(McpLocation {
315 name: "Crush",
316 display: "~/.config/crush/crush.json".into(),
317 path: home.join(".config").join("crush").join("crush.json"),
318 });
319 locations.push(McpLocation {
320 name: "Pi",
321 display: "~/.pi/agent/mcp.json".into(),
322 path: home.join(".pi").join("agent").join("mcp.json"),
323 });
324 locations.push(McpLocation {
325 name: "Aider",
326 display: "~/.aider/mcp.json".into(),
327 path: home.join(".aider").join("mcp.json"),
328 });
329 locations.push(McpLocation {
330 name: "Amp",
331 display: "~/.config/amp/settings.json".into(),
332 path: home.join(".config").join("amp").join("settings.json"),
333 });
334
335 {
336 #[cfg(unix)]
337 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
338 #[cfg(unix)]
339 let opencode_display = "~/.config/opencode/opencode.json";
340
341 #[cfg(windows)]
342 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
343 std::path::PathBuf::from(appdata)
344 .join("opencode")
345 .join("opencode.json")
346 } else {
347 home.join(".config").join("opencode").join("opencode.json")
348 };
349 #[cfg(windows)]
350 let opencode_display = "%APPDATA%/opencode/opencode.json";
351
352 locations.push(McpLocation {
353 name: "OpenCode",
354 display: opencode_display.into(),
355 path: opencode_cfg,
356 });
357 }
358
359 #[cfg(target_os = "macos")]
360 {
361 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
362 locations.push(McpLocation {
363 name: "VS Code / Copilot",
364 display: "~/Library/Application Support/Code/User/mcp.json".into(),
365 path: vscode_mcp,
366 });
367 }
368 #[cfg(target_os = "linux")]
369 {
370 let vscode_mcp = home.join(".config/Code/User/mcp.json");
371 locations.push(McpLocation {
372 name: "VS Code / Copilot",
373 display: "~/.config/Code/User/mcp.json".into(),
374 path: vscode_mcp,
375 });
376 }
377 #[cfg(target_os = "windows")]
378 {
379 if let Ok(appdata) = std::env::var("APPDATA") {
380 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
381 locations.push(McpLocation {
382 name: "VS Code / Copilot",
383 display: "%APPDATA%/Code/User/mcp.json".into(),
384 path: vscode_mcp,
385 });
386 }
387 }
388
389 locations.push(McpLocation {
390 name: "Hermes Agent",
391 display: "~/.hermes/config.yaml".into(),
392 path: home.join(".hermes").join("config.yaml"),
393 });
394
395 {
396 let cline_path = crate::core::editor_registry::cline_mcp_path();
397 if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
398 locations.push(McpLocation {
399 name: "Cline",
400 display: cline_path.display().to_string(),
401 path: cline_path,
402 });
403 }
404 }
405 {
406 let roo_path = crate::core::editor_registry::roo_mcp_path();
407 if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
408 locations.push(McpLocation {
409 name: "Roo Code",
410 display: roo_path.display().to_string(),
411 path: roo_path,
412 });
413 }
414 }
415
416 locations
417}
418
419fn mcp_config_outcome() -> Outcome {
420 let home = match dirs::home_dir() {
421 Some(h) => h,
422 None => {
423 return Outcome {
424 ok: false,
425 line: format!("{BOLD}MCP config{RST} {RED}could not resolve home directory{RST}"),
426 };
427 }
428 };
429
430 let locations = mcp_config_locations(&home);
431 let mut found: Vec<String> = Vec::new();
432 let mut exists_no_ref: Vec<String> = Vec::new();
433
434 for loc in &locations {
435 if let Ok(content) = std::fs::read_to_string(&loc.path) {
436 if has_lean_ctx_mcp_entry(&content) {
437 found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
438 } else {
439 exists_no_ref.push(loc.name.to_string());
440 }
441 }
442 }
443
444 found.sort();
445 found.dedup();
446 exists_no_ref.sort();
447 exists_no_ref.dedup();
448
449 if !found.is_empty() {
450 Outcome {
451 ok: true,
452 line: format!(
453 "{BOLD}MCP config{RST} {GREEN}lean-ctx found in: {}{RST}",
454 found.join(", ")
455 ),
456 }
457 } else if !exists_no_ref.is_empty() {
458 let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
459 let cause = if has_claude {
460 format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
461 } else {
462 String::new()
463 };
464 let hint = if has_claude {
465 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
466 } else {
467 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
468 };
469 Outcome {
470 ok: false,
471 line: format!(
472 "{BOLD}MCP config{RST} {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST} {cause} {hint}",
473 exists_no_ref.join(", "),
474 ),
475 }
476 } else {
477 Outcome {
478 ok: false,
479 line: format!(
480 "{BOLD}MCP config{RST} {YELLOW}no MCP config found{RST} {DIM}(run: lean-ctx setup){RST}"
481 ),
482 }
483 }
484}
485
486fn has_lean_ctx_mcp_entry(content: &str) -> bool {
487 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
488 if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
489 return servers.contains_key("lean-ctx");
490 }
491 if let Some(servers) = json
492 .get("mcp")
493 .and_then(|v| v.get("servers"))
494 .and_then(|v| v.as_object())
495 {
496 return servers.contains_key("lean-ctx");
497 }
498 }
499 content.contains("lean-ctx")
500}
501
502fn port_3333_outcome() -> Outcome {
503 match TcpListener::bind("127.0.0.1:3333") {
504 Ok(_listener) => Outcome {
505 ok: true,
506 line: format!("{BOLD}Dashboard port 3333{RST} {GREEN}available on 127.0.0.1{RST}"),
507 },
508 Err(e) => Outcome {
509 ok: false,
510 line: format!("{BOLD}Dashboard port 3333{RST} {RED}not available: {e}{RST}"),
511 },
512 }
513}
514
515fn pi_outcome() -> Option<Outcome> {
516 let pi_result = std::process::Command::new("pi").arg("--version").output();
517
518 match pi_result {
519 Ok(output) if output.status.success() => {
520 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
521 let has_plugin = std::process::Command::new("pi")
522 .args(["list"])
523 .output()
524 .map(|o| String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx"))
525 .unwrap_or(false);
526
527 let has_mcp = dirs::home_dir()
528 .map(|h| h.join(".pi/agent/mcp.json"))
529 .and_then(|p| std::fs::read_to_string(p).ok())
530 .map(|c| c.contains("lean-ctx"))
531 .unwrap_or(false);
532
533 if has_plugin && has_mcp {
534 Some(Outcome {
535 ok: true,
536 line: format!(
537 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
538 ),
539 })
540 } else if has_plugin {
541 Some(Outcome {
542 ok: true,
543 line: format!(
544 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx installed{RST} {DIM}(MCP not configured — embedded bridge active){RST}"
545 ),
546 })
547 } else {
548 Some(Outcome {
549 ok: false,
550 line: format!(
551 "{BOLD}Pi Coding Agent{RST} {YELLOW}{version}, but pi-lean-ctx not installed{RST} {DIM}(run: pi install npm:pi-lean-ctx){RST}"
552 ),
553 })
554 }
555 }
556 _ => None,
557 }
558}
559
560fn session_state_outcome() -> Outcome {
561 use crate::core::session::SessionState;
562
563 match SessionState::load_latest() {
564 Some(session) => {
565 let root = session
566 .project_root
567 .as_deref()
568 .unwrap_or("(not set)");
569 let cwd = session
570 .shell_cwd
571 .as_deref()
572 .unwrap_or("(not tracked)");
573 Outcome {
574 ok: true,
575 line: format!(
576 "{BOLD}Session state{RST} {GREEN}active{RST} {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
577 session.version
578 ),
579 }
580 }
581 None => Outcome {
582 ok: true,
583 line: format!(
584 "{BOLD}Session state{RST} {YELLOW}no active session{RST} {DIM}(will be created on first tool call){RST}"
585 ),
586 },
587 }
588}
589
590fn docker_env_outcomes() -> Vec<Outcome> {
591 if !crate::shell::is_container() {
592 return vec![];
593 }
594 let env_sh = crate::core::data_dir::lean_ctx_data_dir()
595 .map(|d| d.join("env.sh").to_string_lossy().to_string())
596 .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
597
598 let mut outcomes = vec![];
599
600 let shell_name = std::env::var("SHELL").unwrap_or_default();
601 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
602
603 if is_bash {
604 let has_bash_env = std::env::var("BASH_ENV").is_ok();
605 outcomes.push(if has_bash_env {
606 Outcome {
607 ok: true,
608 line: format!(
609 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
610 std::env::var("BASH_ENV").unwrap_or_default()
611 ),
612 }
613 } else {
614 Outcome {
615 ok: false,
616 line: format!(
617 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
618 ),
619 }
620 });
621 }
622
623 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
624 outcomes.push(if has_claude_env {
625 Outcome {
626 ok: true,
627 line: format!(
628 "{BOLD}CLAUDE_ENV_FILE{RST} {GREEN}set{RST} {DIM}({}){RST}",
629 std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
630 ),
631 }
632 } else {
633 Outcome {
634 ok: false,
635 line: format!(
636 "{BOLD}CLAUDE_ENV_FILE{RST} {RED}not set{RST} {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
637 ),
638 }
639 });
640
641 outcomes
642}
643
644pub fn run() {
646 let mut passed = 0u32;
647 let total = 8u32;
648
649 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
650
651 let path_bin = resolve_lean_ctx_binary();
653 let also_in_path_dirs = path_in_path_env();
654 let bin_ok = path_bin.is_some() || also_in_path_dirs;
655 if bin_ok {
656 passed += 1;
657 }
658 let bin_line = if let Some(p) = path_bin {
659 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
660 } else if also_in_path_dirs {
661 format!(
662 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
663 )
664 } else {
665 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
666 };
667 print_check(&Outcome {
668 ok: bin_ok,
669 line: bin_line,
670 });
671
672 let ver = if bin_ok {
674 lean_ctx_version_from_path()
675 } else {
676 Outcome {
677 ok: false,
678 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
679 }
680 };
681 if ver.ok {
682 passed += 1;
683 }
684 print_check(&ver);
685
686 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
688 let dir_outcome = match &lean_dir {
689 Some(p) if p.is_dir() => {
690 passed += 1;
691 Outcome {
692 ok: true,
693 line: format!(
694 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
695 p.display()
696 ),
697 }
698 }
699 Some(p) => Outcome {
700 ok: false,
701 line: format!(
702 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
703 p.display()
704 ),
705 },
706 None => Outcome {
707 ok: false,
708 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
709 },
710 };
711 print_check(&dir_outcome);
712
713 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
715 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
716 Some(m) if m.is_file() => {
717 passed += 1;
718 let size = m.len();
719 Outcome {
720 ok: true,
721 line: format!(
722 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{}{RST}",
723 stats_path.as_ref().unwrap().display()
724 ),
725 }
726 }
727 Some(_m) => Outcome {
728 ok: false,
729 line: format!(
730 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{}{RST}",
731 stats_path.as_ref().unwrap().display()
732 ),
733 },
734 None => {
735 passed += 1;
736 Outcome {
737 ok: true,
738 line: match &stats_path {
739 Some(p) => format!(
740 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
741 p.display()
742 ),
743 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
744 },
745 }
746 }
747 };
748 print_check(&stats_outcome);
749
750 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
752 let config_outcome = match &config_path {
753 Some(p) => match std::fs::metadata(p) {
754 Ok(m) if m.is_file() => {
755 passed += 1;
756 Outcome {
757 ok: true,
758 line: format!(
759 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
760 p.display()
761 ),
762 }
763 }
764 Ok(_) => Outcome {
765 ok: false,
766 line: format!(
767 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
768 p.display()
769 ),
770 },
771 Err(_) => {
772 passed += 1;
773 Outcome {
774 ok: true,
775 line: format!(
776 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
777 p.display()
778 ),
779 }
780 }
781 },
782 None => Outcome {
783 ok: false,
784 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
785 },
786 };
787 print_check(&config_outcome);
788
789 let aliases = shell_aliases_outcome();
791 if aliases.ok {
792 passed += 1;
793 }
794 print_check(&aliases);
795
796 let mcp = mcp_config_outcome();
798 if mcp.ok {
799 passed += 1;
800 }
801 print_check(&mcp);
802
803 let port = port_3333_outcome();
805 if port.ok {
806 passed += 1;
807 }
808 print_check(&port);
809
810 let session_outcome = session_state_outcome();
812 if session_outcome.ok {
813 passed += 1;
814 }
815 print_check(&session_outcome);
816
817 let docker_outcomes = docker_env_outcomes();
819 for docker_check in &docker_outcomes {
820 if docker_check.ok {
821 passed += 1;
822 }
823 print_check(docker_check);
824 }
825
826 let pi = pi_outcome();
828 if let Some(ref pi_check) = pi {
829 if pi_check.ok {
830 passed += 1;
831 }
832 print_check(pi_check);
833 }
834
835 let integrity = crate::core::integrity::check();
837 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
838 if integrity_ok {
839 passed += 1;
840 }
841 let integrity_line = if integrity_ok {
842 format!(
843 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
844 integrity.repo
845 )
846 } else {
847 format!(
848 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
849 integrity.pkg_name, integrity.repo
850 )
851 };
852 print_check(&Outcome {
853 ok: integrity_ok,
854 line: integrity_line,
855 });
856
857 let claude_truncation = claude_truncation_outcome();
859 if let Some(ref ct) = claude_truncation {
860 if ct.ok {
861 passed += 1;
862 }
863 print_check(ct);
864 }
865
866 let mut effective_total = total + 2; effective_total += docker_outcomes.len() as u32;
868 if pi.is_some() {
869 effective_total += 1;
870 }
871 if claude_truncation.is_some() {
872 effective_total += 1;
873 }
874 println!();
875 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
876 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
877}
878
879fn claude_binary_exists() -> bool {
880 #[cfg(unix)]
881 {
882 std::process::Command::new("which")
883 .arg("claude")
884 .output()
885 .map(|o| o.status.success())
886 .unwrap_or(false)
887 }
888 #[cfg(windows)]
889 {
890 std::process::Command::new("where")
891 .arg("claude")
892 .output()
893 .map(|o| o.status.success())
894 .unwrap_or(false)
895 }
896}
897
898fn claude_truncation_outcome() -> Option<Outcome> {
899 let home = dirs::home_dir()?;
900 let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
901 || crate::core::editor_registry::claude_state_dir(&home).exists()
902 || claude_binary_exists();
903
904 if !claude_detected {
905 return None;
906 }
907
908 let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
909 let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
910
911 let has_rules = rules_path.exists();
912 let has_skill = skill_path.exists();
913
914 if has_rules && has_skill {
915 Some(Outcome {
916 ok: true,
917 line: format!(
918 "{BOLD}Claude Code instructions{RST} {GREEN}rules + skill installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
919 ),
920 })
921 } else if has_rules {
922 Some(Outcome {
923 ok: true,
924 line: format!(
925 "{BOLD}Claude Code instructions{RST} {GREEN}rules file installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
926 ),
927 })
928 } else {
929 Some(Outcome {
930 ok: false,
931 line: format!(
932 "{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}"
933 ),
934 })
935 }
936}
937
938pub fn run_compact() {
939 let (passed, total) = compact_score();
940 print_compact_status(passed, total);
941}
942
943pub fn run_cli(args: &[String]) -> i32 {
944 let fix = args.iter().any(|a| a == "--fix");
945 let json = args.iter().any(|a| a == "--json");
946 let help = args.iter().any(|a| a == "--help" || a == "-h");
947
948 if help {
949 println!("Usage:");
950 println!(" lean-ctx doctor");
951 println!(" lean-ctx doctor --fix [--json]");
952 return 0;
953 }
954
955 if !fix {
956 run();
957 return 0;
958 }
959
960 match run_fix(DoctorFixOptions { json }) {
961 Ok(code) => code,
962 Err(e) => {
963 eprintln!("{RED}doctor --fix failed:{RST} {e}");
964 2
965 }
966 }
967}
968
969struct DoctorFixOptions {
970 json: bool,
971}
972
973fn run_fix(opts: DoctorFixOptions) -> Result<i32, String> {
974 use crate::core::setup_report::{
975 doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
976 };
977
978 let _quiet_guard = opts
979 .json
980 .then(|| crate::setup::EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
981 let started_at = Utc::now();
982 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
983
984 let mut steps: Vec<SetupStepReport> = Vec::new();
985
986 let mut shell_step = SetupStepReport {
988 name: "shell_hook".to_string(),
989 ok: true,
990 items: Vec::new(),
991 warnings: Vec::new(),
992 errors: Vec::new(),
993 };
994 let before = shell_aliases_outcome();
995 if before.ok {
996 shell_step.items.push(SetupItem {
997 name: "init --global".to_string(),
998 status: "already".to_string(),
999 path: None,
1000 note: None,
1001 });
1002 } else {
1003 if opts.json {
1004 crate::cli::cmd_init_quiet(&["--global".to_string()]);
1005 } else {
1006 crate::cli::cmd_init(&["--global".to_string()]);
1007 }
1008 let after = shell_aliases_outcome();
1009 shell_step.ok = after.ok;
1010 shell_step.items.push(SetupItem {
1011 name: "init --global".to_string(),
1012 status: if after.ok {
1013 "fixed".to_string()
1014 } else {
1015 "failed".to_string()
1016 },
1017 path: None,
1018 note: if after.ok {
1019 None
1020 } else {
1021 Some("shell hook still not detected by doctor checks".to_string())
1022 },
1023 });
1024 if !after.ok {
1025 shell_step
1026 .warnings
1027 .push("shell hook not detected after init --global".to_string());
1028 }
1029 }
1030 steps.push(shell_step);
1031
1032 let mut mcp_step = SetupStepReport {
1034 name: "mcp_config".to_string(),
1035 ok: true,
1036 items: Vec::new(),
1037 warnings: Vec::new(),
1038 errors: Vec::new(),
1039 };
1040 let binary = crate::core::portable_binary::resolve_portable_binary();
1041 let targets = crate::core::editor_registry::build_targets(&home);
1042 for t in &targets {
1043 if !t.detect_path.exists() {
1044 continue;
1045 }
1046 let short = t.config_path.to_string_lossy().to_string();
1047 let res = crate::core::editor_registry::write_config_with_options(
1048 t,
1049 &binary,
1050 crate::core::editor_registry::WriteOptions {
1051 overwrite_invalid: true,
1052 },
1053 );
1054 match res {
1055 Ok(r) => {
1056 let status = match r.action {
1057 crate::core::editor_registry::WriteAction::Created => "created",
1058 crate::core::editor_registry::WriteAction::Updated => "updated",
1059 crate::core::editor_registry::WriteAction::Already => "already",
1060 };
1061 mcp_step.items.push(SetupItem {
1062 name: t.name.to_string(),
1063 status: status.to_string(),
1064 path: Some(short),
1065 note: r.note,
1066 });
1067 }
1068 Err(e) => {
1069 mcp_step.ok = false;
1070 mcp_step.items.push(SetupItem {
1071 name: t.name.to_string(),
1072 status: "error".to_string(),
1073 path: Some(short),
1074 note: Some(e.clone()),
1075 });
1076 mcp_step.errors.push(format!("{}: {e}", t.name));
1077 }
1078 }
1079 }
1080 if mcp_step.items.is_empty() {
1081 mcp_step
1082 .warnings
1083 .push("no supported AI tools detected; skipped MCP config repair".to_string());
1084 }
1085 steps.push(mcp_step);
1086
1087 let mut rules_step = SetupStepReport {
1089 name: "agent_rules".to_string(),
1090 ok: true,
1091 items: Vec::new(),
1092 warnings: Vec::new(),
1093 errors: Vec::new(),
1094 };
1095 let inj = crate::rules_inject::inject_all_rules(&home);
1096 if !inj.injected.is_empty() {
1097 rules_step.items.push(SetupItem {
1098 name: "injected".to_string(),
1099 status: inj.injected.len().to_string(),
1100 path: None,
1101 note: Some(inj.injected.join(", ")),
1102 });
1103 }
1104 if !inj.updated.is_empty() {
1105 rules_step.items.push(SetupItem {
1106 name: "updated".to_string(),
1107 status: inj.updated.len().to_string(),
1108 path: None,
1109 note: Some(inj.updated.join(", ")),
1110 });
1111 }
1112 if !inj.already.is_empty() {
1113 rules_step.items.push(SetupItem {
1114 name: "already".to_string(),
1115 status: inj.already.len().to_string(),
1116 path: None,
1117 note: Some(inj.already.join(", ")),
1118 });
1119 }
1120 if !inj.errors.is_empty() {
1121 rules_step.ok = false;
1122 rules_step.errors.extend(inj.errors.clone());
1123 }
1124 steps.push(rules_step);
1125
1126 let mut verify_step = SetupStepReport {
1128 name: "verify".to_string(),
1129 ok: true,
1130 items: Vec::new(),
1131 warnings: Vec::new(),
1132 errors: Vec::new(),
1133 };
1134 let (passed, total) = compact_score();
1135 verify_step.items.push(SetupItem {
1136 name: "doctor_compact".to_string(),
1137 status: format!("{passed}/{total}"),
1138 path: None,
1139 note: None,
1140 });
1141 if passed != total {
1142 verify_step.warnings.push(format!(
1143 "doctor compact not fully passing: {passed}/{total}"
1144 ));
1145 }
1146 steps.push(verify_step);
1147
1148 let finished_at = Utc::now();
1149 let success = steps.iter().all(|s| s.ok);
1150
1151 let report = SetupReport {
1152 schema_version: 1,
1153 started_at,
1154 finished_at,
1155 success,
1156 platform: PlatformInfo {
1157 os: std::env::consts::OS.to_string(),
1158 arch: std::env::consts::ARCH.to_string(),
1159 },
1160 steps,
1161 warnings: Vec::new(),
1162 errors: Vec::new(),
1163 };
1164
1165 let path = doctor_report_path()?;
1166 let json_text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
1167 crate::config_io::write_atomic_with_backup(&path, &json_text)?;
1168
1169 if opts.json {
1170 println!("{json_text}");
1171 } else {
1172 let (passed, total) = compact_score();
1173 print_compact_status(passed, total);
1174 println!(" {DIM}report saved:{RST} {}", path.display());
1175 }
1176
1177 Ok(if report.success { 0 } else { 1 })
1178}
1179
1180pub fn compact_score() -> (u32, u32) {
1181 let mut passed = 0u32;
1182 let total = 5u32;
1183
1184 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1185 passed += 1;
1186 }
1187 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
1188 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1189 passed += 1;
1190 }
1191 if lean_dir
1192 .as_ref()
1193 .map(|d| d.join("stats.json"))
1194 .and_then(|p| std::fs::metadata(p).ok())
1195 .is_some_and(|m| m.is_file())
1196 {
1197 passed += 1;
1198 }
1199 if shell_aliases_outcome().ok {
1200 passed += 1;
1201 }
1202 if mcp_config_outcome().ok {
1203 passed += 1;
1204 }
1205
1206 (passed, total)
1207}
1208
1209fn print_compact_status(passed: u32, total: u32) {
1210 let status = if passed == total {
1211 format!("{GREEN}✓ All {total} checks passed{RST}")
1212 } else {
1213 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1214 };
1215 println!(" {status}");
1216}