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