1mod fix;
4mod integrations;
5
6use std::net::TcpListener;
7use std::path::PathBuf;
8
9pub(super) const GREEN: &str = "\x1b[32m";
10const RED: &str = "\x1b[31m";
11pub(super) const BOLD: &str = "\x1b[1m";
12pub(super) const RST: &str = "\x1b[0m";
13pub(super) const DIM: &str = "\x1b[2m";
14pub(super) const WHITE: &str = "\x1b[97m";
15pub(super) const YELLOW: &str = "\x1b[33m";
16
17pub(super) struct Outcome {
18 pub ok: bool,
19 pub line: String,
20}
21
22fn print_check(outcome: &Outcome) {
23 let mark = if outcome.ok {
24 format!("{GREEN}✓{RST}")
25 } else {
26 format!("{RED}✗{RST}")
27 };
28 println!(" {mark} {}", outcome.line);
29}
30
31fn path_in_path_env() -> bool {
32 if let Ok(path) = std::env::var("PATH") {
33 for dir in std::env::split_paths(&path) {
34 if dir.join("lean-ctx").is_file() {
35 return true;
36 }
37 if cfg!(windows)
38 && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
39 {
40 return true;
41 }
42 }
43 }
44 false
45}
46
47pub(super) fn resolve_lean_ctx_binary() -> Option<PathBuf> {
48 if let Ok(path) = std::env::var("PATH") {
49 for dir in std::env::split_paths(&path) {
50 if cfg!(windows) {
51 let exe = dir.join("lean-ctx.exe");
52 if exe.is_file() {
53 return Some(exe);
54 }
55 let cmd = dir.join("lean-ctx.cmd");
56 if cmd.is_file() {
57 return Some(cmd);
58 }
59 } else {
60 let bin = dir.join("lean-ctx");
61 if bin.is_file() {
62 return Some(bin);
63 }
64 }
65 }
66 }
67 None
68}
69
70fn lean_ctx_version_from_path() -> Outcome {
71 let resolved = resolve_lean_ctx_binary();
72 let bin = resolved
73 .clone()
74 .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
75
76 let v = env!("CARGO_PKG_VERSION");
77 let note = match std::env::current_exe() {
78 Ok(exe) if exe == bin => format!("{DIM}(this binary){RST}"),
79 Ok(_) | Err(_) => format!("{DIM}(resolved: {}){RST}", bin.display()),
80 };
81 Outcome {
82 ok: true,
83 line: format!("{BOLD}lean-ctx version{RST} {WHITE}lean-ctx {v}{RST} {note}"),
84 }
85}
86
87fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
88 match std::fs::read_to_string(path) {
89 Ok(s) => s.contains("lean-ctx"),
90 Err(_) => false,
91 }
92}
93
94fn has_pipe_guard_in_content(content: &str) -> bool {
95 content.contains("! -t 1")
96 || content.contains("isatty stdout")
97 || content.contains("IsOutputRedirected")
98}
99
100fn rc_references_shell_hook(content: &str) -> bool {
101 content.contains("lean-ctx/shell-hook.") || content.contains("lean-ctx\\shell-hook.")
102}
103
104fn rc_has_pipe_guard(path: &PathBuf) -> bool {
105 match std::fs::read_to_string(path) {
106 Ok(s) => {
107 if has_pipe_guard_in_content(&s) {
108 return true;
109 }
110 if rc_references_shell_hook(&s) {
111 let dirs_to_check = hook_dirs();
112 for dir in &dirs_to_check {
113 for ext in &["zsh", "bash", "fish", "ps1"] {
114 let hook = dir.join(format!("shell-hook.{ext}"));
115 if let Ok(h) = std::fs::read_to_string(&hook) {
116 if has_pipe_guard_in_content(&h) {
117 return true;
118 }
119 }
120 }
121 }
122 }
123 false
124 }
125 Err(_) => false,
126 }
127}
128
129fn hook_dirs() -> Vec<std::path::PathBuf> {
130 let mut dirs = Vec::new();
131 if let Ok(d) = crate::core::data_dir::lean_ctx_data_dir() {
132 dirs.push(d);
133 }
134 if let Some(home) = dirs::home_dir() {
135 let legacy = home.join(".lean-ctx");
136 if !dirs.iter().any(|d| d == &legacy) {
137 dirs.push(legacy);
138 }
139 let xdg = home.join(".config").join("lean-ctx");
140 if !dirs.iter().any(|d| d == &xdg) {
141 dirs.push(xdg);
142 }
143 }
144 dirs
145}
146
147fn is_active_shell(rc_name: &str) -> bool {
148 let shell = std::env::var("SHELL").unwrap_or_default();
149 match rc_name {
150 "~/.zshrc" => shell.contains("zsh"),
151 "~/.bashrc" => shell.contains("bash") || shell.is_empty(),
152 "~/.config/fish/config.fish" => shell.contains("fish"),
153 _ => true,
154 }
155}
156
157pub(super) fn shell_aliases_outcome() -> Outcome {
158 let Some(home) = dirs::home_dir() else {
159 return Outcome {
160 ok: false,
161 line: format!("{BOLD}Shell aliases{RST} {RED}could not resolve home directory{RST}"),
162 };
163 };
164
165 let mut parts = Vec::new();
166 let mut needs_update = Vec::new();
167
168 let zsh = home.join(".zshrc");
169 if rc_contains_lean_ctx(&zsh) {
170 parts.push(format!("{DIM}~/.zshrc{RST}"));
171 if !rc_has_pipe_guard(&zsh) && is_active_shell("~/.zshrc") {
172 needs_update.push("~/.zshrc");
173 }
174 }
175 let bash = home.join(".bashrc");
176 if rc_contains_lean_ctx(&bash) {
177 parts.push(format!("{DIM}~/.bashrc{RST}"));
178 if !rc_has_pipe_guard(&bash) && is_active_shell("~/.bashrc") {
179 needs_update.push("~/.bashrc");
180 }
181 }
182
183 let fish = home.join(".config").join("fish").join("config.fish");
184 if rc_contains_lean_ctx(&fish) {
185 parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
186 if !rc_has_pipe_guard(&fish) && is_active_shell("~/.config/fish/config.fish") {
187 needs_update.push("~/.config/fish/config.fish");
188 }
189 }
190
191 #[cfg(windows)]
192 {
193 let ps_profile = home
194 .join("Documents")
195 .join("PowerShell")
196 .join("Microsoft.PowerShell_profile.ps1");
197 let ps_profile_legacy = home
198 .join("Documents")
199 .join("WindowsPowerShell")
200 .join("Microsoft.PowerShell_profile.ps1");
201 if rc_contains_lean_ctx(&ps_profile) {
202 parts.push(format!("{DIM}PowerShell profile{RST}"));
203 if !rc_has_pipe_guard(&ps_profile) {
204 needs_update.push("PowerShell profile");
205 }
206 } else if rc_contains_lean_ctx(&ps_profile_legacy) {
207 parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
208 if !rc_has_pipe_guard(&ps_profile_legacy) {
209 needs_update.push("WindowsPowerShell profile");
210 }
211 }
212 }
213
214 if parts.is_empty() {
215 let hint = if cfg!(windows) {
216 "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
217 } else {
218 "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
219 };
220 Outcome {
221 ok: false,
222 line: format!("{BOLD}Shell aliases{RST} {RED}{hint}{RST}"),
223 }
224 } else if !needs_update.is_empty() {
225 Outcome {
226 ok: false,
227 line: format!(
228 "{BOLD}Shell aliases{RST} {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
229 needs_update.join(", ")
230 ),
231 }
232 } else {
233 Outcome {
234 ok: true,
235 line: format!(
236 "{BOLD}Shell aliases{RST} {GREEN}lean-ctx referenced in {}{RST}",
237 parts.join(", ")
238 ),
239 }
240 }
241}
242
243struct McpLocation {
244 name: &'static str,
245 display: String,
246 path: PathBuf,
247}
248
249fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
250 let mut locations = vec![
251 McpLocation {
252 name: "Cursor",
253 display: "~/.cursor/mcp.json".into(),
254 path: home.join(".cursor").join("mcp.json"),
255 },
256 McpLocation {
257 name: "Claude Code",
258 display: format!(
259 "{}",
260 crate::core::editor_registry::claude_mcp_json_path(home).display()
261 ),
262 path: crate::core::editor_registry::claude_mcp_json_path(home),
263 },
264 McpLocation {
265 name: "Windsurf",
266 display: "~/.codeium/windsurf/mcp_config.json".into(),
267 path: home
268 .join(".codeium")
269 .join("windsurf")
270 .join("mcp_config.json"),
271 },
272 McpLocation {
273 name: "Codex",
274 display: {
275 let codex_dir =
276 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
277 format!("{}/config.toml", codex_dir.display())
278 },
279 path: crate::core::home::resolve_codex_dir()
280 .unwrap_or_else(|| home.join(".codex"))
281 .join("config.toml"),
282 },
283 McpLocation {
284 name: "Gemini CLI",
285 display: "~/.gemini/settings.json".into(),
286 path: home.join(".gemini").join("settings.json"),
287 },
288 McpLocation {
289 name: "Antigravity",
290 display: "~/.gemini/antigravity/mcp_config.json".into(),
291 path: home
292 .join(".gemini")
293 .join("antigravity")
294 .join("mcp_config.json"),
295 },
296 ];
297
298 #[cfg(unix)]
299 {
300 let zed_cfg = home.join(".config").join("zed").join("settings.json");
301 locations.push(McpLocation {
302 name: "Zed",
303 display: "~/.config/zed/settings.json".into(),
304 path: zed_cfg,
305 });
306 }
307
308 locations.push(McpLocation {
309 name: "Qwen Code",
310 display: "~/.qwen/settings.json".into(),
311 path: home.join(".qwen").join("settings.json"),
312 });
313 locations.push(McpLocation {
314 name: "Trae",
315 display: "~/.trae/mcp.json".into(),
316 path: home.join(".trae").join("mcp.json"),
317 });
318 locations.push(McpLocation {
319 name: "Amazon Q",
320 display: "~/.aws/amazonq/default.json".into(),
321 path: home.join(".aws").join("amazonq").join("default.json"),
322 });
323 locations.push(McpLocation {
324 name: "JetBrains",
325 display: "~/.jb-mcp.json".into(),
326 path: home.join(".jb-mcp.json"),
327 });
328 locations.push(McpLocation {
329 name: "AWS Kiro",
330 display: "~/.kiro/settings/mcp.json".into(),
331 path: home.join(".kiro").join("settings").join("mcp.json"),
332 });
333 locations.push(McpLocation {
334 name: "Verdent",
335 display: "~/.verdent/mcp.json".into(),
336 path: home.join(".verdent").join("mcp.json"),
337 });
338 locations.push(McpLocation {
339 name: "Crush",
340 display: "~/.config/crush/crush.json".into(),
341 path: home.join(".config").join("crush").join("crush.json"),
342 });
343 locations.push(McpLocation {
344 name: "Pi",
345 display: "~/.pi/agent/mcp.json".into(),
346 path: home.join(".pi").join("agent").join("mcp.json"),
347 });
348 locations.push(McpLocation {
349 name: "Amp",
350 display: "~/.config/amp/settings.json".into(),
351 path: home.join(".config").join("amp").join("settings.json"),
352 });
353
354 {
355 #[cfg(unix)]
356 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
357 #[cfg(unix)]
358 let opencode_display = "~/.config/opencode/opencode.json";
359
360 #[cfg(windows)]
361 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
362 std::path::PathBuf::from(appdata)
363 .join("opencode")
364 .join("opencode.json")
365 } else {
366 home.join(".config").join("opencode").join("opencode.json")
367 };
368 #[cfg(windows)]
369 let opencode_display = "%APPDATA%/opencode/opencode.json";
370
371 locations.push(McpLocation {
372 name: "OpenCode",
373 display: opencode_display.into(),
374 path: opencode_cfg,
375 });
376 }
377
378 #[cfg(target_os = "macos")]
379 {
380 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
381 locations.push(McpLocation {
382 name: "VS Code / Copilot",
383 display: "~/Library/Application Support/Code/User/mcp.json".into(),
384 path: vscode_mcp,
385 });
386 }
387 #[cfg(target_os = "linux")]
388 {
389 let vscode_mcp = home.join(".config/Code/User/mcp.json");
390 locations.push(McpLocation {
391 name: "VS Code / Copilot",
392 display: "~/.config/Code/User/mcp.json".into(),
393 path: vscode_mcp,
394 });
395 }
396 #[cfg(target_os = "windows")]
397 {
398 if let Ok(appdata) = std::env::var("APPDATA") {
399 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
400 locations.push(McpLocation {
401 name: "VS Code / Copilot",
402 display: "%APPDATA%/Code/User/mcp.json".into(),
403 path: vscode_mcp,
404 });
405 }
406 }
407
408 locations.push(McpLocation {
409 name: "Hermes Agent",
410 display: "~/.hermes/config.yaml".into(),
411 path: home.join(".hermes").join("config.yaml"),
412 });
413
414 {
415 let cline_path = crate::core::editor_registry::cline_mcp_path();
416 if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
417 locations.push(McpLocation {
418 name: "Cline",
419 display: cline_path.display().to_string(),
420 path: cline_path,
421 });
422 }
423 }
424 {
425 let roo_path = crate::core::editor_registry::roo_mcp_path();
426 if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
427 locations.push(McpLocation {
428 name: "Roo Code",
429 display: roo_path.display().to_string(),
430 path: roo_path,
431 });
432 }
433 }
434
435 locations
436}
437
438fn mcp_config_outcome() -> Outcome {
439 let Some(home) = dirs::home_dir() else {
440 return Outcome {
441 ok: false,
442 line: format!("{BOLD}MCP config{RST} {RED}could not resolve home directory{RST}"),
443 };
444 };
445
446 let locations = mcp_config_locations(&home);
447 let mut found: Vec<String> = Vec::new();
448 let mut exists_no_ref: Vec<String> = Vec::new();
449
450 for loc in &locations {
451 if let Ok(content) = std::fs::read_to_string(&loc.path) {
452 if has_lean_ctx_mcp_entry(&content) {
453 found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
454 } else {
455 exists_no_ref.push(loc.name.to_string());
456 }
457 }
458 }
459
460 found.sort();
461 found.dedup();
462 exists_no_ref.sort();
463 exists_no_ref.dedup();
464
465 if !found.is_empty() {
466 Outcome {
467 ok: true,
468 line: format!(
469 "{BOLD}MCP config{RST} {GREEN}lean-ctx found in: {}{RST}",
470 found.join(", ")
471 ),
472 }
473 } else if !exists_no_ref.is_empty() {
474 let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
475 let cause = if has_claude {
476 format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
477 } else {
478 String::new()
479 };
480 let hint = if has_claude {
481 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
482 } else {
483 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
484 };
485 Outcome {
486 ok: false,
487 line: format!(
488 "{BOLD}MCP config{RST} {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST} {cause} {hint}",
489 exists_no_ref.join(", "),
490 ),
491 }
492 } else {
493 Outcome {
494 ok: false,
495 line: format!(
496 "{BOLD}MCP config{RST} {YELLOW}no MCP config found{RST} {DIM}(run: lean-ctx setup){RST}"
497 ),
498 }
499 }
500}
501
502fn has_lean_ctx_mcp_entry(content: &str) -> bool {
503 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
504 if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
505 return servers.contains_key("lean-ctx");
506 }
507 if let Some(servers) = json
508 .get("mcp")
509 .and_then(|v| v.get("servers"))
510 .and_then(|v| v.as_object())
511 {
512 return servers.contains_key("lean-ctx");
513 }
514 }
515 content.contains("lean-ctx")
516}
517
518fn port_3333_outcome() -> Outcome {
519 match TcpListener::bind("127.0.0.1:3333") {
520 Ok(_listener) => Outcome {
521 ok: true,
522 line: format!("{BOLD}Dashboard port 3333{RST} {GREEN}available on 127.0.0.1{RST}"),
523 },
524 Err(e) => Outcome {
525 ok: false,
526 line: format!("{BOLD}Dashboard port 3333{RST} {RED}not available: {e}{RST}"),
527 },
528 }
529}
530
531fn pi_outcome() -> Option<Outcome> {
532 let pi_result = std::process::Command::new("pi").arg("--version").output();
533
534 match pi_result {
535 Ok(output) if output.status.success() => {
536 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
537 let has_plugin = std::process::Command::new("pi")
538 .args(["list"])
539 .output()
540 .is_ok_and(|o| {
541 o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
542 });
543
544 let has_mcp = dirs::home_dir()
545 .map(|h| h.join(".pi/agent/mcp.json"))
546 .and_then(|p| std::fs::read_to_string(p).ok())
547 .is_some_and(|c| c.contains("lean-ctx"));
548
549 if has_plugin && has_mcp {
550 Some(Outcome {
551 ok: true,
552 line: format!(
553 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
554 ),
555 })
556 } else if has_plugin {
557 Some(Outcome {
558 ok: true,
559 line: format!(
560 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx installed{RST} {DIM}(MCP not configured — embedded bridge active){RST}"
561 ),
562 })
563 } else {
564 Some(Outcome {
565 ok: false,
566 line: format!(
567 "{BOLD}Pi Coding Agent{RST} {YELLOW}{version}, but pi-lean-ctx not installed{RST} {DIM}(run: pi install npm:pi-lean-ctx){RST}"
568 ),
569 })
570 }
571 }
572 _ => None,
573 }
574}
575
576fn session_state_outcome() -> Outcome {
577 use crate::core::session::SessionState;
578
579 match SessionState::load_latest() {
580 Some(session) => {
581 let root = session
582 .project_root
583 .as_deref()
584 .unwrap_or("(not set)");
585 let cwd = session
586 .shell_cwd
587 .as_deref()
588 .unwrap_or("(not tracked)");
589 Outcome {
590 ok: true,
591 line: format!(
592 "{BOLD}Session state{RST} {GREEN}active{RST} {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
593 session.version
594 ),
595 }
596 }
597 None => Outcome {
598 ok: true,
599 line: format!(
600 "{BOLD}Session state{RST} {YELLOW}no active session{RST} {DIM}(will be created on first tool call){RST}"
601 ),
602 },
603 }
604}
605
606fn docker_env_outcomes() -> Vec<Outcome> {
607 if !crate::shell::is_container() {
608 return vec![];
609 }
610 let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
611 |_| "/root/.lean-ctx/env.sh".to_string(),
612 |d| d.join("env.sh").to_string_lossy().to_string(),
613 );
614
615 let mut outcomes = vec![];
616
617 let shell_name = std::env::var("SHELL").unwrap_or_default();
618 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
619
620 if is_bash {
621 let has_bash_env = std::env::var("BASH_ENV").is_ok();
622 outcomes.push(if has_bash_env {
623 Outcome {
624 ok: true,
625 line: format!(
626 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
627 std::env::var("BASH_ENV").unwrap_or_default()
628 ),
629 }
630 } else {
631 Outcome {
632 ok: false,
633 line: format!(
634 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
635 ),
636 }
637 });
638 }
639
640 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
641 outcomes.push(if has_claude_env {
642 Outcome {
643 ok: true,
644 line: format!(
645 "{BOLD}CLAUDE_ENV_FILE{RST} {GREEN}set{RST} {DIM}({}){RST}",
646 std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
647 ),
648 }
649 } else {
650 Outcome {
651 ok: false,
652 line: format!(
653 "{BOLD}CLAUDE_ENV_FILE{RST} {RED}not set{RST} {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
654 ),
655 }
656 });
657
658 outcomes
659}
660
661pub fn run() {
663 let mut passed = 0u32;
664 let total = 10u32;
665
666 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
667
668 let path_bin = resolve_lean_ctx_binary();
670 let also_in_path_dirs = path_in_path_env();
671 let bin_ok = path_bin.is_some() || also_in_path_dirs;
672 if bin_ok {
673 passed += 1;
674 }
675 let bin_line = if let Some(p) = path_bin {
676 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
677 } else if also_in_path_dirs {
678 format!(
679 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
680 )
681 } else {
682 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
683 };
684 print_check(&Outcome {
685 ok: bin_ok,
686 line: bin_line,
687 });
688
689 let ver = if bin_ok {
691 lean_ctx_version_from_path()
692 } else {
693 Outcome {
694 ok: false,
695 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
696 }
697 };
698 if ver.ok {
699 passed += 1;
700 }
701 print_check(&ver);
702
703 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
705 let dir_outcome = match &lean_dir {
706 Some(p) if p.is_dir() => {
707 passed += 1;
708 Outcome {
709 ok: true,
710 line: format!(
711 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
712 p.display()
713 ),
714 }
715 }
716 Some(p) => Outcome {
717 ok: false,
718 line: format!(
719 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
720 p.display()
721 ),
722 },
723 None => Outcome {
724 ok: false,
725 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
726 },
727 };
728 print_check(&dir_outcome);
729
730 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
732 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
733 Some(m) if m.is_file() => {
734 passed += 1;
735 let size = m.len();
736 let path_display = if let Some(p) = stats_path.as_ref() {
737 p.display().to_string()
738 } else {
739 String::new()
740 };
741 Outcome {
742 ok: true,
743 line: format!(
744 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{path_display}{RST}",
745 ),
746 }
747 }
748 Some(_m) => {
749 let path_display = if let Some(p) = stats_path.as_ref() {
750 p.display().to_string()
751 } else {
752 String::new()
753 };
754 Outcome {
755 ok: false,
756 line: format!(
757 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{path_display}{RST}",
758 ),
759 }
760 }
761 None => {
762 passed += 1;
763 Outcome {
764 ok: true,
765 line: match &stats_path {
766 Some(p) => format!(
767 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
768 p.display()
769 ),
770 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
771 },
772 }
773 }
774 };
775 print_check(&stats_outcome);
776
777 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
779 let config_outcome = match &config_path {
780 Some(p) => match std::fs::metadata(p) {
781 Ok(m) if m.is_file() => {
782 passed += 1;
783 Outcome {
784 ok: true,
785 line: format!(
786 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
787 p.display()
788 ),
789 }
790 }
791 Ok(_) => Outcome {
792 ok: false,
793 line: format!(
794 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
795 p.display()
796 ),
797 },
798 Err(_) => {
799 passed += 1;
800 Outcome {
801 ok: true,
802 line: format!(
803 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
804 p.display()
805 ),
806 }
807 }
808 },
809 None => Outcome {
810 ok: false,
811 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
812 },
813 };
814 print_check(&config_outcome);
815
816 let proxy_outcome = proxy_upstream_outcome();
818 if proxy_outcome.ok {
819 passed += 1;
820 }
821 print_check(&proxy_outcome);
822
823 let aliases = shell_aliases_outcome();
825 if aliases.ok {
826 passed += 1;
827 }
828 print_check(&aliases);
829
830 let mcp = mcp_config_outcome();
832 if mcp.ok {
833 passed += 1;
834 }
835 print_check(&mcp);
836
837 let skill = skill_files_outcome();
839 if skill.ok {
840 passed += 1;
841 }
842 print_check(&skill);
843
844 let port = port_3333_outcome();
846 if port.ok {
847 passed += 1;
848 }
849 print_check(&port);
850
851 #[cfg(unix)]
853 let daemon_outcome = if crate::daemon::is_daemon_running() {
854 let pid_path = crate::daemon::daemon_pid_path();
855 let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
856 Outcome {
857 ok: true,
858 line: format!(
859 "{BOLD}Daemon{RST} {GREEN}running (PID {}){RST}",
860 pid_str.trim()
861 ),
862 }
863 } else {
864 Outcome {
865 ok: true,
866 line: format!(
867 "{BOLD}Daemon{RST} {YELLOW}not running{RST} {DIM}(run: lean-ctx serve -d){RST}"
868 ),
869 }
870 };
871 #[cfg(not(unix))]
872 let daemon_outcome = Outcome {
873 ok: true,
874 line: format!("{BOLD}Daemon{RST} {DIM}not supported on this platform{RST}"),
875 };
876 if daemon_outcome.ok {
877 passed += 1;
878 }
879 print_check(&daemon_outcome);
880
881 let session_outcome = session_state_outcome();
883 if session_outcome.ok {
884 passed += 1;
885 }
886 print_check(&session_outcome);
887
888 let docker_outcomes = docker_env_outcomes();
890 for docker_check in &docker_outcomes {
891 if docker_check.ok {
892 passed += 1;
893 }
894 print_check(docker_check);
895 }
896
897 let pi = pi_outcome();
899 if let Some(ref pi_check) = pi {
900 if pi_check.ok {
901 passed += 1;
902 }
903 print_check(pi_check);
904 }
905
906 let integrity = crate::core::integrity::check();
908 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
909 if integrity_ok {
910 passed += 1;
911 }
912 let integrity_line = if integrity_ok {
913 format!(
914 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
915 integrity.repo
916 )
917 } else {
918 format!(
919 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
920 integrity.pkg_name, integrity.repo
921 )
922 };
923 print_check(&Outcome {
924 ok: integrity_ok,
925 line: integrity_line,
926 });
927
928 let cache_safety = cache_safety_outcome();
930 if cache_safety.ok {
931 passed += 1;
932 }
933 print_check(&cache_safety);
934
935 let claude_truncation = claude_truncation_outcome();
937 if let Some(ref ct) = claude_truncation {
938 if ct.ok {
939 passed += 1;
940 }
941 print_check(ct);
942 }
943
944 let bm25_health = bm25_cache_health_outcome();
946 if bm25_health.ok {
947 passed += 1;
948 }
949 print_check(&bm25_health);
950
951 let mem_profile = memory_profile_outcome();
953 passed += 1;
954 print_check(&mem_profile);
955
956 let mem_cleanup = memory_cleanup_outcome();
958 passed += 1;
959 print_check(&mem_cleanup);
960
961 let mut effective_total = total + 7; effective_total += docker_outcomes.len() as u32;
963 if pi.is_some() {
964 effective_total += 1;
965 }
966 if claude_truncation.is_some() {
967 effective_total += 1;
968 }
969 println!();
970 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
971 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
972}
973
974fn skill_files_outcome() -> Outcome {
975 let Some(home) = dirs::home_dir() else {
976 return Outcome {
977 ok: false,
978 line: format!("{BOLD}SKILL.md{RST} {RED}could not resolve home directory{RST}"),
979 };
980 };
981
982 let candidates = [
983 ("Claude Code", home.join(".claude/skills/lean-ctx/SKILL.md")),
984 ("Cursor", home.join(".cursor/skills/lean-ctx/SKILL.md")),
985 (
986 "Codex CLI",
987 crate::core::home::resolve_codex_dir()
988 .unwrap_or_else(|| home.join(".codex"))
989 .join("skills/lean-ctx/SKILL.md"),
990 ),
991 (
992 "GitHub Copilot",
993 home.join(".vscode/skills/lean-ctx/SKILL.md"),
994 ),
995 ];
996
997 let mut found: Vec<&str> = Vec::new();
998 for (name, path) in &candidates {
999 if path.exists() {
1000 found.push(name);
1001 }
1002 }
1003
1004 if found.is_empty() {
1005 Outcome {
1006 ok: false,
1007 line: format!(
1008 "{BOLD}SKILL.md{RST} {YELLOW}not installed{RST} {DIM}(run: lean-ctx setup){RST}"
1009 ),
1010 }
1011 } else {
1012 Outcome {
1013 ok: true,
1014 line: format!(
1015 "{BOLD}SKILL.md{RST} {GREEN}installed for {}{RST}",
1016 found.join(", ")
1017 ),
1018 }
1019 }
1020}
1021
1022fn proxy_upstream_outcome() -> Outcome {
1023 use crate::core::config::{is_local_proxy_url, Config, ProxyProvider};
1024
1025 let cfg = Config::load();
1026 let checks = [
1027 (
1028 "Anthropic",
1029 "proxy.anthropic_upstream",
1030 cfg.proxy.resolve_upstream(ProxyProvider::Anthropic),
1031 ),
1032 (
1033 "OpenAI",
1034 "proxy.openai_upstream",
1035 cfg.proxy.resolve_upstream(ProxyProvider::OpenAi),
1036 ),
1037 (
1038 "Gemini",
1039 "proxy.gemini_upstream",
1040 cfg.proxy.resolve_upstream(ProxyProvider::Gemini),
1041 ),
1042 ];
1043
1044 let mut custom = Vec::new();
1045 for (label, key, resolved) in &checks {
1046 if is_local_proxy_url(resolved) {
1047 return Outcome {
1048 ok: false,
1049 line: format!(
1050 "{BOLD}Proxy upstream{RST} {RED}{label} upstream points back to local proxy{RST} {YELLOW}run: lean-ctx config set {key} <url>{RST}"
1051 ),
1052 };
1053 }
1054 if !resolved.starts_with("http://") && !resolved.starts_with("https://") {
1055 return Outcome {
1056 ok: false,
1057 line: format!(
1058 "{BOLD}Proxy upstream{RST} {RED}invalid {label} upstream{RST} {YELLOW}set {key} to an http(s) URL{RST}"
1059 ),
1060 };
1061 }
1062 let is_default = matches!(
1063 *label,
1064 "Anthropic" if resolved == "https://api.anthropic.com"
1065 ) || matches!(
1066 *label,
1067 "OpenAI" if resolved == "https://api.openai.com"
1068 ) || matches!(
1069 *label,
1070 "Gemini" if resolved == "https://generativelanguage.googleapis.com"
1071 );
1072 if !is_default {
1073 custom.push(format!("{label}={resolved}"));
1074 }
1075 }
1076
1077 if custom.is_empty() {
1078 Outcome {
1079 ok: true,
1080 line: format!("{BOLD}Proxy upstream{RST} {GREEN}provider defaults{RST}"),
1081 }
1082 } else {
1083 Outcome {
1084 ok: true,
1085 line: format!(
1086 "{BOLD}Proxy upstream{RST} {GREEN}custom: {}{RST}",
1087 custom.join(", ")
1088 ),
1089 }
1090 }
1091}
1092
1093fn cache_safety_outcome() -> Outcome {
1094 use crate::core::neural::cache_alignment::CacheAlignedOutput;
1095 use crate::core::provider_cache::ProviderCacheState;
1096
1097 let mut issues = Vec::new();
1098
1099 let mut aligned = CacheAlignedOutput::new();
1100 aligned.add_stable_block("test", "stable content".into(), 1);
1101 aligned.add_variable_block("test_var", "variable content".into(), 1);
1102 let rendered = aligned.render();
1103 if rendered.find("stable content").unwrap_or(usize::MAX)
1104 > rendered.find("variable content").unwrap_or(0)
1105 {
1106 issues.push("cache_alignment: stable blocks not ordered first");
1107 }
1108
1109 let mut state = ProviderCacheState::new();
1110 let section = crate::core::provider_cache::CacheableSection::new(
1111 "doctor_test",
1112 "test content".into(),
1113 crate::core::provider_cache::SectionPriority::System,
1114 true,
1115 );
1116 state.mark_sent(§ion);
1117 if state.needs_update(§ion) {
1118 issues.push("provider_cache: hash tracking broken");
1119 }
1120
1121 if issues.is_empty() {
1122 Outcome {
1123 ok: true,
1124 line: format!(
1125 "{BOLD}Cache safety{RST} {GREEN}cache_alignment + provider_cache operational{RST}"
1126 ),
1127 }
1128 } else {
1129 Outcome {
1130 ok: false,
1131 line: format!("{BOLD}Cache safety{RST} {RED}{}{RST}", issues.join("; ")),
1132 }
1133 }
1134}
1135
1136pub(super) fn claude_binary_exists() -> bool {
1137 #[cfg(unix)]
1138 {
1139 std::process::Command::new("which")
1140 .arg("claude")
1141 .output()
1142 .is_ok_and(|o| o.status.success())
1143 }
1144 #[cfg(windows)]
1145 {
1146 std::process::Command::new("where")
1147 .arg("claude")
1148 .output()
1149 .is_ok_and(|o| o.status.success())
1150 }
1151}
1152
1153fn claude_truncation_outcome() -> Option<Outcome> {
1154 let home = dirs::home_dir()?;
1155 let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1156 || crate::core::editor_registry::claude_state_dir(&home).exists()
1157 || claude_binary_exists();
1158
1159 if !claude_detected {
1160 return None;
1161 }
1162
1163 let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1164 let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1165
1166 let has_rules = rules_path.exists();
1167 let has_skill = skill_path.exists();
1168
1169 if has_rules && has_skill {
1170 Some(Outcome {
1171 ok: true,
1172 line: format!(
1173 "{BOLD}Claude Code instructions{RST} {GREEN}rules + skill installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1174 ),
1175 })
1176 } else if has_rules {
1177 Some(Outcome {
1178 ok: true,
1179 line: format!(
1180 "{BOLD}Claude Code instructions{RST} {GREEN}rules file installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1181 ),
1182 })
1183 } else {
1184 Some(Outcome {
1185 ok: false,
1186 line: format!(
1187 "{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}"
1188 ),
1189 })
1190 }
1191}
1192
1193fn bm25_cache_health_outcome() -> Outcome {
1194 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1195 return Outcome {
1196 ok: true,
1197 line: format!("{BOLD}BM25 cache{RST} {DIM}skipped (no data dir){RST}"),
1198 };
1199 };
1200
1201 let vectors_dir = data_dir.join("vectors");
1202 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
1203 return Outcome {
1204 ok: true,
1205 line: format!("{BOLD}BM25 cache{RST} {GREEN}no vector dirs{RST}"),
1206 };
1207 };
1208
1209 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
1210 let warn_bytes = 100 * 1024 * 1024; let mut total_dirs = 0u32;
1212 let mut total_bytes = 0u64;
1213 let mut oversized: Vec<(String, u64)> = Vec::new();
1214 let mut warnings: Vec<(String, u64)> = Vec::new();
1215 let mut quarantined_count = 0u32;
1216
1217 for entry in entries.flatten() {
1218 let dir = entry.path();
1219 if !dir.is_dir() {
1220 continue;
1221 }
1222 total_dirs += 1;
1223
1224 if dir.join("bm25_index.json.quarantined").exists() {
1225 quarantined_count += 1;
1226 }
1227
1228 let index_path = dir.join("bm25_index.json");
1229 if let Ok(meta) = std::fs::metadata(&index_path) {
1230 let size = meta.len();
1231 total_bytes += size;
1232 let display = index_path.display().to_string();
1233 if size > max_bytes {
1234 oversized.push((display, size));
1235 } else if size > warn_bytes {
1236 warnings.push((display, size));
1237 }
1238 }
1239 }
1240
1241 if !oversized.is_empty() {
1242 let details: Vec<String> = oversized
1243 .iter()
1244 .map(|(p, s)| format!("{p} ({:.1} GB)", *s as f64 / 1_073_741_824.0))
1245 .collect();
1246 return Outcome {
1247 ok: false,
1248 line: format!(
1249 "{BOLD}BM25 cache{RST} {RED}{} index(es) exceed limit ({:.0} MB){RST}: {} {DIM}(run: lean-ctx cache prune){RST}",
1250 oversized.len(),
1251 max_bytes / (1024 * 1024),
1252 details.join(", ")
1253 ),
1254 };
1255 }
1256
1257 if !warnings.is_empty() {
1258 let details: Vec<String> = warnings
1259 .iter()
1260 .map(|(p, s)| format!("{p} ({:.0} MB)", *s as f64 / 1_048_576.0))
1261 .collect();
1262 return Outcome {
1263 ok: true,
1264 line: format!(
1265 "{BOLD}BM25 cache{RST} {YELLOW}{} large index(es) (>100 MB){RST}: {} {DIM}(consider extra_ignore_patterns){RST}",
1266 warnings.len(),
1267 details.join(", ")
1268 ),
1269 };
1270 }
1271
1272 let quarantine_note = if quarantined_count > 0 {
1273 format!(" {YELLOW}{quarantined_count} quarantined (run: lean-ctx cache prune){RST}")
1274 } else {
1275 String::new()
1276 };
1277
1278 Outcome {
1279 ok: true,
1280 line: format!(
1281 "{BOLD}BM25 cache{RST} {GREEN}{total_dirs} index(es), {:.1} MB total{RST}{quarantine_note}",
1282 total_bytes as f64 / 1_048_576.0
1283 ),
1284 }
1285}
1286
1287pub fn run_compact() {
1288 let (passed, total) = compact_score();
1289 print_compact_status(passed, total);
1290}
1291
1292pub fn run_cli(args: &[String]) -> i32 {
1293 let (sub, rest) = match args.first().map(String::as_str) {
1294 Some("integrations") => ("integrations", &args[1..]),
1295 _ => ("", args),
1296 };
1297
1298 let fix = rest.iter().any(|a| a == "--fix");
1299 let json = rest.iter().any(|a| a == "--json");
1300 let help = rest.iter().any(|a| a == "--help" || a == "-h");
1301
1302 if help {
1303 println!("Usage:");
1304 println!(" lean-ctx doctor");
1305 println!(" lean-ctx doctor integrations [--json]");
1306 println!(" lean-ctx doctor --fix [--json]");
1307 return 0;
1308 }
1309
1310 if sub == "integrations" {
1311 if fix {
1312 let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
1313 }
1314 return integrations::run_integrations(&integrations::IntegrationsOptions { json });
1315 }
1316
1317 if !fix {
1318 run();
1319 return 0;
1320 }
1321
1322 match fix::run_fix(&fix::DoctorFixOptions { json }) {
1323 Ok(code) => code,
1324 Err(e) => {
1325 tracing::error!("doctor --fix failed: {e}");
1326 2
1327 }
1328 }
1329}
1330
1331pub fn compact_score() -> (u32, u32) {
1332 let mut passed = 0u32;
1333 let total = 6u32;
1334
1335 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1336 passed += 1;
1337 }
1338 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
1339 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1340 passed += 1;
1341 }
1342 if lean_dir
1343 .as_ref()
1344 .map(|d| d.join("stats.json"))
1345 .and_then(|p| std::fs::metadata(p).ok())
1346 .is_some_and(|m| m.is_file())
1347 {
1348 passed += 1;
1349 }
1350 if shell_aliases_outcome().ok {
1351 passed += 1;
1352 }
1353 if mcp_config_outcome().ok {
1354 passed += 1;
1355 }
1356 if skill_files_outcome().ok {
1357 passed += 1;
1358 }
1359
1360 (passed, total)
1361}
1362
1363pub(super) fn print_compact_status(passed: u32, total: u32) {
1364 let status = if passed == total {
1365 format!("{GREEN}✓ All {total} checks passed{RST}")
1366 } else {
1367 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1368 };
1369 println!(" {status}");
1370}
1371
1372fn memory_profile_outcome() -> Outcome {
1373 let cfg = crate::core::config::Config::load();
1374 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1375 let (label, detail) = match profile {
1376 crate::core::config::MemoryProfile::Low => {
1377 ("low", "embeddings+semantic cache disabled, BM25 64 MB")
1378 }
1379 crate::core::config::MemoryProfile::Balanced => {
1380 ("balanced", "default — BM25 128 MB, single embedding engine")
1381 }
1382 crate::core::config::MemoryProfile::Performance => {
1383 ("performance", "full caches, BM25 512 MB")
1384 }
1385 };
1386 let source = if crate::core::config::MemoryProfile::from_env().is_some() {
1387 "env"
1388 } else if cfg.memory_profile != crate::core::config::MemoryProfile::default() {
1389 "config"
1390 } else {
1391 "default"
1392 };
1393 Outcome {
1394 ok: true,
1395 line: format!(
1396 "{BOLD}Memory profile{RST} {GREEN}{label}{RST} {DIM}({source}: {detail}){RST}"
1397 ),
1398 }
1399}
1400
1401fn memory_cleanup_outcome() -> Outcome {
1402 let cfg = crate::core::config::Config::load();
1403 let cleanup = crate::core::config::MemoryCleanup::effective(&cfg);
1404 let (label, detail) = match cleanup {
1405 crate::core::config::MemoryCleanup::Aggressive => (
1406 "aggressive",
1407 "cache cleared after 5 min idle, single-IDE optimized",
1408 ),
1409 crate::core::config::MemoryCleanup::Shared => (
1410 "shared",
1411 "cache retained 30 min, multi-IDE/multi-model optimized",
1412 ),
1413 };
1414 let source = if crate::core::config::MemoryCleanup::from_env().is_some() {
1415 "env"
1416 } else if cfg.memory_cleanup != crate::core::config::MemoryCleanup::default() {
1417 "config"
1418 } else {
1419 "default"
1420 };
1421 Outcome {
1422 ok: true,
1423 line: format!(
1424 "{BOLD}Memory cleanup{RST} {GREEN}{label}{RST} {DIM}({source}: {detail}){RST}"
1425 ),
1426 }
1427}