Skip to main content

lean_ctx/doctor/
mod.rs

1//! Environment diagnostics for lean-ctx installation and integration.
2
3mod 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
661/// Run diagnostic checks and print colored results to stdout.
662pub 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    // 1) Binary on PATH
669    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    // 2) Version from PATH binary
690    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    // 3) data directory (respects LEAN_CTX_DATA_DIR)
704    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    // 4) stats.json + size
731    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    // 5) config.toml (missing is OK)
778    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    // 6) Proxy upstreams
817    let proxy_outcome = proxy_upstream_outcome();
818    if proxy_outcome.ok {
819        passed += 1;
820    }
821    print_check(&proxy_outcome);
822
823    // 7) Shell aliases
824    let aliases = shell_aliases_outcome();
825    if aliases.ok {
826        passed += 1;
827    }
828    print_check(&aliases);
829
830    // 7) MCP
831    let mcp = mcp_config_outcome();
832    if mcp.ok {
833        passed += 1;
834    }
835    print_check(&mcp);
836
837    // 9) SKILL.md
838    let skill = skill_files_outcome();
839    if skill.ok {
840        passed += 1;
841    }
842    print_check(&skill);
843
844    // 10) Port
845    let port = port_3333_outcome();
846    if port.ok {
847        passed += 1;
848    }
849    print_check(&port);
850
851    // Daemon status
852    #[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    // 9) Session state (project_root + shell_cwd)
882    let session_outcome = session_state_outcome();
883    if session_outcome.ok {
884        passed += 1;
885    }
886    print_check(&session_outcome);
887
888    // 10) Docker env vars (optional, only in containers)
889    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    // 11) Pi Coding Agent (optional)
898    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    // 12) Build integrity (canary / origin check)
907    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    // 13) Cache safety
929    let cache_safety = cache_safety_outcome();
930    if cache_safety.ok {
931        passed += 1;
932    }
933    print_check(&cache_safety);
934
935    // 14) Claude Code instruction truncation guard
936    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    // 15) BM25 cache health
945    let bm25_health = bm25_cache_health_outcome();
946    if bm25_health.ok {
947        passed += 1;
948    }
949    print_check(&bm25_health);
950
951    // 16) Memory profile
952    let mem_profile = memory_profile_outcome();
953    passed += 1;
954    print_check(&mem_profile);
955
956    // 17) Memory cleanup
957    let mem_cleanup = memory_cleanup_outcome();
958    passed += 1;
959    print_check(&mem_cleanup);
960
961    let mut effective_total = total + 7; // session_state + integrity + cache_safety + bm25_health + daemon + mem_profile + mem_cleanup
962    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(&section);
1117    if state.needs_update(&section) {
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; // 100 MB
1211    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}