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(_) | Err(_) => format!("{DIM}(resolved: {}){RST}", bin.display()),
79 };
80 Outcome {
81 ok: true,
82 line: format!("{BOLD}lean-ctx version{RST} {WHITE}lean-ctx {v}{RST} {note}"),
83 }
84}
85
86fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
87 match std::fs::read_to_string(path) {
88 Ok(s) => s.contains("lean-ctx"),
89 Err(_) => false,
90 }
91}
92
93fn has_pipe_guard_in_content(content: &str) -> bool {
94 content.contains("! -t 1")
95 || content.contains("isatty stdout")
96 || content.contains("IsOutputRedirected")
97}
98
99fn rc_has_pipe_guard(path: &PathBuf) -> bool {
100 match std::fs::read_to_string(path) {
101 Ok(s) => {
102 if has_pipe_guard_in_content(&s) {
103 return true;
104 }
105 if s.contains(".lean-ctx/shell-hook.") {
106 if let Some(home) = dirs::home_dir() {
107 for ext in &["zsh", "bash", "fish", "ps1"] {
108 let hook = home.join(format!(".lean-ctx/shell-hook.{ext}"));
109 if let Ok(h) = std::fs::read_to_string(&hook) {
110 if has_pipe_guard_in_content(&h) {
111 return true;
112 }
113 }
114 }
115 }
116 }
117 false
118 }
119 Err(_) => false,
120 }
121}
122
123fn is_active_shell(rc_name: &str) -> bool {
124 let shell = std::env::var("SHELL").unwrap_or_default();
125 match rc_name {
126 "~/.zshrc" => shell.contains("zsh"),
127 "~/.bashrc" => shell.contains("bash") || shell.is_empty(),
128 "~/.config/fish/config.fish" => shell.contains("fish"),
129 _ => true,
130 }
131}
132
133fn shell_aliases_outcome() -> Outcome {
134 let Some(home) = dirs::home_dir() else {
135 return Outcome {
136 ok: false,
137 line: format!("{BOLD}Shell aliases{RST} {RED}could not resolve home directory{RST}"),
138 };
139 };
140
141 let mut parts = Vec::new();
142 let mut needs_update = Vec::new();
143
144 let zsh = home.join(".zshrc");
145 if rc_contains_lean_ctx(&zsh) {
146 parts.push(format!("{DIM}~/.zshrc{RST}"));
147 if !rc_has_pipe_guard(&zsh) && is_active_shell("~/.zshrc") {
148 needs_update.push("~/.zshrc");
149 }
150 }
151 let bash = home.join(".bashrc");
152 if rc_contains_lean_ctx(&bash) {
153 parts.push(format!("{DIM}~/.bashrc{RST}"));
154 if !rc_has_pipe_guard(&bash) && is_active_shell("~/.bashrc") {
155 needs_update.push("~/.bashrc");
156 }
157 }
158
159 let fish = home.join(".config").join("fish").join("config.fish");
160 if rc_contains_lean_ctx(&fish) {
161 parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
162 if !rc_has_pipe_guard(&fish) && is_active_shell("~/.config/fish/config.fish") {
163 needs_update.push("~/.config/fish/config.fish");
164 }
165 }
166
167 #[cfg(windows)]
168 {
169 let ps_profile = home
170 .join("Documents")
171 .join("PowerShell")
172 .join("Microsoft.PowerShell_profile.ps1");
173 let ps_profile_legacy = home
174 .join("Documents")
175 .join("WindowsPowerShell")
176 .join("Microsoft.PowerShell_profile.ps1");
177 if rc_contains_lean_ctx(&ps_profile) {
178 parts.push(format!("{DIM}PowerShell profile{RST}"));
179 if !rc_has_pipe_guard(&ps_profile) {
180 needs_update.push("PowerShell profile");
181 }
182 } else if rc_contains_lean_ctx(&ps_profile_legacy) {
183 parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
184 if !rc_has_pipe_guard(&ps_profile_legacy) {
185 needs_update.push("WindowsPowerShell profile");
186 }
187 }
188 }
189
190 if parts.is_empty() {
191 let hint = if cfg!(windows) {
192 "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
193 } else {
194 "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
195 };
196 Outcome {
197 ok: false,
198 line: format!("{BOLD}Shell aliases{RST} {RED}{hint}{RST}"),
199 }
200 } else if !needs_update.is_empty() {
201 Outcome {
202 ok: false,
203 line: format!(
204 "{BOLD}Shell aliases{RST} {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
205 needs_update.join(", ")
206 ),
207 }
208 } else {
209 Outcome {
210 ok: true,
211 line: format!(
212 "{BOLD}Shell aliases{RST} {GREEN}lean-ctx referenced in {}{RST}",
213 parts.join(", ")
214 ),
215 }
216 }
217}
218
219struct McpLocation {
220 name: &'static str,
221 display: String,
222 path: PathBuf,
223}
224
225fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
226 let mut locations = vec![
227 McpLocation {
228 name: "Cursor",
229 display: "~/.cursor/mcp.json".into(),
230 path: home.join(".cursor").join("mcp.json"),
231 },
232 McpLocation {
233 name: "Claude Code",
234 display: format!(
235 "{}",
236 crate::core::editor_registry::claude_mcp_json_path(home).display()
237 ),
238 path: crate::core::editor_registry::claude_mcp_json_path(home),
239 },
240 McpLocation {
241 name: "Windsurf",
242 display: "~/.codeium/windsurf/mcp_config.json".into(),
243 path: home
244 .join(".codeium")
245 .join("windsurf")
246 .join("mcp_config.json"),
247 },
248 McpLocation {
249 name: "Codex",
250 display: "~/.codex/config.toml".into(),
251 path: home.join(".codex").join("config.toml"),
252 },
253 McpLocation {
254 name: "Gemini CLI",
255 display: "~/.gemini/settings/mcp.json".into(),
256 path: home.join(".gemini").join("settings").join("mcp.json"),
257 },
258 McpLocation {
259 name: "Antigravity",
260 display: "~/.gemini/antigravity/mcp_config.json".into(),
261 path: home
262 .join(".gemini")
263 .join("antigravity")
264 .join("mcp_config.json"),
265 },
266 ];
267
268 #[cfg(unix)]
269 {
270 let zed_cfg = home.join(".config").join("zed").join("settings.json");
271 locations.push(McpLocation {
272 name: "Zed",
273 display: "~/.config/zed/settings.json".into(),
274 path: zed_cfg,
275 });
276 }
277
278 locations.push(McpLocation {
279 name: "Qwen Code",
280 display: "~/.qwen/mcp.json".into(),
281 path: home.join(".qwen").join("mcp.json"),
282 });
283 locations.push(McpLocation {
284 name: "Trae",
285 display: "~/.trae/mcp.json".into(),
286 path: home.join(".trae").join("mcp.json"),
287 });
288 locations.push(McpLocation {
289 name: "Amazon Q",
290 display: "~/.aws/amazonq/mcp.json".into(),
291 path: home.join(".aws").join("amazonq").join("mcp.json"),
292 });
293 locations.push(McpLocation {
294 name: "JetBrains",
295 display: "~/.jb-mcp.json".into(),
296 path: home.join(".jb-mcp.json"),
297 });
298 locations.push(McpLocation {
299 name: "AWS Kiro",
300 display: "~/.kiro/settings/mcp.json".into(),
301 path: home.join(".kiro").join("settings").join("mcp.json"),
302 });
303 locations.push(McpLocation {
304 name: "Verdent",
305 display: "~/.verdent/mcp.json".into(),
306 path: home.join(".verdent").join("mcp.json"),
307 });
308 locations.push(McpLocation {
309 name: "Crush",
310 display: "~/.config/crush/crush.json".into(),
311 path: home.join(".config").join("crush").join("crush.json"),
312 });
313 locations.push(McpLocation {
314 name: "Pi",
315 display: "~/.pi/agent/mcp.json".into(),
316 path: home.join(".pi").join("agent").join("mcp.json"),
317 });
318 locations.push(McpLocation {
319 name: "Aider",
320 display: "~/.aider/mcp.json".into(),
321 path: home.join(".aider").join("mcp.json"),
322 });
323 locations.push(McpLocation {
324 name: "Amp",
325 display: "~/.config/amp/settings.json".into(),
326 path: home.join(".config").join("amp").join("settings.json"),
327 });
328
329 {
330 #[cfg(unix)]
331 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
332 #[cfg(unix)]
333 let opencode_display = "~/.config/opencode/opencode.json";
334
335 #[cfg(windows)]
336 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
337 std::path::PathBuf::from(appdata)
338 .join("opencode")
339 .join("opencode.json")
340 } else {
341 home.join(".config").join("opencode").join("opencode.json")
342 };
343 #[cfg(windows)]
344 let opencode_display = "%APPDATA%/opencode/opencode.json";
345
346 locations.push(McpLocation {
347 name: "OpenCode",
348 display: opencode_display.into(),
349 path: opencode_cfg,
350 });
351 }
352
353 #[cfg(target_os = "macos")]
354 {
355 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
356 locations.push(McpLocation {
357 name: "VS Code / Copilot",
358 display: "~/Library/Application Support/Code/User/mcp.json".into(),
359 path: vscode_mcp,
360 });
361 }
362 #[cfg(target_os = "linux")]
363 {
364 let vscode_mcp = home.join(".config/Code/User/mcp.json");
365 locations.push(McpLocation {
366 name: "VS Code / Copilot",
367 display: "~/.config/Code/User/mcp.json".into(),
368 path: vscode_mcp,
369 });
370 }
371 #[cfg(target_os = "windows")]
372 {
373 if let Ok(appdata) = std::env::var("APPDATA") {
374 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
375 locations.push(McpLocation {
376 name: "VS Code / Copilot",
377 display: "%APPDATA%/Code/User/mcp.json".into(),
378 path: vscode_mcp,
379 });
380 }
381 }
382
383 locations.push(McpLocation {
384 name: "Hermes Agent",
385 display: "~/.hermes/config.yaml".into(),
386 path: home.join(".hermes").join("config.yaml"),
387 });
388
389 {
390 let cline_path = crate::core::editor_registry::cline_mcp_path();
391 if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
392 locations.push(McpLocation {
393 name: "Cline",
394 display: cline_path.display().to_string(),
395 path: cline_path,
396 });
397 }
398 }
399 {
400 let roo_path = crate::core::editor_registry::roo_mcp_path();
401 if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
402 locations.push(McpLocation {
403 name: "Roo Code",
404 display: roo_path.display().to_string(),
405 path: roo_path,
406 });
407 }
408 }
409
410 locations
411}
412
413fn mcp_config_outcome() -> Outcome {
414 let Some(home) = dirs::home_dir() else {
415 return Outcome {
416 ok: false,
417 line: format!("{BOLD}MCP config{RST} {RED}could not resolve home directory{RST}"),
418 };
419 };
420
421 let locations = mcp_config_locations(&home);
422 let mut found: Vec<String> = Vec::new();
423 let mut exists_no_ref: Vec<String> = Vec::new();
424
425 for loc in &locations {
426 if let Ok(content) = std::fs::read_to_string(&loc.path) {
427 if has_lean_ctx_mcp_entry(&content) {
428 found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
429 } else {
430 exists_no_ref.push(loc.name.to_string());
431 }
432 }
433 }
434
435 found.sort();
436 found.dedup();
437 exists_no_ref.sort();
438 exists_no_ref.dedup();
439
440 if !found.is_empty() {
441 Outcome {
442 ok: true,
443 line: format!(
444 "{BOLD}MCP config{RST} {GREEN}lean-ctx found in: {}{RST}",
445 found.join(", ")
446 ),
447 }
448 } else if !exists_no_ref.is_empty() {
449 let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
450 let cause = if has_claude {
451 format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
452 } else {
453 String::new()
454 };
455 let hint = if has_claude {
456 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
457 } else {
458 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
459 };
460 Outcome {
461 ok: false,
462 line: format!(
463 "{BOLD}MCP config{RST} {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST} {cause} {hint}",
464 exists_no_ref.join(", "),
465 ),
466 }
467 } else {
468 Outcome {
469 ok: false,
470 line: format!(
471 "{BOLD}MCP config{RST} {YELLOW}no MCP config found{RST} {DIM}(run: lean-ctx setup){RST}"
472 ),
473 }
474 }
475}
476
477fn has_lean_ctx_mcp_entry(content: &str) -> bool {
478 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
479 if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
480 return servers.contains_key("lean-ctx");
481 }
482 if let Some(servers) = json
483 .get("mcp")
484 .and_then(|v| v.get("servers"))
485 .and_then(|v| v.as_object())
486 {
487 return servers.contains_key("lean-ctx");
488 }
489 }
490 content.contains("lean-ctx")
491}
492
493fn port_3333_outcome() -> Outcome {
494 match TcpListener::bind("127.0.0.1:3333") {
495 Ok(_listener) => Outcome {
496 ok: true,
497 line: format!("{BOLD}Dashboard port 3333{RST} {GREEN}available on 127.0.0.1{RST}"),
498 },
499 Err(e) => Outcome {
500 ok: false,
501 line: format!("{BOLD}Dashboard port 3333{RST} {RED}not available: {e}{RST}"),
502 },
503 }
504}
505
506fn pi_outcome() -> Option<Outcome> {
507 let pi_result = std::process::Command::new("pi").arg("--version").output();
508
509 match pi_result {
510 Ok(output) if output.status.success() => {
511 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
512 let has_plugin = std::process::Command::new("pi")
513 .args(["list"])
514 .output()
515 .is_ok_and(|o| {
516 o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
517 });
518
519 let has_mcp = dirs::home_dir()
520 .map(|h| h.join(".pi/agent/mcp.json"))
521 .and_then(|p| std::fs::read_to_string(p).ok())
522 .is_some_and(|c| c.contains("lean-ctx"));
523
524 if has_plugin && has_mcp {
525 Some(Outcome {
526 ok: true,
527 line: format!(
528 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
529 ),
530 })
531 } else if has_plugin {
532 Some(Outcome {
533 ok: true,
534 line: format!(
535 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx installed{RST} {DIM}(MCP not configured — embedded bridge active){RST}"
536 ),
537 })
538 } else {
539 Some(Outcome {
540 ok: false,
541 line: format!(
542 "{BOLD}Pi Coding Agent{RST} {YELLOW}{version}, but pi-lean-ctx not installed{RST} {DIM}(run: pi install npm:pi-lean-ctx){RST}"
543 ),
544 })
545 }
546 }
547 _ => None,
548 }
549}
550
551fn session_state_outcome() -> Outcome {
552 use crate::core::session::SessionState;
553
554 match SessionState::load_latest() {
555 Some(session) => {
556 let root = session
557 .project_root
558 .as_deref()
559 .unwrap_or("(not set)");
560 let cwd = session
561 .shell_cwd
562 .as_deref()
563 .unwrap_or("(not tracked)");
564 Outcome {
565 ok: true,
566 line: format!(
567 "{BOLD}Session state{RST} {GREEN}active{RST} {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
568 session.version
569 ),
570 }
571 }
572 None => Outcome {
573 ok: true,
574 line: format!(
575 "{BOLD}Session state{RST} {YELLOW}no active session{RST} {DIM}(will be created on first tool call){RST}"
576 ),
577 },
578 }
579}
580
581fn docker_env_outcomes() -> Vec<Outcome> {
582 if !crate::shell::is_container() {
583 return vec![];
584 }
585 let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
586 |_| "/root/.lean-ctx/env.sh".to_string(),
587 |d| d.join("env.sh").to_string_lossy().to_string(),
588 );
589
590 let mut outcomes = vec![];
591
592 let shell_name = std::env::var("SHELL").unwrap_or_default();
593 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
594
595 if is_bash {
596 let has_bash_env = std::env::var("BASH_ENV").is_ok();
597 outcomes.push(if has_bash_env {
598 Outcome {
599 ok: true,
600 line: format!(
601 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
602 std::env::var("BASH_ENV").unwrap_or_default()
603 ),
604 }
605 } else {
606 Outcome {
607 ok: false,
608 line: format!(
609 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
610 ),
611 }
612 });
613 }
614
615 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
616 outcomes.push(if has_claude_env {
617 Outcome {
618 ok: true,
619 line: format!(
620 "{BOLD}CLAUDE_ENV_FILE{RST} {GREEN}set{RST} {DIM}({}){RST}",
621 std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
622 ),
623 }
624 } else {
625 Outcome {
626 ok: false,
627 line: format!(
628 "{BOLD}CLAUDE_ENV_FILE{RST} {RED}not set{RST} {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
629 ),
630 }
631 });
632
633 outcomes
634}
635
636pub fn run() {
638 let mut passed = 0u32;
639 let total = 8u32;
640
641 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
642
643 let path_bin = resolve_lean_ctx_binary();
645 let also_in_path_dirs = path_in_path_env();
646 let bin_ok = path_bin.is_some() || also_in_path_dirs;
647 if bin_ok {
648 passed += 1;
649 }
650 let bin_line = if let Some(p) = path_bin {
651 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
652 } else if also_in_path_dirs {
653 format!(
654 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
655 )
656 } else {
657 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
658 };
659 print_check(&Outcome {
660 ok: bin_ok,
661 line: bin_line,
662 });
663
664 let ver = if bin_ok {
666 lean_ctx_version_from_path()
667 } else {
668 Outcome {
669 ok: false,
670 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
671 }
672 };
673 if ver.ok {
674 passed += 1;
675 }
676 print_check(&ver);
677
678 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
680 let dir_outcome = match &lean_dir {
681 Some(p) if p.is_dir() => {
682 passed += 1;
683 Outcome {
684 ok: true,
685 line: format!(
686 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
687 p.display()
688 ),
689 }
690 }
691 Some(p) => Outcome {
692 ok: false,
693 line: format!(
694 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
695 p.display()
696 ),
697 },
698 None => Outcome {
699 ok: false,
700 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
701 },
702 };
703 print_check(&dir_outcome);
704
705 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
707 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
708 Some(m) if m.is_file() => {
709 passed += 1;
710 let size = m.len();
711 let path_display = if let Some(p) = stats_path.as_ref() {
712 p.display().to_string()
713 } else {
714 String::new()
715 };
716 Outcome {
717 ok: true,
718 line: format!(
719 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{path_display}{RST}",
720 ),
721 }
722 }
723 Some(_m) => {
724 let path_display = if let Some(p) = stats_path.as_ref() {
725 p.display().to_string()
726 } else {
727 String::new()
728 };
729 Outcome {
730 ok: false,
731 line: format!(
732 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{path_display}{RST}",
733 ),
734 }
735 }
736 None => {
737 passed += 1;
738 Outcome {
739 ok: true,
740 line: match &stats_path {
741 Some(p) => format!(
742 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
743 p.display()
744 ),
745 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
746 },
747 }
748 }
749 };
750 print_check(&stats_outcome);
751
752 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
754 let config_outcome = match &config_path {
755 Some(p) => match std::fs::metadata(p) {
756 Ok(m) if m.is_file() => {
757 passed += 1;
758 Outcome {
759 ok: true,
760 line: format!(
761 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
762 p.display()
763 ),
764 }
765 }
766 Ok(_) => Outcome {
767 ok: false,
768 line: format!(
769 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
770 p.display()
771 ),
772 },
773 Err(_) => {
774 passed += 1;
775 Outcome {
776 ok: true,
777 line: format!(
778 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
779 p.display()
780 ),
781 }
782 }
783 },
784 None => Outcome {
785 ok: false,
786 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
787 },
788 };
789 print_check(&config_outcome);
790
791 let aliases = shell_aliases_outcome();
793 if aliases.ok {
794 passed += 1;
795 }
796 print_check(&aliases);
797
798 let mcp = mcp_config_outcome();
800 if mcp.ok {
801 passed += 1;
802 }
803 print_check(&mcp);
804
805 let port = port_3333_outcome();
807 if port.ok {
808 passed += 1;
809 }
810 print_check(&port);
811
812 let session_outcome = session_state_outcome();
814 if session_outcome.ok {
815 passed += 1;
816 }
817 print_check(&session_outcome);
818
819 let docker_outcomes = docker_env_outcomes();
821 for docker_check in &docker_outcomes {
822 if docker_check.ok {
823 passed += 1;
824 }
825 print_check(docker_check);
826 }
827
828 let pi = pi_outcome();
830 if let Some(ref pi_check) = pi {
831 if pi_check.ok {
832 passed += 1;
833 }
834 print_check(pi_check);
835 }
836
837 let integrity = crate::core::integrity::check();
839 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
840 if integrity_ok {
841 passed += 1;
842 }
843 let integrity_line = if integrity_ok {
844 format!(
845 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
846 integrity.repo
847 )
848 } else {
849 format!(
850 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
851 integrity.pkg_name, integrity.repo
852 )
853 };
854 print_check(&Outcome {
855 ok: integrity_ok,
856 line: integrity_line,
857 });
858
859 let cache_safety = cache_safety_outcome();
861 if cache_safety.ok {
862 passed += 1;
863 }
864 print_check(&cache_safety);
865
866 let claude_truncation = claude_truncation_outcome();
868 if let Some(ref ct) = claude_truncation {
869 if ct.ok {
870 passed += 1;
871 }
872 print_check(ct);
873 }
874
875 let mut effective_total = total + 3; effective_total += docker_outcomes.len() as u32;
877 if pi.is_some() {
878 effective_total += 1;
879 }
880 if claude_truncation.is_some() {
881 effective_total += 1;
882 }
883 println!();
884 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
885 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
886}
887
888fn cache_safety_outcome() -> Outcome {
889 use crate::core::neural::cache_alignment::CacheAlignedOutput;
890 use crate::core::provider_cache::ProviderCacheState;
891
892 let mut issues = Vec::new();
893
894 let mut aligned = CacheAlignedOutput::new();
895 aligned.add_stable_block("test", "stable content".into(), 1);
896 aligned.add_variable_block("test_var", "variable content".into(), 1);
897 let rendered = aligned.render();
898 if rendered.find("stable content").unwrap_or(usize::MAX)
899 > rendered.find("variable content").unwrap_or(0)
900 {
901 issues.push("cache_alignment: stable blocks not ordered first");
902 }
903
904 let mut state = ProviderCacheState::new();
905 let section = crate::core::provider_cache::CacheableSection::new(
906 "doctor_test",
907 "test content".into(),
908 crate::core::provider_cache::SectionPriority::System,
909 true,
910 );
911 state.mark_sent(§ion);
912 if state.needs_update(§ion) {
913 issues.push("provider_cache: hash tracking broken");
914 }
915
916 if issues.is_empty() {
917 Outcome {
918 ok: true,
919 line: format!(
920 "{BOLD}Cache safety{RST} {GREEN}cache_alignment + provider_cache operational{RST}"
921 ),
922 }
923 } else {
924 Outcome {
925 ok: false,
926 line: format!("{BOLD}Cache safety{RST} {RED}{}{RST}", issues.join("; ")),
927 }
928 }
929}
930
931fn claude_binary_exists() -> bool {
932 #[cfg(unix)]
933 {
934 std::process::Command::new("which")
935 .arg("claude")
936 .output()
937 .is_ok_and(|o| o.status.success())
938 }
939 #[cfg(windows)]
940 {
941 std::process::Command::new("where")
942 .arg("claude")
943 .output()
944 .is_ok_and(|o| o.status.success())
945 }
946}
947
948fn claude_truncation_outcome() -> Option<Outcome> {
949 let home = dirs::home_dir()?;
950 let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
951 || crate::core::editor_registry::claude_state_dir(&home).exists()
952 || claude_binary_exists();
953
954 if !claude_detected {
955 return None;
956 }
957
958 let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
959 let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
960
961 let has_rules = rules_path.exists();
962 let has_skill = skill_path.exists();
963
964 if has_rules && has_skill {
965 Some(Outcome {
966 ok: true,
967 line: format!(
968 "{BOLD}Claude Code instructions{RST} {GREEN}rules + skill installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
969 ),
970 })
971 } else if has_rules {
972 Some(Outcome {
973 ok: true,
974 line: format!(
975 "{BOLD}Claude Code instructions{RST} {GREEN}rules file installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
976 ),
977 })
978 } else {
979 Some(Outcome {
980 ok: false,
981 line: format!(
982 "{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}"
983 ),
984 })
985 }
986}
987
988pub fn run_compact() {
989 let (passed, total) = compact_score();
990 print_compact_status(passed, total);
991}
992
993pub fn run_cli(args: &[String]) -> i32 {
994 let fix = args.iter().any(|a| a == "--fix");
995 let json = args.iter().any(|a| a == "--json");
996 let help = args.iter().any(|a| a == "--help" || a == "-h");
997
998 if help {
999 println!("Usage:");
1000 println!(" lean-ctx doctor");
1001 println!(" lean-ctx doctor --fix [--json]");
1002 return 0;
1003 }
1004
1005 if !fix {
1006 run();
1007 return 0;
1008 }
1009
1010 match run_fix(&DoctorFixOptions { json }) {
1011 Ok(code) => code,
1012 Err(e) => {
1013 tracing::error!("doctor --fix failed: {e}");
1014 2
1015 }
1016 }
1017}
1018
1019struct DoctorFixOptions {
1020 json: bool,
1021}
1022
1023fn run_fix(opts: &DoctorFixOptions) -> Result<i32, String> {
1024 use crate::core::setup_report::{
1025 doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
1026 };
1027
1028 let _quiet_guard = opts
1029 .json
1030 .then(|| crate::setup::EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
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(i32::from(!report.success))
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 = crate::core::data_dir::lean_ctx_data_dir().ok();
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}