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: "~/.codex/config.toml".into(),
275 path: home.join(".codex").join("config.toml"),
276 },
277 McpLocation {
278 name: "Gemini CLI",
279 display: "~/.gemini/settings.json".into(),
280 path: home.join(".gemini").join("settings.json"),
281 },
282 McpLocation {
283 name: "Antigravity",
284 display: "~/.gemini/antigravity/mcp_config.json".into(),
285 path: home
286 .join(".gemini")
287 .join("antigravity")
288 .join("mcp_config.json"),
289 },
290 ];
291
292 #[cfg(unix)]
293 {
294 let zed_cfg = home.join(".config").join("zed").join("settings.json");
295 locations.push(McpLocation {
296 name: "Zed",
297 display: "~/.config/zed/settings.json".into(),
298 path: zed_cfg,
299 });
300 }
301
302 locations.push(McpLocation {
303 name: "Qwen Code",
304 display: "~/.qwen/settings.json".into(),
305 path: home.join(".qwen").join("settings.json"),
306 });
307 locations.push(McpLocation {
308 name: "Trae",
309 display: "~/.trae/mcp.json".into(),
310 path: home.join(".trae").join("mcp.json"),
311 });
312 locations.push(McpLocation {
313 name: "Amazon Q",
314 display: "~/.aws/amazonq/default.json".into(),
315 path: home.join(".aws").join("amazonq").join("default.json"),
316 });
317 locations.push(McpLocation {
318 name: "JetBrains",
319 display: "~/.jb-mcp.json".into(),
320 path: home.join(".jb-mcp.json"),
321 });
322 locations.push(McpLocation {
323 name: "AWS Kiro",
324 display: "~/.kiro/settings/mcp.json".into(),
325 path: home.join(".kiro").join("settings").join("mcp.json"),
326 });
327 locations.push(McpLocation {
328 name: "Verdent",
329 display: "~/.verdent/mcp.json".into(),
330 path: home.join(".verdent").join("mcp.json"),
331 });
332 locations.push(McpLocation {
333 name: "Crush",
334 display: "~/.config/crush/crush.json".into(),
335 path: home.join(".config").join("crush").join("crush.json"),
336 });
337 locations.push(McpLocation {
338 name: "Pi",
339 display: "~/.pi/agent/mcp.json".into(),
340 path: home.join(".pi").join("agent").join("mcp.json"),
341 });
342 locations.push(McpLocation {
343 name: "Amp",
344 display: "~/.config/amp/settings.json".into(),
345 path: home.join(".config").join("amp").join("settings.json"),
346 });
347
348 {
349 #[cfg(unix)]
350 let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
351 #[cfg(unix)]
352 let opencode_display = "~/.config/opencode/opencode.json";
353
354 #[cfg(windows)]
355 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
356 std::path::PathBuf::from(appdata)
357 .join("opencode")
358 .join("opencode.json")
359 } else {
360 home.join(".config").join("opencode").join("opencode.json")
361 };
362 #[cfg(windows)]
363 let opencode_display = "%APPDATA%/opencode/opencode.json";
364
365 locations.push(McpLocation {
366 name: "OpenCode",
367 display: opencode_display.into(),
368 path: opencode_cfg,
369 });
370 }
371
372 #[cfg(target_os = "macos")]
373 {
374 let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
375 locations.push(McpLocation {
376 name: "VS Code / Copilot",
377 display: "~/Library/Application Support/Code/User/mcp.json".into(),
378 path: vscode_mcp,
379 });
380 }
381 #[cfg(target_os = "linux")]
382 {
383 let vscode_mcp = home.join(".config/Code/User/mcp.json");
384 locations.push(McpLocation {
385 name: "VS Code / Copilot",
386 display: "~/.config/Code/User/mcp.json".into(),
387 path: vscode_mcp,
388 });
389 }
390 #[cfg(target_os = "windows")]
391 {
392 if let Ok(appdata) = std::env::var("APPDATA") {
393 let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
394 locations.push(McpLocation {
395 name: "VS Code / Copilot",
396 display: "%APPDATA%/Code/User/mcp.json".into(),
397 path: vscode_mcp,
398 });
399 }
400 }
401
402 locations.push(McpLocation {
403 name: "Hermes Agent",
404 display: "~/.hermes/config.yaml".into(),
405 path: home.join(".hermes").join("config.yaml"),
406 });
407
408 {
409 let cline_path = crate::core::editor_registry::cline_mcp_path();
410 if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
411 locations.push(McpLocation {
412 name: "Cline",
413 display: cline_path.display().to_string(),
414 path: cline_path,
415 });
416 }
417 }
418 {
419 let roo_path = crate::core::editor_registry::roo_mcp_path();
420 if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
421 locations.push(McpLocation {
422 name: "Roo Code",
423 display: roo_path.display().to_string(),
424 path: roo_path,
425 });
426 }
427 }
428
429 locations
430}
431
432fn mcp_config_outcome() -> Outcome {
433 let Some(home) = dirs::home_dir() else {
434 return Outcome {
435 ok: false,
436 line: format!("{BOLD}MCP config{RST} {RED}could not resolve home directory{RST}"),
437 };
438 };
439
440 let locations = mcp_config_locations(&home);
441 let mut found: Vec<String> = Vec::new();
442 let mut exists_no_ref: Vec<String> = Vec::new();
443
444 for loc in &locations {
445 if let Ok(content) = std::fs::read_to_string(&loc.path) {
446 if has_lean_ctx_mcp_entry(&content) {
447 found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
448 } else {
449 exists_no_ref.push(loc.name.to_string());
450 }
451 }
452 }
453
454 found.sort();
455 found.dedup();
456 exists_no_ref.sort();
457 exists_no_ref.dedup();
458
459 if !found.is_empty() {
460 Outcome {
461 ok: true,
462 line: format!(
463 "{BOLD}MCP config{RST} {GREEN}lean-ctx found in: {}{RST}",
464 found.join(", ")
465 ),
466 }
467 } else if !exists_no_ref.is_empty() {
468 let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
469 let cause = if has_claude {
470 format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
471 } else {
472 String::new()
473 };
474 let hint = if has_claude {
475 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
476 } else {
477 format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
478 };
479 Outcome {
480 ok: false,
481 line: format!(
482 "{BOLD}MCP config{RST} {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST} {cause} {hint}",
483 exists_no_ref.join(", "),
484 ),
485 }
486 } else {
487 Outcome {
488 ok: false,
489 line: format!(
490 "{BOLD}MCP config{RST} {YELLOW}no MCP config found{RST} {DIM}(run: lean-ctx setup){RST}"
491 ),
492 }
493 }
494}
495
496fn has_lean_ctx_mcp_entry(content: &str) -> bool {
497 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
498 if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
499 return servers.contains_key("lean-ctx");
500 }
501 if let Some(servers) = json
502 .get("mcp")
503 .and_then(|v| v.get("servers"))
504 .and_then(|v| v.as_object())
505 {
506 return servers.contains_key("lean-ctx");
507 }
508 }
509 content.contains("lean-ctx")
510}
511
512fn port_3333_outcome() -> Outcome {
513 match TcpListener::bind("127.0.0.1:3333") {
514 Ok(_listener) => Outcome {
515 ok: true,
516 line: format!("{BOLD}Dashboard port 3333{RST} {GREEN}available on 127.0.0.1{RST}"),
517 },
518 Err(e) => Outcome {
519 ok: false,
520 line: format!("{BOLD}Dashboard port 3333{RST} {RED}not available: {e}{RST}"),
521 },
522 }
523}
524
525fn pi_outcome() -> Option<Outcome> {
526 let pi_result = std::process::Command::new("pi").arg("--version").output();
527
528 match pi_result {
529 Ok(output) if output.status.success() => {
530 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
531 let has_plugin = std::process::Command::new("pi")
532 .args(["list"])
533 .output()
534 .is_ok_and(|o| {
535 o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
536 });
537
538 let has_mcp = dirs::home_dir()
539 .map(|h| h.join(".pi/agent/mcp.json"))
540 .and_then(|p| std::fs::read_to_string(p).ok())
541 .is_some_and(|c| c.contains("lean-ctx"));
542
543 if has_plugin && has_mcp {
544 Some(Outcome {
545 ok: true,
546 line: format!(
547 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
548 ),
549 })
550 } else if has_plugin {
551 Some(Outcome {
552 ok: true,
553 line: format!(
554 "{BOLD}Pi Coding Agent{RST} {GREEN}{version}, pi-lean-ctx installed{RST} {DIM}(MCP not configured — embedded bridge active){RST}"
555 ),
556 })
557 } else {
558 Some(Outcome {
559 ok: false,
560 line: format!(
561 "{BOLD}Pi Coding Agent{RST} {YELLOW}{version}, but pi-lean-ctx not installed{RST} {DIM}(run: pi install npm:pi-lean-ctx){RST}"
562 ),
563 })
564 }
565 }
566 _ => None,
567 }
568}
569
570fn session_state_outcome() -> Outcome {
571 use crate::core::session::SessionState;
572
573 match SessionState::load_latest() {
574 Some(session) => {
575 let root = session
576 .project_root
577 .as_deref()
578 .unwrap_or("(not set)");
579 let cwd = session
580 .shell_cwd
581 .as_deref()
582 .unwrap_or("(not tracked)");
583 Outcome {
584 ok: true,
585 line: format!(
586 "{BOLD}Session state{RST} {GREEN}active{RST} {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
587 session.version
588 ),
589 }
590 }
591 None => Outcome {
592 ok: true,
593 line: format!(
594 "{BOLD}Session state{RST} {YELLOW}no active session{RST} {DIM}(will be created on first tool call){RST}"
595 ),
596 },
597 }
598}
599
600fn docker_env_outcomes() -> Vec<Outcome> {
601 if !crate::shell::is_container() {
602 return vec![];
603 }
604 let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
605 |_| "/root/.lean-ctx/env.sh".to_string(),
606 |d| d.join("env.sh").to_string_lossy().to_string(),
607 );
608
609 let mut outcomes = vec![];
610
611 let shell_name = std::env::var("SHELL").unwrap_or_default();
612 let is_bash = shell_name.contains("bash") || shell_name.is_empty();
613
614 if is_bash {
615 let has_bash_env = std::env::var("BASH_ENV").is_ok();
616 outcomes.push(if has_bash_env {
617 Outcome {
618 ok: true,
619 line: format!(
620 "{BOLD}BASH_ENV{RST} {GREEN}set{RST} {DIM}({}){RST}",
621 std::env::var("BASH_ENV").unwrap_or_default()
622 ),
623 }
624 } else {
625 Outcome {
626 ok: false,
627 line: format!(
628 "{BOLD}BASH_ENV{RST} {RED}not set{RST} {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
629 ),
630 }
631 });
632 }
633
634 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
635 outcomes.push(if has_claude_env {
636 Outcome {
637 ok: true,
638 line: format!(
639 "{BOLD}CLAUDE_ENV_FILE{RST} {GREEN}set{RST} {DIM}({}){RST}",
640 std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
641 ),
642 }
643 } else {
644 Outcome {
645 ok: false,
646 line: format!(
647 "{BOLD}CLAUDE_ENV_FILE{RST} {RED}not set{RST} {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
648 ),
649 }
650 });
651
652 outcomes
653}
654
655pub fn run() {
657 let mut passed = 0u32;
658 let total = 10u32;
659
660 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
661
662 let path_bin = resolve_lean_ctx_binary();
664 let also_in_path_dirs = path_in_path_env();
665 let bin_ok = path_bin.is_some() || also_in_path_dirs;
666 if bin_ok {
667 passed += 1;
668 }
669 let bin_line = if let Some(p) = path_bin {
670 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
671 } else if also_in_path_dirs {
672 format!(
673 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
674 )
675 } else {
676 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
677 };
678 print_check(&Outcome {
679 ok: bin_ok,
680 line: bin_line,
681 });
682
683 let ver = if bin_ok {
685 lean_ctx_version_from_path()
686 } else {
687 Outcome {
688 ok: false,
689 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
690 }
691 };
692 if ver.ok {
693 passed += 1;
694 }
695 print_check(&ver);
696
697 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
699 let dir_outcome = match &lean_dir {
700 Some(p) if p.is_dir() => {
701 passed += 1;
702 Outcome {
703 ok: true,
704 line: format!(
705 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
706 p.display()
707 ),
708 }
709 }
710 Some(p) => Outcome {
711 ok: false,
712 line: format!(
713 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
714 p.display()
715 ),
716 },
717 None => Outcome {
718 ok: false,
719 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
720 },
721 };
722 print_check(&dir_outcome);
723
724 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
726 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
727 Some(m) if m.is_file() => {
728 passed += 1;
729 let size = m.len();
730 let path_display = if let Some(p) = stats_path.as_ref() {
731 p.display().to_string()
732 } else {
733 String::new()
734 };
735 Outcome {
736 ok: true,
737 line: format!(
738 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{path_display}{RST}",
739 ),
740 }
741 }
742 Some(_m) => {
743 let path_display = if let Some(p) = stats_path.as_ref() {
744 p.display().to_string()
745 } else {
746 String::new()
747 };
748 Outcome {
749 ok: false,
750 line: format!(
751 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{path_display}{RST}",
752 ),
753 }
754 }
755 None => {
756 passed += 1;
757 Outcome {
758 ok: true,
759 line: match &stats_path {
760 Some(p) => format!(
761 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
762 p.display()
763 ),
764 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
765 },
766 }
767 }
768 };
769 print_check(&stats_outcome);
770
771 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
773 let config_outcome = match &config_path {
774 Some(p) => match std::fs::metadata(p) {
775 Ok(m) if m.is_file() => {
776 passed += 1;
777 Outcome {
778 ok: true,
779 line: format!(
780 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
781 p.display()
782 ),
783 }
784 }
785 Ok(_) => Outcome {
786 ok: false,
787 line: format!(
788 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
789 p.display()
790 ),
791 },
792 Err(_) => {
793 passed += 1;
794 Outcome {
795 ok: true,
796 line: format!(
797 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
798 p.display()
799 ),
800 }
801 }
802 },
803 None => Outcome {
804 ok: false,
805 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
806 },
807 };
808 print_check(&config_outcome);
809
810 let proxy_outcome = proxy_upstream_outcome();
812 if proxy_outcome.ok {
813 passed += 1;
814 }
815 print_check(&proxy_outcome);
816
817 let aliases = shell_aliases_outcome();
819 if aliases.ok {
820 passed += 1;
821 }
822 print_check(&aliases);
823
824 let mcp = mcp_config_outcome();
826 if mcp.ok {
827 passed += 1;
828 }
829 print_check(&mcp);
830
831 let skill = skill_files_outcome();
833 if skill.ok {
834 passed += 1;
835 }
836 print_check(&skill);
837
838 let port = port_3333_outcome();
840 if port.ok {
841 passed += 1;
842 }
843 print_check(&port);
844
845 #[cfg(unix)]
847 let daemon_outcome = if crate::daemon::is_daemon_running() {
848 let pid_path = crate::daemon::daemon_pid_path();
849 let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
850 Outcome {
851 ok: true,
852 line: format!(
853 "{BOLD}Daemon{RST} {GREEN}running (PID {}){RST}",
854 pid_str.trim()
855 ),
856 }
857 } else {
858 Outcome {
859 ok: true,
860 line: format!(
861 "{BOLD}Daemon{RST} {YELLOW}not running{RST} {DIM}(run: lean-ctx serve -d){RST}"
862 ),
863 }
864 };
865 #[cfg(not(unix))]
866 let daemon_outcome = Outcome {
867 ok: true,
868 line: format!("{BOLD}Daemon{RST} {DIM}not supported on this platform{RST}"),
869 };
870 if daemon_outcome.ok {
871 passed += 1;
872 }
873 print_check(&daemon_outcome);
874
875 let session_outcome = session_state_outcome();
877 if session_outcome.ok {
878 passed += 1;
879 }
880 print_check(&session_outcome);
881
882 let docker_outcomes = docker_env_outcomes();
884 for docker_check in &docker_outcomes {
885 if docker_check.ok {
886 passed += 1;
887 }
888 print_check(docker_check);
889 }
890
891 let pi = pi_outcome();
893 if let Some(ref pi_check) = pi {
894 if pi_check.ok {
895 passed += 1;
896 }
897 print_check(pi_check);
898 }
899
900 let integrity = crate::core::integrity::check();
902 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
903 if integrity_ok {
904 passed += 1;
905 }
906 let integrity_line = if integrity_ok {
907 format!(
908 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
909 integrity.repo
910 )
911 } else {
912 format!(
913 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
914 integrity.pkg_name, integrity.repo
915 )
916 };
917 print_check(&Outcome {
918 ok: integrity_ok,
919 line: integrity_line,
920 });
921
922 let cache_safety = cache_safety_outcome();
924 if cache_safety.ok {
925 passed += 1;
926 }
927 print_check(&cache_safety);
928
929 let claude_truncation = claude_truncation_outcome();
931 if let Some(ref ct) = claude_truncation {
932 if ct.ok {
933 passed += 1;
934 }
935 print_check(ct);
936 }
937
938 let bm25_health = bm25_cache_health_outcome();
940 if bm25_health.ok {
941 passed += 1;
942 }
943 print_check(&bm25_health);
944
945 let mem_profile = memory_profile_outcome();
947 passed += 1;
948 print_check(&mem_profile);
949
950 let mut effective_total = total + 6; effective_total += docker_outcomes.len() as u32;
952 if pi.is_some() {
953 effective_total += 1;
954 }
955 if claude_truncation.is_some() {
956 effective_total += 1;
957 }
958 println!();
959 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
960 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
961}
962
963fn skill_files_outcome() -> Outcome {
964 let Some(home) = dirs::home_dir() else {
965 return Outcome {
966 ok: false,
967 line: format!("{BOLD}SKILL.md{RST} {RED}could not resolve home directory{RST}"),
968 };
969 };
970
971 let candidates = [
972 ("Claude Code", home.join(".claude/skills/lean-ctx/SKILL.md")),
973 ("Cursor", home.join(".cursor/skills/lean-ctx/SKILL.md")),
974 ("Codex CLI", home.join(".codex/skills/lean-ctx/SKILL.md")),
975 (
976 "GitHub Copilot",
977 home.join(".vscode/skills/lean-ctx/SKILL.md"),
978 ),
979 ];
980
981 let mut found: Vec<&str> = Vec::new();
982 for (name, path) in &candidates {
983 if path.exists() {
984 found.push(name);
985 }
986 }
987
988 if found.is_empty() {
989 Outcome {
990 ok: false,
991 line: format!(
992 "{BOLD}SKILL.md{RST} {YELLOW}not installed{RST} {DIM}(run: lean-ctx setup){RST}"
993 ),
994 }
995 } else {
996 Outcome {
997 ok: true,
998 line: format!(
999 "{BOLD}SKILL.md{RST} {GREEN}installed for {}{RST}",
1000 found.join(", ")
1001 ),
1002 }
1003 }
1004}
1005
1006fn proxy_upstream_outcome() -> Outcome {
1007 use crate::core::config::{is_local_proxy_url, Config, ProxyProvider};
1008
1009 let cfg = Config::load();
1010 let checks = [
1011 (
1012 "Anthropic",
1013 "proxy.anthropic_upstream",
1014 cfg.proxy.resolve_upstream(ProxyProvider::Anthropic),
1015 ),
1016 (
1017 "OpenAI",
1018 "proxy.openai_upstream",
1019 cfg.proxy.resolve_upstream(ProxyProvider::OpenAi),
1020 ),
1021 (
1022 "Gemini",
1023 "proxy.gemini_upstream",
1024 cfg.proxy.resolve_upstream(ProxyProvider::Gemini),
1025 ),
1026 ];
1027
1028 let mut custom = Vec::new();
1029 for (label, key, resolved) in &checks {
1030 if is_local_proxy_url(resolved) {
1031 return Outcome {
1032 ok: false,
1033 line: format!(
1034 "{BOLD}Proxy upstream{RST} {RED}{label} upstream points back to local proxy{RST} {YELLOW}run: lean-ctx config set {key} <url>{RST}"
1035 ),
1036 };
1037 }
1038 if !resolved.starts_with("http://") && !resolved.starts_with("https://") {
1039 return Outcome {
1040 ok: false,
1041 line: format!(
1042 "{BOLD}Proxy upstream{RST} {RED}invalid {label} upstream{RST} {YELLOW}set {key} to an http(s) URL{RST}"
1043 ),
1044 };
1045 }
1046 let is_default = matches!(
1047 *label,
1048 "Anthropic" if resolved == "https://api.anthropic.com"
1049 ) || matches!(
1050 *label,
1051 "OpenAI" if resolved == "https://api.openai.com"
1052 ) || matches!(
1053 *label,
1054 "Gemini" if resolved == "https://generativelanguage.googleapis.com"
1055 );
1056 if !is_default {
1057 custom.push(format!("{label}={resolved}"));
1058 }
1059 }
1060
1061 if custom.is_empty() {
1062 Outcome {
1063 ok: true,
1064 line: format!("{BOLD}Proxy upstream{RST} {GREEN}provider defaults{RST}"),
1065 }
1066 } else {
1067 Outcome {
1068 ok: true,
1069 line: format!(
1070 "{BOLD}Proxy upstream{RST} {GREEN}custom: {}{RST}",
1071 custom.join(", ")
1072 ),
1073 }
1074 }
1075}
1076
1077fn cache_safety_outcome() -> Outcome {
1078 use crate::core::neural::cache_alignment::CacheAlignedOutput;
1079 use crate::core::provider_cache::ProviderCacheState;
1080
1081 let mut issues = Vec::new();
1082
1083 let mut aligned = CacheAlignedOutput::new();
1084 aligned.add_stable_block("test", "stable content".into(), 1);
1085 aligned.add_variable_block("test_var", "variable content".into(), 1);
1086 let rendered = aligned.render();
1087 if rendered.find("stable content").unwrap_or(usize::MAX)
1088 > rendered.find("variable content").unwrap_or(0)
1089 {
1090 issues.push("cache_alignment: stable blocks not ordered first");
1091 }
1092
1093 let mut state = ProviderCacheState::new();
1094 let section = crate::core::provider_cache::CacheableSection::new(
1095 "doctor_test",
1096 "test content".into(),
1097 crate::core::provider_cache::SectionPriority::System,
1098 true,
1099 );
1100 state.mark_sent(§ion);
1101 if state.needs_update(§ion) {
1102 issues.push("provider_cache: hash tracking broken");
1103 }
1104
1105 if issues.is_empty() {
1106 Outcome {
1107 ok: true,
1108 line: format!(
1109 "{BOLD}Cache safety{RST} {GREEN}cache_alignment + provider_cache operational{RST}"
1110 ),
1111 }
1112 } else {
1113 Outcome {
1114 ok: false,
1115 line: format!("{BOLD}Cache safety{RST} {RED}{}{RST}", issues.join("; ")),
1116 }
1117 }
1118}
1119
1120pub(super) fn claude_binary_exists() -> bool {
1121 #[cfg(unix)]
1122 {
1123 std::process::Command::new("which")
1124 .arg("claude")
1125 .output()
1126 .is_ok_and(|o| o.status.success())
1127 }
1128 #[cfg(windows)]
1129 {
1130 std::process::Command::new("where")
1131 .arg("claude")
1132 .output()
1133 .is_ok_and(|o| o.status.success())
1134 }
1135}
1136
1137fn claude_truncation_outcome() -> Option<Outcome> {
1138 let home = dirs::home_dir()?;
1139 let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1140 || crate::core::editor_registry::claude_state_dir(&home).exists()
1141 || claude_binary_exists();
1142
1143 if !claude_detected {
1144 return None;
1145 }
1146
1147 let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1148 let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1149
1150 let has_rules = rules_path.exists();
1151 let has_skill = skill_path.exists();
1152
1153 if has_rules && has_skill {
1154 Some(Outcome {
1155 ok: true,
1156 line: format!(
1157 "{BOLD}Claude Code instructions{RST} {GREEN}rules + skill installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1158 ),
1159 })
1160 } else if has_rules {
1161 Some(Outcome {
1162 ok: true,
1163 line: format!(
1164 "{BOLD}Claude Code instructions{RST} {GREEN}rules file installed{RST} {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1165 ),
1166 })
1167 } else {
1168 Some(Outcome {
1169 ok: false,
1170 line: format!(
1171 "{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}"
1172 ),
1173 })
1174 }
1175}
1176
1177fn bm25_cache_health_outcome() -> Outcome {
1178 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1179 return Outcome {
1180 ok: true,
1181 line: format!("{BOLD}BM25 cache{RST} {DIM}skipped (no data dir){RST}"),
1182 };
1183 };
1184
1185 let vectors_dir = data_dir.join("vectors");
1186 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
1187 return Outcome {
1188 ok: true,
1189 line: format!("{BOLD}BM25 cache{RST} {GREEN}no vector dirs{RST}"),
1190 };
1191 };
1192
1193 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
1194 let warn_bytes = 100 * 1024 * 1024; let mut total_dirs = 0u32;
1196 let mut total_bytes = 0u64;
1197 let mut oversized: Vec<(String, u64)> = Vec::new();
1198 let mut warnings: Vec<(String, u64)> = Vec::new();
1199 let mut quarantined_count = 0u32;
1200
1201 for entry in entries.flatten() {
1202 let dir = entry.path();
1203 if !dir.is_dir() {
1204 continue;
1205 }
1206 total_dirs += 1;
1207
1208 if dir.join("bm25_index.json.quarantined").exists() {
1209 quarantined_count += 1;
1210 }
1211
1212 let index_path = dir.join("bm25_index.json");
1213 if let Ok(meta) = std::fs::metadata(&index_path) {
1214 let size = meta.len();
1215 total_bytes += size;
1216 let display = index_path.display().to_string();
1217 if size > max_bytes {
1218 oversized.push((display, size));
1219 } else if size > warn_bytes {
1220 warnings.push((display, size));
1221 }
1222 }
1223 }
1224
1225 if !oversized.is_empty() {
1226 let details: Vec<String> = oversized
1227 .iter()
1228 .map(|(p, s)| format!("{p} ({:.1} GB)", *s as f64 / 1_073_741_824.0))
1229 .collect();
1230 return Outcome {
1231 ok: false,
1232 line: format!(
1233 "{BOLD}BM25 cache{RST} {RED}{} index(es) exceed limit ({:.0} MB){RST}: {} {DIM}(run: lean-ctx cache prune){RST}",
1234 oversized.len(),
1235 max_bytes / (1024 * 1024),
1236 details.join(", ")
1237 ),
1238 };
1239 }
1240
1241 if !warnings.is_empty() {
1242 let details: Vec<String> = warnings
1243 .iter()
1244 .map(|(p, s)| format!("{p} ({:.0} MB)", *s as f64 / 1_048_576.0))
1245 .collect();
1246 return Outcome {
1247 ok: true,
1248 line: format!(
1249 "{BOLD}BM25 cache{RST} {YELLOW}{} large index(es) (>100 MB){RST}: {} {DIM}(consider extra_ignore_patterns){RST}",
1250 warnings.len(),
1251 details.join(", ")
1252 ),
1253 };
1254 }
1255
1256 let quarantine_note = if quarantined_count > 0 {
1257 format!(" {YELLOW}{quarantined_count} quarantined (run: lean-ctx cache prune){RST}")
1258 } else {
1259 String::new()
1260 };
1261
1262 Outcome {
1263 ok: true,
1264 line: format!(
1265 "{BOLD}BM25 cache{RST} {GREEN}{total_dirs} index(es), {:.1} MB total{RST}{quarantine_note}",
1266 total_bytes as f64 / 1_048_576.0
1267 ),
1268 }
1269}
1270
1271pub fn run_compact() {
1272 let (passed, total) = compact_score();
1273 print_compact_status(passed, total);
1274}
1275
1276pub fn run_cli(args: &[String]) -> i32 {
1277 let (sub, rest) = match args.first().map(String::as_str) {
1278 Some("integrations") => ("integrations", &args[1..]),
1279 _ => ("", args),
1280 };
1281
1282 let fix = rest.iter().any(|a| a == "--fix");
1283 let json = rest.iter().any(|a| a == "--json");
1284 let help = rest.iter().any(|a| a == "--help" || a == "-h");
1285
1286 if help {
1287 println!("Usage:");
1288 println!(" lean-ctx doctor");
1289 println!(" lean-ctx doctor integrations [--json]");
1290 println!(" lean-ctx doctor --fix [--json]");
1291 return 0;
1292 }
1293
1294 if sub == "integrations" {
1295 if fix {
1296 let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
1297 }
1298 return integrations::run_integrations(&integrations::IntegrationsOptions { json });
1299 }
1300
1301 if !fix {
1302 run();
1303 return 0;
1304 }
1305
1306 match fix::run_fix(&fix::DoctorFixOptions { json }) {
1307 Ok(code) => code,
1308 Err(e) => {
1309 tracing::error!("doctor --fix failed: {e}");
1310 2
1311 }
1312 }
1313}
1314
1315pub fn compact_score() -> (u32, u32) {
1316 let mut passed = 0u32;
1317 let total = 6u32;
1318
1319 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1320 passed += 1;
1321 }
1322 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
1323 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1324 passed += 1;
1325 }
1326 if lean_dir
1327 .as_ref()
1328 .map(|d| d.join("stats.json"))
1329 .and_then(|p| std::fs::metadata(p).ok())
1330 .is_some_and(|m| m.is_file())
1331 {
1332 passed += 1;
1333 }
1334 if shell_aliases_outcome().ok {
1335 passed += 1;
1336 }
1337 if mcp_config_outcome().ok {
1338 passed += 1;
1339 }
1340 if skill_files_outcome().ok {
1341 passed += 1;
1342 }
1343
1344 (passed, total)
1345}
1346
1347pub(super) fn print_compact_status(passed: u32, total: u32) {
1348 let status = if passed == total {
1349 format!("{GREEN}✓ All {total} checks passed{RST}")
1350 } else {
1351 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1352 };
1353 println!(" {status}");
1354}
1355
1356fn memory_profile_outcome() -> Outcome {
1357 let cfg = crate::core::config::Config::load();
1358 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1359 let (label, detail) = match profile {
1360 crate::core::config::MemoryProfile::Low => {
1361 ("low", "embeddings+semantic cache disabled, BM25 64 MB")
1362 }
1363 crate::core::config::MemoryProfile::Balanced => {
1364 ("balanced", "default — BM25 128 MB, single embedding engine")
1365 }
1366 crate::core::config::MemoryProfile::Performance => {
1367 ("performance", "full caches, BM25 512 MB")
1368 }
1369 };
1370 let source = if crate::core::config::MemoryProfile::from_env().is_some() {
1371 "env"
1372 } else if cfg.memory_profile != crate::core::config::MemoryProfile::default() {
1373 "config"
1374 } else {
1375 "default"
1376 };
1377 Outcome {
1378 ok: true,
1379 line: format!(
1380 "{BOLD}Memory profile{RST} {GREEN}{label}{RST} {DIM}({source}: {detail}){RST}"
1381 ),
1382 }
1383}