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 rc_has_pipe_guard(path: &PathBuf) -> bool {
95 match std::fs::read_to_string(path) {
96 Ok(s) => {
97 s.contains("! -t 1") || s.contains("isatty stdout") || s.contains("IsOutputRedirected")
98 }
99 Err(_) => false,
100 }
101}
102
103fn shell_aliases_outcome() -> Outcome {
104 let home = match dirs::home_dir() {
105 Some(h) => h,
106 None => {
107 return Outcome {
108 ok: false,
109 line: format!(
110 "{BOLD}Shell aliases{RST} {RED}could not resolve home directory{RST}"
111 ),
112 };
113 }
114 };
115
116 let mut parts = Vec::new();
117 let mut needs_update = Vec::new();
118
119 let zsh = home.join(".zshrc");
120 if rc_contains_lean_ctx(&zsh) {
121 parts.push(format!("{DIM}~/.zshrc{RST}"));
122 if !rc_has_pipe_guard(&zsh) {
123 needs_update.push("~/.zshrc");
124 }
125 }
126 let bash = home.join(".bashrc");
127 if rc_contains_lean_ctx(&bash) {
128 parts.push(format!("{DIM}~/.bashrc{RST}"));
129 if !rc_has_pipe_guard(&bash) {
130 needs_update.push("~/.bashrc");
131 }
132 }
133
134 let fish = home.join(".config").join("fish").join("config.fish");
135 if rc_contains_lean_ctx(&fish) {
136 parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
137 if !rc_has_pipe_guard(&fish) {
138 needs_update.push("~/.config/fish/config.fish");
139 }
140 }
141
142 #[cfg(windows)]
143 {
144 let ps_profile = home
145 .join("Documents")
146 .join("PowerShell")
147 .join("Microsoft.PowerShell_profile.ps1");
148 let ps_profile_legacy = home
149 .join("Documents")
150 .join("WindowsPowerShell")
151 .join("Microsoft.PowerShell_profile.ps1");
152 if rc_contains_lean_ctx(&ps_profile) {
153 parts.push(format!("{DIM}PowerShell profile{RST}"));
154 if !rc_has_pipe_guard(&ps_profile) {
155 needs_update.push("PowerShell profile");
156 }
157 } else if rc_contains_lean_ctx(&ps_profile_legacy) {
158 parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
159 if !rc_has_pipe_guard(&ps_profile_legacy) {
160 needs_update.push("WindowsPowerShell profile");
161 }
162 }
163 }
164
165 if parts.is_empty() {
166 let hint = if cfg!(windows) {
167 "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
168 } else {
169 "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
170 };
171 Outcome {
172 ok: false,
173 line: format!("{BOLD}Shell aliases{RST} {RED}{hint}{RST}"),
174 }
175 } else if !needs_update.is_empty() {
176 Outcome {
177 ok: false,
178 line: format!(
179 "{BOLD}Shell aliases{RST} {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
180 needs_update.join(", ")
181 ),
182 }
183 } else {
184 Outcome {
185 ok: true,
186 line: format!(
187 "{BOLD}Shell aliases{RST} {GREEN}lean-ctx referenced in {}{RST}",
188 parts.join(", ")
189 ),
190 }
191 }
192}
193
194struct McpLocation {
195 name: &'static str,
196 display: String,
197 path: PathBuf,
198}
199
200fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
201 let mut locations = vec![
202 McpLocation {
203 name: "Cursor",
204 display: "~/.cursor/mcp.json".into(),
205 path: home.join(".cursor").join("mcp.json"),
206 },
207 McpLocation {
208 name: "Claude Code",
209 display: format!(
210 "{}",
211 crate::core::editor_registry::claude_mcp_json_path(home).display()
212 ),
213 path: crate::core::editor_registry::claude_mcp_json_path(home),
214 },
215 McpLocation {
216 name: "Windsurf",
217 display: "~/.codeium/windsurf/mcp_config.json".into(),
218 path: home
219 .join(".codeium")
220 .join("windsurf")
221 .join("mcp_config.json"),
222 },
223 McpLocation {
224 name: "Codex",
225 display: "~/.codex/config.toml".into(),
226 path: home.join(".codex").join("config.toml"),
227 },
228 McpLocation {
229 name: "Gemini CLI",
230 display: "~/.gemini/settings/mcp.json".into(),
231 path: home.join(".gemini").join("settings").join("mcp.json"),
232 },
233 McpLocation {
234 name: "Antigravity",
235 display: "~/.gemini/antigravity/mcp_config.json".into(),
236 path: home
237 .join(".gemini")
238 .join("antigravity")
239 .join("mcp_config.json"),
240 },
241 ];
242
243 #[cfg(unix)]
244 {
245 let zed_cfg = home.join(".config").join("zed").join("settings.json");
246 locations.push(McpLocation {
247 name: "Zed",
248 display: "~/.config/zed/settings.json".into(),
249 path: zed_cfg,
250 });
251 }
252
253 locations.push(McpLocation {
254 name: "Qwen Code",
255 display: "~/.qwen/mcp.json".into(),
256 path: home.join(".qwen").join("mcp.json"),
257 });
258 locations.push(McpLocation {
259 name: "Trae",
260 display: "~/.trae/mcp.json".into(),
261 path: home.join(".trae").join("mcp.json"),
262 });
263 locations.push(McpLocation {
264 name: "Amazon Q",
265 display: "~/.aws/amazonq/mcp.json".into(),
266 path: home.join(".aws").join("amazonq").join("mcp.json"),
267 });
268 locations.push(McpLocation {
269 name: "JetBrains",
270 display: "~/.jb-mcp.json".into(),
271 path: home.join(".jb-mcp.json"),
272 });
273 locations.push(McpLocation {
274 name: "AWS Kiro",
275 display: "~/.kiro/settings/mcp.json".into(),
276 path: home.join(".kiro").join("settings").join("mcp.json"),
277 });
278 locations.push(McpLocation {
279 name: "Verdent",
280 display: "~/.verdent/mcp.json".into(),
281 path: home.join(".verdent").join("mcp.json"),
282 });
283 locations.push(McpLocation {
284 name: "Crush",
285 display: "~/.config/crush/crush.json".into(),
286 path: home.join(".config").join("crush").join("crush.json"),
287 });
288 locations.push(McpLocation {
289 name: "Pi",
290 display: "~/.pi/agent/mcp.json".into(),
291 path: home.join(".pi").join("agent").join("mcp.json"),
292 });
293 locations.push(McpLocation {
294 name: "Aider",
295 display: "~/.aider/mcp.json".into(),
296 path: home.join(".aider").join("mcp.json"),
297 });
298 locations.push(McpLocation {
299 name: "Amp",
300 display: "~/.config/amp/settings.json".into(),
301 path: home.join(".config").join("amp").join("settings.json"),
302 });
303
304 {
305 #[cfg(unix)]
306 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
307 #[cfg(unix)]
308 let opencode_display = "~/.config/opencode/opencode.json";
309
310 #[cfg(windows)]
311 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
312 std::path::PathBuf::from(appdata)
313 .join("opencode")
314 .join("opencode.json")
315 } else {
316 home.join(".config").join("opencode").join("opencode.json")
317 };
318 #[cfg(windows)]
319 let opencode_display = "%APPDATA%/opencode/opencode.json";
320
321 locations.push(McpLocation {
322 name: "OpenCode",
323 display: opencode_display.into(),
324 path: opencode_cfg,
325 });
326 }
327
328 #[cfg(target_os = "macos")]
329 {
330 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
331 locations.push(McpLocation {
332 name: "VS Code / Copilot",
333 display: "~/Library/Application Support/Code/User/mcp.json".into(),
334 path: vscode_mcp,
335 });
336 }
337 #[cfg(target_os = "linux")]
338 {
339 let vscode_mcp = home.join(".config/Code/User/mcp.json");
340 locations.push(McpLocation {
341 name: "VS Code / Copilot",
342 display: "~/.config/Code/User/mcp.json".into(),
343 path: vscode_mcp,
344 });
345 }
346 #[cfg(target_os = "windows")]
347 {
348 if let Ok(appdata) = std::env::var("APPDATA") {
349 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
350 locations.push(McpLocation {
351 name: "VS Code / Copilot",
352 display: "%APPDATA%/Code/User/mcp.json".into(),
353 path: vscode_mcp,
354 });
355 }
356 }
357
358 locations.push(McpLocation {
359 name: "Hermes Agent",
360 display: "~/.hermes/config.yaml".into(),
361 path: home.join(".hermes").join("config.yaml"),
362 });
363
364 {
365 let cline_path = crate::core::editor_registry::cline_mcp_path();
366 if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
367 locations.push(McpLocation {
368 name: "Cline",
369 display: cline_path.display().to_string(),
370 path: cline_path,
371 });
372 }
373 }
374 {
375 let roo_path = crate::core::editor_registry::roo_mcp_path();
376 if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
377 locations.push(McpLocation {
378 name: "Roo Code",
379 display: roo_path.display().to_string(),
380 path: roo_path,
381 });
382 }
383 }
384
385 locations
386}
387
388fn docker_bash_env_outcome() -> Option<Outcome> {
389 if !crate::shell::is_container() {
390 return None;
391 }
392 let shell_name = std::env::var("SHELL").unwrap_or_default();
393 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
394 if !is_bash {
395 return None;
396 }
397 let has_bash_env = std::env::var("BASH_ENV").is_ok();
398 if has_bash_env {
399 Some(Outcome {
400 ok: true,
401 line: format!(
402 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
403 std::env::var("BASH_ENV").unwrap_or_default()
404 ),
405 })
406 } else {
407 let env_sh = crate::core::data_dir::lean_ctx_data_dir()
408 .map(|d| d.join("env.sh").to_string_lossy().to_string())
409 .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
410 Some(Outcome {
411 ok: false,
412 line: format!(
413 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(Docker detected — add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
414 ),
415 })
416 }
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 = dirs::home_dir()
595 .map(|h| {
596 h.join(".lean-ctx")
597 .join("env.sh")
598 .to_string_lossy()
599 .to_string()
600 })
601 .unwrap_or_else(|| "/root/.lean-ctx/env.sh".to_string());
602
603 let mut outcomes = vec![];
604
605 let shell_name = std::env::var("SHELL").unwrap_or_default();
606 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
607
608 if is_bash {
609 let has_bash_env = std::env::var("BASH_ENV").is_ok();
610 outcomes.push(if has_bash_env {
611 Outcome {
612 ok: true,
613 line: format!(
614 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
615 std::env::var("BASH_ENV").unwrap_or_default()
616 ),
617 }
618 } else {
619 Outcome {
620 ok: false,
621 line: format!(
622 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
623 ),
624 }
625 });
626 }
627
628 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
629 outcomes.push(if has_claude_env {
630 Outcome {
631 ok: true,
632 line: format!(
633 "{BOLD}CLAUDE_ENV_FILE{RST} {GREEN}set{RST} {DIM}({}){RST}",
634 std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
635 ),
636 }
637 } else {
638 Outcome {
639 ok: false,
640 line: format!(
641 "{BOLD}CLAUDE_ENV_FILE{RST} {RED}not set{RST} {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
642 ),
643 }
644 });
645
646 outcomes
647}
648
649pub fn run() {
651 let mut passed = 0u32;
652 let total = 8u32;
653
654 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
655
656 let path_bin = resolve_lean_ctx_binary();
658 let also_in_path_dirs = path_in_path_env();
659 let bin_ok = path_bin.is_some() || also_in_path_dirs;
660 if bin_ok {
661 passed += 1;
662 }
663 let bin_line = if let Some(p) = path_bin {
664 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
665 } else if also_in_path_dirs {
666 format!(
667 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
668 )
669 } else {
670 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
671 };
672 print_check(&Outcome {
673 ok: bin_ok,
674 line: bin_line,
675 });
676
677 let ver = if bin_ok {
679 lean_ctx_version_from_path()
680 } else {
681 Outcome {
682 ok: false,
683 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
684 }
685 };
686 if ver.ok {
687 passed += 1;
688 }
689 print_check(&ver);
690
691 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
693 let dir_outcome = match &lean_dir {
694 Some(p) if p.is_dir() => {
695 passed += 1;
696 Outcome {
697 ok: true,
698 line: format!(
699 "{BOLD}~/.lean-ctx/{RST} {GREEN}exists{RST} {DIM}{}{RST}",
700 p.display()
701 ),
702 }
703 }
704 Some(p) => Outcome {
705 ok: false,
706 line: format!(
707 "{BOLD}~/.lean-ctx/{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
708 p.display()
709 ),
710 },
711 None => Outcome {
712 ok: false,
713 line: format!("{BOLD}~/.lean-ctx/{RST} {RED}could not resolve home directory{RST}"),
714 },
715 };
716 print_check(&dir_outcome);
717
718 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
720 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
721 Some(m) if m.is_file() => {
722 passed += 1;
723 let size = m.len();
724 Outcome {
725 ok: true,
726 line: format!(
727 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{}{RST}",
728 stats_path.as_ref().unwrap().display()
729 ),
730 }
731 }
732 Some(_m) => Outcome {
733 ok: false,
734 line: format!(
735 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{}{RST}",
736 stats_path.as_ref().unwrap().display()
737 ),
738 },
739 None => {
740 passed += 1;
741 Outcome {
742 ok: true,
743 line: match &stats_path {
744 Some(p) => format!(
745 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
746 p.display()
747 ),
748 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
749 },
750 }
751 }
752 };
753 print_check(&stats_outcome);
754
755 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
757 let config_outcome = match &config_path {
758 Some(p) => match std::fs::metadata(p) {
759 Ok(m) if m.is_file() => {
760 passed += 1;
761 Outcome {
762 ok: true,
763 line: format!(
764 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
765 p.display()
766 ),
767 }
768 }
769 Ok(_) => Outcome {
770 ok: false,
771 line: format!(
772 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
773 p.display()
774 ),
775 },
776 Err(_) => {
777 passed += 1;
778 Outcome {
779 ok: true,
780 line: format!(
781 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
782 p.display()
783 ),
784 }
785 }
786 },
787 None => Outcome {
788 ok: false,
789 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
790 },
791 };
792 print_check(&config_outcome);
793
794 let aliases = shell_aliases_outcome();
796 if aliases.ok {
797 passed += 1;
798 }
799 print_check(&aliases);
800
801 let docker_outcome = docker_bash_env_outcome();
803 if let Some(ref docker_check) = docker_outcome {
804 if docker_check.ok {
805 passed += 1;
806 }
807 print_check(docker_check);
808 }
809
810 let mcp = mcp_config_outcome();
812 if mcp.ok {
813 passed += 1;
814 }
815 print_check(&mcp);
816
817 let port = port_3333_outcome();
819 if port.ok {
820 passed += 1;
821 }
822 print_check(&port);
823
824 let session_outcome = session_state_outcome();
826 if session_outcome.ok {
827 passed += 1;
828 }
829 print_check(&session_outcome);
830
831 let docker_outcomes = docker_env_outcomes();
833 for docker_check in &docker_outcomes {
834 if docker_check.ok {
835 passed += 1;
836 }
837 print_check(docker_check);
838 }
839
840 let pi = pi_outcome();
842 if let Some(ref pi_check) = pi {
843 if pi_check.ok {
844 passed += 1;
845 }
846 print_check(pi_check);
847 }
848
849 let integrity = crate::core::integrity::check();
851 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
852 if integrity_ok {
853 passed += 1;
854 }
855 let integrity_line = if integrity_ok {
856 format!(
857 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
858 integrity.repo
859 )
860 } else {
861 format!(
862 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
863 integrity.pkg_name, integrity.repo
864 )
865 };
866 print_check(&Outcome {
867 ok: integrity_ok,
868 line: integrity_line,
869 });
870
871 let claude_truncation = claude_truncation_outcome();
873 if let Some(ref ct) = claude_truncation {
874 if ct.ok {
875 passed += 1;
876 }
877 print_check(ct);
878 }
879
880 let mut effective_total = total + 2; effective_total += docker_outcomes.len() as u32;
882 if pi.is_some() {
883 effective_total += 1;
884 }
885 if claude_truncation.is_some() {
886 effective_total += 1;
887 }
888 println!();
889 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
890 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
891}
892
893fn claude_binary_exists() -> bool {
894 #[cfg(unix)]
895 {
896 std::process::Command::new("which")
897 .arg("claude")
898 .output()
899 .map(|o| o.status.success())
900 .unwrap_or(false)
901 }
902 #[cfg(windows)]
903 {
904 std::process::Command::new("where")
905 .arg("claude")
906 .output()
907 .map(|o| o.status.success())
908 .unwrap_or(false)
909 }
910}
911
912fn claude_truncation_outcome() -> Option<Outcome> {
913 let home = dirs::home_dir()?;
914 let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
915 || crate::core::editor_registry::claude_state_dir(&home).exists()
916 || claude_binary_exists();
917
918 if !claude_detected {
919 return None;
920 }
921
922 let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
923 let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
924
925 let has_rules = rules_path.exists();
926 let has_skill = skill_path.exists();
927
928 if has_rules && has_skill {
929 Some(Outcome {
930 ok: true,
931 line: format!(
932 "{BOLD}Claude Code instructions{RST} {GREEN}rules + skill installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
933 ),
934 })
935 } else if has_rules {
936 Some(Outcome {
937 ok: true,
938 line: format!(
939 "{BOLD}Claude Code instructions{RST} {GREEN}rules file installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
940 ),
941 })
942 } else {
943 Some(Outcome {
944 ok: false,
945 line: format!(
946 "{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}"
947 ),
948 })
949 }
950}
951
952pub fn run_compact() {
953 let (passed, total) = compact_score();
954 print_compact_status(passed, total);
955}
956
957pub fn run_cli(args: &[String]) -> i32 {
958 let fix = args.iter().any(|a| a == "--fix");
959 let json = args.iter().any(|a| a == "--json");
960 let help = args.iter().any(|a| a == "--help" || a == "-h");
961
962 if help {
963 println!("Usage:");
964 println!(" lean-ctx doctor");
965 println!(" lean-ctx doctor --fix [--json]");
966 return 0;
967 }
968
969 if !fix {
970 run();
971 return 0;
972 }
973
974 match run_fix(DoctorFixOptions { json }) {
975 Ok(code) => code,
976 Err(e) => {
977 eprintln!("{RED}doctor --fix failed:{RST} {e}");
978 2
979 }
980 }
981}
982
983struct DoctorFixOptions {
984 json: bool,
985}
986
987fn run_fix(opts: DoctorFixOptions) -> Result<i32, String> {
988 use crate::core::setup_report::{
989 doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
990 };
991
992 let started_at = Utc::now();
993 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
994
995 let mut steps: Vec<SetupStepReport> = Vec::new();
996
997 let mut shell_step = SetupStepReport {
999 name: "shell_hook".to_string(),
1000 ok: true,
1001 items: Vec::new(),
1002 warnings: Vec::new(),
1003 errors: Vec::new(),
1004 };
1005 let before = shell_aliases_outcome();
1006 if before.ok {
1007 shell_step.items.push(SetupItem {
1008 name: "init --global".to_string(),
1009 status: "already".to_string(),
1010 path: None,
1011 note: None,
1012 });
1013 } else {
1014 if opts.json {
1015 crate::cli::cmd_init_quiet(&["--global".to_string()]);
1016 } else {
1017 crate::cli::cmd_init(&["--global".to_string()]);
1018 }
1019 let after = shell_aliases_outcome();
1020 shell_step.ok = after.ok;
1021 shell_step.items.push(SetupItem {
1022 name: "init --global".to_string(),
1023 status: if after.ok {
1024 "fixed".to_string()
1025 } else {
1026 "failed".to_string()
1027 },
1028 path: None,
1029 note: if after.ok {
1030 None
1031 } else {
1032 Some("shell hook still not detected by doctor checks".to_string())
1033 },
1034 });
1035 if !after.ok {
1036 shell_step
1037 .warnings
1038 .push("shell hook not detected after init --global".to_string());
1039 }
1040 }
1041 steps.push(shell_step);
1042
1043 let mut mcp_step = SetupStepReport {
1045 name: "mcp_config".to_string(),
1046 ok: true,
1047 items: Vec::new(),
1048 warnings: Vec::new(),
1049 errors: Vec::new(),
1050 };
1051 let binary = crate::core::portable_binary::resolve_portable_binary();
1052 let targets = crate::core::editor_registry::build_targets(&home);
1053 for t in &targets {
1054 if !t.detect_path.exists() {
1055 continue;
1056 }
1057 let short = t.config_path.to_string_lossy().to_string();
1058 let res = crate::core::editor_registry::write_config_with_options(
1059 t,
1060 &binary,
1061 crate::core::editor_registry::WriteOptions {
1062 overwrite_invalid: true,
1063 },
1064 );
1065 match res {
1066 Ok(r) => {
1067 let status = match r.action {
1068 crate::core::editor_registry::WriteAction::Created => "created",
1069 crate::core::editor_registry::WriteAction::Updated => "updated",
1070 crate::core::editor_registry::WriteAction::Already => "already",
1071 };
1072 mcp_step.items.push(SetupItem {
1073 name: t.name.to_string(),
1074 status: status.to_string(),
1075 path: Some(short),
1076 note: r.note,
1077 });
1078 }
1079 Err(e) => {
1080 mcp_step.ok = false;
1081 mcp_step.items.push(SetupItem {
1082 name: t.name.to_string(),
1083 status: "error".to_string(),
1084 path: Some(short),
1085 note: Some(e.clone()),
1086 });
1087 mcp_step.errors.push(format!("{}: {e}", t.name));
1088 }
1089 }
1090 }
1091 if mcp_step.items.is_empty() {
1092 mcp_step
1093 .warnings
1094 .push("no supported AI tools detected; skipped MCP config repair".to_string());
1095 }
1096 steps.push(mcp_step);
1097
1098 let mut rules_step = SetupStepReport {
1100 name: "agent_rules".to_string(),
1101 ok: true,
1102 items: Vec::new(),
1103 warnings: Vec::new(),
1104 errors: Vec::new(),
1105 };
1106 let inj = crate::rules_inject::inject_all_rules(&home);
1107 if !inj.injected.is_empty() {
1108 rules_step.items.push(SetupItem {
1109 name: "injected".to_string(),
1110 status: inj.injected.len().to_string(),
1111 path: None,
1112 note: Some(inj.injected.join(", ")),
1113 });
1114 }
1115 if !inj.updated.is_empty() {
1116 rules_step.items.push(SetupItem {
1117 name: "updated".to_string(),
1118 status: inj.updated.len().to_string(),
1119 path: None,
1120 note: Some(inj.updated.join(", ")),
1121 });
1122 }
1123 if !inj.already.is_empty() {
1124 rules_step.items.push(SetupItem {
1125 name: "already".to_string(),
1126 status: inj.already.len().to_string(),
1127 path: None,
1128 note: Some(inj.already.join(", ")),
1129 });
1130 }
1131 if !inj.errors.is_empty() {
1132 rules_step.ok = false;
1133 rules_step.errors.extend(inj.errors.clone());
1134 }
1135 steps.push(rules_step);
1136
1137 let mut verify_step = SetupStepReport {
1139 name: "verify".to_string(),
1140 ok: true,
1141 items: Vec::new(),
1142 warnings: Vec::new(),
1143 errors: Vec::new(),
1144 };
1145 let (passed, total) = compact_score();
1146 verify_step.items.push(SetupItem {
1147 name: "doctor_compact".to_string(),
1148 status: format!("{passed}/{total}"),
1149 path: None,
1150 note: None,
1151 });
1152 if passed != total {
1153 verify_step.warnings.push(format!(
1154 "doctor compact not fully passing: {passed}/{total}"
1155 ));
1156 }
1157 steps.push(verify_step);
1158
1159 let finished_at = Utc::now();
1160 let success = steps.iter().all(|s| s.ok);
1161
1162 let report = SetupReport {
1163 schema_version: 1,
1164 started_at,
1165 finished_at,
1166 success,
1167 platform: PlatformInfo {
1168 os: std::env::consts::OS.to_string(),
1169 arch: std::env::consts::ARCH.to_string(),
1170 },
1171 steps,
1172 warnings: Vec::new(),
1173 errors: Vec::new(),
1174 };
1175
1176 let path = doctor_report_path()?;
1177 let json_text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
1178 crate::config_io::write_atomic_with_backup(&path, &json_text)?;
1179
1180 if opts.json {
1181 println!("{json_text}");
1182 } else {
1183 let (passed, total) = compact_score();
1184 print_compact_status(passed, total);
1185 println!(" {DIM}report saved:{RST} {}", path.display());
1186 }
1187
1188 Ok(if report.success { 0 } else { 1 })
1189}
1190
1191pub fn compact_score() -> (u32, u32) {
1192 let mut passed = 0u32;
1193 let total = 5u32;
1194
1195 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1196 passed += 1;
1197 }
1198 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1199 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1200 passed += 1;
1201 }
1202 if lean_dir
1203 .as_ref()
1204 .map(|d| d.join("stats.json"))
1205 .and_then(|p| std::fs::metadata(p).ok())
1206 .is_some_and(|m| m.is_file())
1207 {
1208 passed += 1;
1209 }
1210 if shell_aliases_outcome().ok {
1211 passed += 1;
1212 }
1213 if mcp_config_outcome().ok {
1214 passed += 1;
1215 }
1216
1217 (passed, total)
1218}
1219
1220fn print_compact_status(passed: u32, total: u32) {
1221 let status = if passed == total {
1222 format!("{GREEN}✓ All {total} checks passed{RST}")
1223 } else {
1224 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1225 };
1226 println!(" {status}");
1227}