Skip to main content

lean_ctx/
doctor.rs

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