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