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