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 rc_has_pipe_guard(path: &PathBuf) -> bool {
95    match std::fs::read_to_string(path) {
96        Ok(s) => {
97            s.contains("! -t 1") || s.contains("isatty stdout") || s.contains("IsOutputRedirected")
98        }
99        Err(_) => false,
100    }
101}
102
103fn shell_aliases_outcome() -> Outcome {
104    let home = match dirs::home_dir() {
105        Some(h) => h,
106        None => {
107            return Outcome {
108                ok: false,
109                line: format!(
110                    "{BOLD}Shell aliases{RST}  {RED}could not resolve home directory{RST}"
111                ),
112            };
113        }
114    };
115
116    let mut parts = Vec::new();
117    let mut needs_update = Vec::new();
118
119    let zsh = home.join(".zshrc");
120    if rc_contains_lean_ctx(&zsh) {
121        parts.push(format!("{DIM}~/.zshrc{RST}"));
122        if !rc_has_pipe_guard(&zsh) {
123            needs_update.push("~/.zshrc");
124        }
125    }
126    let bash = home.join(".bashrc");
127    if rc_contains_lean_ctx(&bash) {
128        parts.push(format!("{DIM}~/.bashrc{RST}"));
129        if !rc_has_pipe_guard(&bash) {
130            needs_update.push("~/.bashrc");
131        }
132    }
133
134    let fish = home.join(".config").join("fish").join("config.fish");
135    if rc_contains_lean_ctx(&fish) {
136        parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
137        if !rc_has_pipe_guard(&fish) {
138            needs_update.push("~/.config/fish/config.fish");
139        }
140    }
141
142    #[cfg(windows)]
143    {
144        let ps_profile = home
145            .join("Documents")
146            .join("PowerShell")
147            .join("Microsoft.PowerShell_profile.ps1");
148        let ps_profile_legacy = home
149            .join("Documents")
150            .join("WindowsPowerShell")
151            .join("Microsoft.PowerShell_profile.ps1");
152        if rc_contains_lean_ctx(&ps_profile) {
153            parts.push(format!("{DIM}PowerShell profile{RST}"));
154            if !rc_has_pipe_guard(&ps_profile) {
155                needs_update.push("PowerShell profile");
156            }
157        } else if rc_contains_lean_ctx(&ps_profile_legacy) {
158            parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
159            if !rc_has_pipe_guard(&ps_profile_legacy) {
160                needs_update.push("WindowsPowerShell profile");
161            }
162        }
163    }
164
165    if parts.is_empty() {
166        let hint = if cfg!(windows) {
167            "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
168        } else {
169            "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
170        };
171        Outcome {
172            ok: false,
173            line: format!("{BOLD}Shell aliases{RST}  {RED}{hint}{RST}"),
174        }
175    } else if !needs_update.is_empty() {
176        Outcome {
177            ok: false,
178            line: format!(
179                "{BOLD}Shell aliases{RST}  {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
180                needs_update.join(", ")
181            ),
182        }
183    } else {
184        Outcome {
185            ok: true,
186            line: format!(
187                "{BOLD}Shell aliases{RST}  {GREEN}lean-ctx referenced in {}{RST}",
188                parts.join(", ")
189            ),
190        }
191    }
192}
193
194struct McpLocation {
195    name: &'static str,
196    display: String,
197    path: PathBuf,
198}
199
200fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
201    let mut locations = vec![
202        McpLocation {
203            name: "Cursor",
204            display: "~/.cursor/mcp.json".into(),
205            path: home.join(".cursor").join("mcp.json"),
206        },
207        McpLocation {
208            name: "Claude Code",
209            display: format!(
210                "{}",
211                crate::core::editor_registry::claude_mcp_json_path(home).display()
212            ),
213            path: crate::core::editor_registry::claude_mcp_json_path(home),
214        },
215        McpLocation {
216            name: "Windsurf",
217            display: "~/.codeium/windsurf/mcp_config.json".into(),
218            path: home
219                .join(".codeium")
220                .join("windsurf")
221                .join("mcp_config.json"),
222        },
223        McpLocation {
224            name: "Codex",
225            display: "~/.codex/config.toml".into(),
226            path: home.join(".codex").join("config.toml"),
227        },
228        McpLocation {
229            name: "Gemini CLI",
230            display: "~/.gemini/settings/mcp.json".into(),
231            path: home.join(".gemini").join("settings").join("mcp.json"),
232        },
233        McpLocation {
234            name: "Antigravity",
235            display: "~/.gemini/antigravity/mcp_config.json".into(),
236            path: home
237                .join(".gemini")
238                .join("antigravity")
239                .join("mcp_config.json"),
240        },
241    ];
242
243    #[cfg(unix)]
244    {
245        let zed_cfg = home.join(".config").join("zed").join("settings.json");
246        locations.push(McpLocation {
247            name: "Zed",
248            display: "~/.config/zed/settings.json".into(),
249            path: zed_cfg,
250        });
251    }
252
253    locations.push(McpLocation {
254        name: "Qwen Code",
255        display: "~/.qwen/mcp.json".into(),
256        path: home.join(".qwen").join("mcp.json"),
257    });
258    locations.push(McpLocation {
259        name: "Trae",
260        display: "~/.trae/mcp.json".into(),
261        path: home.join(".trae").join("mcp.json"),
262    });
263    locations.push(McpLocation {
264        name: "Amazon Q",
265        display: "~/.aws/amazonq/mcp.json".into(),
266        path: home.join(".aws").join("amazonq").join("mcp.json"),
267    });
268    locations.push(McpLocation {
269        name: "JetBrains",
270        display: "~/.jb-mcp.json".into(),
271        path: home.join(".jb-mcp.json"),
272    });
273    locations.push(McpLocation {
274        name: "AWS Kiro",
275        display: "~/.kiro/settings/mcp.json".into(),
276        path: home.join(".kiro").join("settings").join("mcp.json"),
277    });
278    locations.push(McpLocation {
279        name: "Verdent",
280        display: "~/.verdent/mcp.json".into(),
281        path: home.join(".verdent").join("mcp.json"),
282    });
283    locations.push(McpLocation {
284        name: "Crush",
285        display: "~/.config/crush/crush.json".into(),
286        path: home.join(".config").join("crush").join("crush.json"),
287    });
288    locations.push(McpLocation {
289        name: "Pi",
290        display: "~/.pi/agent/mcp.json".into(),
291        path: home.join(".pi").join("agent").join("mcp.json"),
292    });
293    locations.push(McpLocation {
294        name: "Aider",
295        display: "~/.aider/mcp.json".into(),
296        path: home.join(".aider").join("mcp.json"),
297    });
298    locations.push(McpLocation {
299        name: "Amp",
300        display: "~/.config/amp/settings.json".into(),
301        path: home.join(".config").join("amp").join("settings.json"),
302    });
303
304    {
305        #[cfg(unix)]
306        let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
307        #[cfg(unix)]
308        let opencode_display = "~/.config/opencode/opencode.json";
309
310        #[cfg(windows)]
311        let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
312            std::path::PathBuf::from(appdata)
313                .join("opencode")
314                .join("opencode.json")
315        } else {
316            home.join(".config").join("opencode").join("opencode.json")
317        };
318        #[cfg(windows)]
319        let opencode_display = "%APPDATA%/opencode/opencode.json";
320
321        locations.push(McpLocation {
322            name: "OpenCode",
323            display: opencode_display.into(),
324            path: opencode_cfg,
325        });
326    }
327
328    #[cfg(target_os = "macos")]
329    {
330        let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
331        locations.push(McpLocation {
332            name: "VS Code / Copilot",
333            display: "~/Library/Application Support/Code/User/mcp.json".into(),
334            path: vscode_mcp,
335        });
336    }
337    #[cfg(target_os = "linux")]
338    {
339        let vscode_mcp = home.join(".config/Code/User/mcp.json");
340        locations.push(McpLocation {
341            name: "VS Code / Copilot",
342            display: "~/.config/Code/User/mcp.json".into(),
343            path: vscode_mcp,
344        });
345    }
346    #[cfg(target_os = "windows")]
347    {
348        if let Ok(appdata) = std::env::var("APPDATA") {
349            let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
350            locations.push(McpLocation {
351                name: "VS Code / Copilot",
352                display: "%APPDATA%/Code/User/mcp.json".into(),
353                path: vscode_mcp,
354            });
355        }
356    }
357
358    locations.push(McpLocation {
359        name: "Hermes Agent",
360        display: "~/.hermes/config.yaml".into(),
361        path: home.join(".hermes").join("config.yaml"),
362    });
363
364    {
365        let cline_path = crate::core::editor_registry::cline_mcp_path();
366        if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
367            locations.push(McpLocation {
368                name: "Cline",
369                display: cline_path.display().to_string(),
370                path: cline_path,
371            });
372        }
373    }
374    {
375        let roo_path = crate::core::editor_registry::roo_mcp_path();
376        if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
377            locations.push(McpLocation {
378                name: "Roo Code",
379                display: roo_path.display().to_string(),
380                path: roo_path,
381            });
382        }
383    }
384
385    locations
386}
387
388fn docker_bash_env_outcome() -> Option<Outcome> {
389    if !crate::shell::is_container() {
390        return None;
391    }
392    let shell_name = std::env::var("SHELL").unwrap_or_default();
393    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
394    if !is_bash {
395        return None;
396    }
397    let has_bash_env = std::env::var("BASH_ENV").is_ok();
398    if has_bash_env {
399        Some(Outcome {
400            ok: true,
401            line: format!(
402                "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
403                std::env::var("BASH_ENV").unwrap_or_default()
404            ),
405        })
406    } else {
407        let env_sh = crate::core::data_dir::lean_ctx_data_dir()
408            .map(|d| d.join("env.sh").to_string_lossy().to_string())
409            .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
410        Some(Outcome {
411            ok: false,
412            line: format!(
413                "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(Docker detected — add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
414            ),
415        })
416    }
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 = dirs::home_dir()
595        .map(|h| {
596            h.join(".lean-ctx")
597                .join("env.sh")
598                .to_string_lossy()
599                .to_string()
600        })
601        .unwrap_or_else(|| "/root/.lean-ctx/env.sh".to_string());
602
603    let mut outcomes = vec![];
604
605    let shell_name = std::env::var("SHELL").unwrap_or_default();
606    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
607
608    if is_bash {
609        let has_bash_env = std::env::var("BASH_ENV").is_ok();
610        outcomes.push(if has_bash_env {
611            Outcome {
612                ok: true,
613                line: format!(
614                    "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
615                    std::env::var("BASH_ENV").unwrap_or_default()
616                ),
617            }
618        } else {
619            Outcome {
620                ok: false,
621                line: format!(
622                    "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
623                ),
624            }
625        });
626    }
627
628    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
629    outcomes.push(if has_claude_env {
630        Outcome {
631            ok: true,
632            line: format!(
633                "{BOLD}CLAUDE_ENV_FILE{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
634                std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
635            ),
636        }
637    } else {
638        Outcome {
639            ok: false,
640            line: format!(
641                "{BOLD}CLAUDE_ENV_FILE{RST}  {RED}not set{RST}  {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
642            ),
643        }
644    });
645
646    outcomes
647}
648
649/// Run diagnostic checks and print colored results to stdout.
650pub fn run() {
651    let mut passed = 0u32;
652    let total = 8u32;
653
654    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
655
656    // 1) Binary on PATH
657    let path_bin = resolve_lean_ctx_binary();
658    let also_in_path_dirs = path_in_path_env();
659    let bin_ok = path_bin.is_some() || also_in_path_dirs;
660    if bin_ok {
661        passed += 1;
662    }
663    let bin_line = if let Some(p) = path_bin {
664        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
665    } else if also_in_path_dirs {
666        format!(
667            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
668        )
669    } else {
670        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
671    };
672    print_check(&Outcome {
673        ok: bin_ok,
674        line: bin_line,
675    });
676
677    // 2) Version from PATH binary
678    let ver = if bin_ok {
679        lean_ctx_version_from_path()
680    } else {
681        Outcome {
682            ok: false,
683            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
684        }
685    };
686    if ver.ok {
687        passed += 1;
688    }
689    print_check(&ver);
690
691    // 3) ~/.lean-ctx directory
692    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
693    let dir_outcome = match &lean_dir {
694        Some(p) if p.is_dir() => {
695            passed += 1;
696            Outcome {
697                ok: true,
698                line: format!(
699                    "{BOLD}~/.lean-ctx/{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
700                    p.display()
701                ),
702            }
703        }
704        Some(p) => Outcome {
705            ok: false,
706            line: format!(
707                "{BOLD}~/.lean-ctx/{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
708                p.display()
709            ),
710        },
711        None => Outcome {
712            ok: false,
713            line: format!("{BOLD}~/.lean-ctx/{RST}  {RED}could not resolve home directory{RST}"),
714        },
715    };
716    print_check(&dir_outcome);
717
718    // 4) stats.json + size
719    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
720    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
721        Some(m) if m.is_file() => {
722            passed += 1;
723            let size = m.len();
724            Outcome {
725                ok: true,
726                line: format!(
727                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{}{RST}",
728                    stats_path.as_ref().unwrap().display()
729                ),
730            }
731        }
732        Some(_m) => Outcome {
733            ok: false,
734            line: format!(
735                "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{}{RST}",
736                stats_path.as_ref().unwrap().display()
737            ),
738        },
739        None => {
740            passed += 1;
741            Outcome {
742                ok: true,
743                line: match &stats_path {
744                    Some(p) => format!(
745                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
746                        p.display()
747                    ),
748                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
749                },
750            }
751        }
752    };
753    print_check(&stats_outcome);
754
755    // 5) config.toml (missing is OK)
756    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
757    let config_outcome = match &config_path {
758        Some(p) => match std::fs::metadata(p) {
759            Ok(m) if m.is_file() => {
760                passed += 1;
761                Outcome {
762                    ok: true,
763                    line: format!(
764                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
765                        p.display()
766                    ),
767                }
768            }
769            Ok(_) => Outcome {
770                ok: false,
771                line: format!(
772                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
773                    p.display()
774                ),
775            },
776            Err(_) => {
777                passed += 1;
778                Outcome {
779                    ok: true,
780                    line: format!(
781                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
782                        p.display()
783                    ),
784                }
785            }
786        },
787        None => Outcome {
788            ok: false,
789            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
790        },
791    };
792    print_check(&config_outcome);
793
794    // 6) Shell aliases
795    let aliases = shell_aliases_outcome();
796    if aliases.ok {
797        passed += 1;
798    }
799    print_check(&aliases);
800
801    // 7) Docker BASH_ENV (only shown when in a container)
802    let docker_outcome = docker_bash_env_outcome();
803    if let Some(ref docker_check) = docker_outcome {
804        if docker_check.ok {
805            passed += 1;
806        }
807        print_check(docker_check);
808    }
809
810    // 8) MCP
811    let mcp = mcp_config_outcome();
812    if mcp.ok {
813        passed += 1;
814    }
815    print_check(&mcp);
816
817    // 9) Port
818    let port = port_3333_outcome();
819    if port.ok {
820        passed += 1;
821    }
822    print_check(&port);
823
824    // 9) Session state (project_root + shell_cwd)
825    let session_outcome = session_state_outcome();
826    if session_outcome.ok {
827        passed += 1;
828    }
829    print_check(&session_outcome);
830
831    // 10) Docker env vars (optional, only in containers)
832    let docker_outcomes = docker_env_outcomes();
833    for docker_check in &docker_outcomes {
834        if docker_check.ok {
835            passed += 1;
836        }
837        print_check(docker_check);
838    }
839
840    // 11) Pi Coding Agent (optional)
841    let pi = pi_outcome();
842    if let Some(ref pi_check) = pi {
843        if pi_check.ok {
844            passed += 1;
845        }
846        print_check(pi_check);
847    }
848
849    // 12) Build integrity (canary / origin check)
850    let integrity = crate::core::integrity::check();
851    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
852    if integrity_ok {
853        passed += 1;
854    }
855    let integrity_line = if integrity_ok {
856        format!(
857            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
858            integrity.repo
859        )
860    } else {
861        format!(
862            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
863            integrity.pkg_name, integrity.repo
864        )
865    };
866    print_check(&Outcome {
867        ok: integrity_ok,
868        line: integrity_line,
869    });
870
871    // 13) Claude Code instruction truncation guard
872    let claude_truncation = claude_truncation_outcome();
873    if let Some(ref ct) = claude_truncation {
874        if ct.ok {
875            passed += 1;
876        }
877        print_check(ct);
878    }
879
880    let mut effective_total = total + 2; // session_state + integrity always shown
881    effective_total += docker_outcomes.len() as u32;
882    if pi.is_some() {
883        effective_total += 1;
884    }
885    if claude_truncation.is_some() {
886        effective_total += 1;
887    }
888    println!();
889    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
890    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
891}
892
893fn claude_binary_exists() -> bool {
894    #[cfg(unix)]
895    {
896        std::process::Command::new("which")
897            .arg("claude")
898            .output()
899            .map(|o| o.status.success())
900            .unwrap_or(false)
901    }
902    #[cfg(windows)]
903    {
904        std::process::Command::new("where")
905            .arg("claude")
906            .output()
907            .map(|o| o.status.success())
908            .unwrap_or(false)
909    }
910}
911
912fn claude_truncation_outcome() -> Option<Outcome> {
913    let home = dirs::home_dir()?;
914    let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
915        || crate::core::editor_registry::claude_state_dir(&home).exists()
916        || claude_binary_exists();
917
918    if !claude_detected {
919        return None;
920    }
921
922    let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
923    let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
924
925    let has_rules = rules_path.exists();
926    let has_skill = skill_path.exists();
927
928    if has_rules && has_skill {
929        Some(Outcome {
930            ok: true,
931            line: format!(
932                "{BOLD}Claude Code instructions{RST}  {GREEN}rules + skill installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
933            ),
934        })
935    } else if has_rules {
936        Some(Outcome {
937            ok: true,
938            line: format!(
939                "{BOLD}Claude Code instructions{RST}  {GREEN}rules file installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
940            ),
941        })
942    } else {
943        Some(Outcome {
944            ok: false,
945            line: format!(
946                "{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}"
947            ),
948        })
949    }
950}
951
952pub fn run_compact() {
953    let (passed, total) = compact_score();
954    print_compact_status(passed, total);
955}
956
957pub fn run_cli(args: &[String]) -> i32 {
958    let fix = args.iter().any(|a| a == "--fix");
959    let json = args.iter().any(|a| a == "--json");
960    let help = args.iter().any(|a| a == "--help" || a == "-h");
961
962    if help {
963        println!("Usage:");
964        println!("  lean-ctx doctor");
965        println!("  lean-ctx doctor --fix [--json]");
966        return 0;
967    }
968
969    if !fix {
970        run();
971        return 0;
972    }
973
974    match run_fix(DoctorFixOptions { json }) {
975        Ok(code) => code,
976        Err(e) => {
977            eprintln!("{RED}doctor --fix failed:{RST} {e}");
978            2
979        }
980    }
981}
982
983struct DoctorFixOptions {
984    json: bool,
985}
986
987fn run_fix(opts: DoctorFixOptions) -> Result<i32, String> {
988    use crate::core::setup_report::{
989        doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
990    };
991
992    let started_at = Utc::now();
993    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
994
995    let mut steps: Vec<SetupStepReport> = Vec::new();
996
997    // Step: shell hook repair
998    let mut shell_step = SetupStepReport {
999        name: "shell_hook".to_string(),
1000        ok: true,
1001        items: Vec::new(),
1002        warnings: Vec::new(),
1003        errors: Vec::new(),
1004    };
1005    let before = shell_aliases_outcome();
1006    if before.ok {
1007        shell_step.items.push(SetupItem {
1008            name: "init --global".to_string(),
1009            status: "already".to_string(),
1010            path: None,
1011            note: None,
1012        });
1013    } else {
1014        if opts.json {
1015            crate::cli::cmd_init_quiet(&["--global".to_string()]);
1016        } else {
1017            crate::cli::cmd_init(&["--global".to_string()]);
1018        }
1019        let after = shell_aliases_outcome();
1020        shell_step.ok = after.ok;
1021        shell_step.items.push(SetupItem {
1022            name: "init --global".to_string(),
1023            status: if after.ok {
1024                "fixed".to_string()
1025            } else {
1026                "failed".to_string()
1027            },
1028            path: None,
1029            note: if after.ok {
1030                None
1031            } else {
1032                Some("shell hook still not detected by doctor checks".to_string())
1033            },
1034        });
1035        if !after.ok {
1036            shell_step
1037                .warnings
1038                .push("shell hook not detected after init --global".to_string());
1039        }
1040    }
1041    steps.push(shell_step);
1042
1043    // Step: MCP config repair (detected tools)
1044    let mut mcp_step = SetupStepReport {
1045        name: "mcp_config".to_string(),
1046        ok: true,
1047        items: Vec::new(),
1048        warnings: Vec::new(),
1049        errors: Vec::new(),
1050    };
1051    let binary = crate::core::portable_binary::resolve_portable_binary();
1052    let targets = crate::core::editor_registry::build_targets(&home);
1053    for t in &targets {
1054        if !t.detect_path.exists() {
1055            continue;
1056        }
1057        let short = t.config_path.to_string_lossy().to_string();
1058        let res = crate::core::editor_registry::write_config_with_options(
1059            t,
1060            &binary,
1061            crate::core::editor_registry::WriteOptions {
1062                overwrite_invalid: true,
1063            },
1064        );
1065        match res {
1066            Ok(r) => {
1067                let status = match r.action {
1068                    crate::core::editor_registry::WriteAction::Created => "created",
1069                    crate::core::editor_registry::WriteAction::Updated => "updated",
1070                    crate::core::editor_registry::WriteAction::Already => "already",
1071                };
1072                mcp_step.items.push(SetupItem {
1073                    name: t.name.to_string(),
1074                    status: status.to_string(),
1075                    path: Some(short),
1076                    note: r.note,
1077                });
1078            }
1079            Err(e) => {
1080                mcp_step.ok = false;
1081                mcp_step.items.push(SetupItem {
1082                    name: t.name.to_string(),
1083                    status: "error".to_string(),
1084                    path: Some(short),
1085                    note: Some(e.clone()),
1086                });
1087                mcp_step.errors.push(format!("{}: {e}", t.name));
1088            }
1089        }
1090    }
1091    if mcp_step.items.is_empty() {
1092        mcp_step
1093            .warnings
1094            .push("no supported AI tools detected; skipped MCP config repair".to_string());
1095    }
1096    steps.push(mcp_step);
1097
1098    // Step: agent rules injection
1099    let mut rules_step = SetupStepReport {
1100        name: "agent_rules".to_string(),
1101        ok: true,
1102        items: Vec::new(),
1103        warnings: Vec::new(),
1104        errors: Vec::new(),
1105    };
1106    let inj = crate::rules_inject::inject_all_rules(&home);
1107    if !inj.injected.is_empty() {
1108        rules_step.items.push(SetupItem {
1109            name: "injected".to_string(),
1110            status: inj.injected.len().to_string(),
1111            path: None,
1112            note: Some(inj.injected.join(", ")),
1113        });
1114    }
1115    if !inj.updated.is_empty() {
1116        rules_step.items.push(SetupItem {
1117            name: "updated".to_string(),
1118            status: inj.updated.len().to_string(),
1119            path: None,
1120            note: Some(inj.updated.join(", ")),
1121        });
1122    }
1123    if !inj.already.is_empty() {
1124        rules_step.items.push(SetupItem {
1125            name: "already".to_string(),
1126            status: inj.already.len().to_string(),
1127            path: None,
1128            note: Some(inj.already.join(", ")),
1129        });
1130    }
1131    if !inj.errors.is_empty() {
1132        rules_step.ok = false;
1133        rules_step.errors.extend(inj.errors.clone());
1134    }
1135    steps.push(rules_step);
1136
1137    // Step: verify (compact)
1138    let mut verify_step = SetupStepReport {
1139        name: "verify".to_string(),
1140        ok: true,
1141        items: Vec::new(),
1142        warnings: Vec::new(),
1143        errors: Vec::new(),
1144    };
1145    let (passed, total) = compact_score();
1146    verify_step.items.push(SetupItem {
1147        name: "doctor_compact".to_string(),
1148        status: format!("{passed}/{total}"),
1149        path: None,
1150        note: None,
1151    });
1152    if passed != total {
1153        verify_step.warnings.push(format!(
1154            "doctor compact not fully passing: {passed}/{total}"
1155        ));
1156    }
1157    steps.push(verify_step);
1158
1159    let finished_at = Utc::now();
1160    let success = steps.iter().all(|s| s.ok);
1161
1162    let report = SetupReport {
1163        schema_version: 1,
1164        started_at,
1165        finished_at,
1166        success,
1167        platform: PlatformInfo {
1168            os: std::env::consts::OS.to_string(),
1169            arch: std::env::consts::ARCH.to_string(),
1170        },
1171        steps,
1172        warnings: Vec::new(),
1173        errors: Vec::new(),
1174    };
1175
1176    let path = doctor_report_path()?;
1177    let json_text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
1178    crate::config_io::write_atomic_with_backup(&path, &json_text)?;
1179
1180    if opts.json {
1181        println!("{json_text}");
1182    } else {
1183        let (passed, total) = compact_score();
1184        print_compact_status(passed, total);
1185        println!("  {DIM}report saved:{RST} {}", path.display());
1186    }
1187
1188    Ok(if report.success { 0 } else { 1 })
1189}
1190
1191pub fn compact_score() -> (u32, u32) {
1192    let mut passed = 0u32;
1193    let total = 5u32;
1194
1195    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1196        passed += 1;
1197    }
1198    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1199    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1200        passed += 1;
1201    }
1202    if lean_dir
1203        .as_ref()
1204        .map(|d| d.join("stats.json"))
1205        .and_then(|p| std::fs::metadata(p).ok())
1206        .is_some_and(|m| m.is_file())
1207    {
1208        passed += 1;
1209    }
1210    if shell_aliases_outcome().ok {
1211        passed += 1;
1212    }
1213    if mcp_config_outcome().ok {
1214        passed += 1;
1215    }
1216
1217    (passed, total)
1218}
1219
1220fn print_compact_status(passed: u32, total: u32) {
1221    let status = if passed == total {
1222        format!("{GREEN}✓ All {total} checks passed{RST}")
1223    } else {
1224        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1225    };
1226    println!("  {status}");
1227}