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: "~/.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
655/// Run diagnostic checks and print colored results to stdout.
656pub 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    // 1) Binary on PATH
663    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    // 2) Version from PATH binary
684    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    // 3) data directory (respects LEAN_CTX_DATA_DIR)
698    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    // 4) stats.json + size
725    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    // 5) config.toml (missing is OK)
772    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    // 6) Proxy upstreams
811    let proxy_outcome = proxy_upstream_outcome();
812    if proxy_outcome.ok {
813        passed += 1;
814    }
815    print_check(&proxy_outcome);
816
817    // 7) Shell aliases
818    let aliases = shell_aliases_outcome();
819    if aliases.ok {
820        passed += 1;
821    }
822    print_check(&aliases);
823
824    // 7) MCP
825    let mcp = mcp_config_outcome();
826    if mcp.ok {
827        passed += 1;
828    }
829    print_check(&mcp);
830
831    // 9) SKILL.md
832    let skill = skill_files_outcome();
833    if skill.ok {
834        passed += 1;
835    }
836    print_check(&skill);
837
838    // 10) Port
839    let port = port_3333_outcome();
840    if port.ok {
841        passed += 1;
842    }
843    print_check(&port);
844
845    // Daemon status
846    #[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    // 9) Session state (project_root + shell_cwd)
876    let session_outcome = session_state_outcome();
877    if session_outcome.ok {
878        passed += 1;
879    }
880    print_check(&session_outcome);
881
882    // 10) Docker env vars (optional, only in containers)
883    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    // 11) Pi Coding Agent (optional)
892    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    // 12) Build integrity (canary / origin check)
901    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    // 13) Cache safety
923    let cache_safety = cache_safety_outcome();
924    if cache_safety.ok {
925        passed += 1;
926    }
927    print_check(&cache_safety);
928
929    // 14) Claude Code instruction truncation guard
930    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    // 15) BM25 cache health
939    let bm25_health = bm25_cache_health_outcome();
940    if bm25_health.ok {
941        passed += 1;
942    }
943    print_check(&bm25_health);
944
945    // 16) Memory profile
946    let mem_profile = memory_profile_outcome();
947    passed += 1;
948    print_check(&mem_profile);
949
950    let mut effective_total = total + 6; // session_state + integrity + cache_safety + bm25_health + daemon + mem_profile
951    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(&section);
1101    if state.needs_update(&section) {
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; // 100 MB
1195    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}