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    #[cfg(unix)]
48    {
49        let output = std::process::Command::new("/bin/sh")
50            .arg("-c")
51            .arg("command -v lean-ctx")
52            .env("LEAN_CTX_ACTIVE", "1")
53            .output()
54            .ok()?;
55        if !output.status.success() {
56            return None;
57        }
58        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
59        if s.is_empty() {
60            None
61        } else {
62            Some(PathBuf::from(s))
63        }
64    }
65
66    #[cfg(windows)]
67    {
68        let output = std::process::Command::new("where.exe")
69            .arg("lean-ctx")
70            .env("LEAN_CTX_ACTIVE", "1")
71            .output()
72            .ok()?;
73        if !output.status.success() {
74            return None;
75        }
76        let stdout = String::from_utf8_lossy(&output.stdout);
77        let lines: Vec<&str> = stdout
78            .lines()
79            .map(|l| l.trim())
80            .filter(|l| !l.is_empty())
81            .collect();
82        let exe_line = lines.iter().find(|l| l.ends_with(".exe"));
83        let best = exe_line.or(lines.first()).map(|s| s.to_string());
84        best.map(PathBuf::from)
85    }
86}
87
88fn lean_ctx_version_from_path() -> Outcome {
89    let resolved = resolve_lean_ctx_binary();
90    let bin = resolved
91        .clone()
92        .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
93
94    let try_run = |cmd: &std::path::Path| -> Result<String, String> {
95        let output = std::process::Command::new(cmd)
96            .args(["--version"])
97            .env("LEAN_CTX_ACTIVE", "1")
98            .output()
99            .map_err(|e| e.to_string())?;
100        if !output.status.success() {
101            return Err(format!(
102                "exited with {}",
103                output.status.code().unwrap_or(-1)
104            ));
105        }
106        let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
107        if text.is_empty() {
108            return Err("empty output".to_string());
109        }
110        Ok(text)
111    };
112
113    match try_run(&bin) {
114        Ok(text) => Outcome {
115            ok: true,
116            line: format!("{BOLD}lean-ctx version{RST}  {WHITE}{text}{RST}"),
117        },
118        Err(_first_err) => {
119            #[cfg(windows)]
120            {
121                let candidates = [
122                    bin.with_extension("exe"),
123                    bin.parent()
124                        .unwrap_or(std::path::Path::new("."))
125                        .join("node_modules")
126                        .join("lean-ctx-bin")
127                        .join("bin")
128                        .join("lean-ctx.exe"),
129                ];
130                for candidate in &candidates {
131                    if candidate.is_file() {
132                        if let Ok(text) = try_run(candidate) {
133                            return Outcome {
134                                ok: true,
135                                line: format!(
136                                    "{BOLD}lean-ctx version{RST}  {WHITE}{text}{RST}  {DIM}(via {}){RST}",
137                                    candidate.display()
138                                ),
139                            };
140                        }
141                    }
142                }
143            }
144
145            let current_exe_result = std::env::current_exe();
146            if let Ok(ref exe) = current_exe_result {
147                if exe != &bin {
148                    if let Ok(text) = try_run(exe) {
149                        return Outcome {
150                            ok: true,
151                            line: format!("{BOLD}lean-ctx version{RST}  {WHITE}{text}{RST}  {DIM}(this binary){RST}"),
152                        };
153                    }
154                }
155            }
156
157            Outcome {
158                ok: false,
159                line: format!(
160                    "{BOLD}lean-ctx version{RST}  {RED}failed to run `lean-ctx --version`: {_first_err}{RST}  {DIM}(resolved: {}){RST}",
161                    bin.display()
162                ),
163            }
164        }
165    }
166}
167
168fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
169    match std::fs::read_to_string(path) {
170        Ok(s) => s.contains("lean-ctx"),
171        Err(_) => false,
172    }
173}
174
175fn rc_has_pipe_guard(path: &PathBuf) -> bool {
176    match std::fs::read_to_string(path) {
177        Ok(s) => {
178            s.contains("! -t 1") || s.contains("isatty stdout") || s.contains("IsOutputRedirected")
179        }
180        Err(_) => false,
181    }
182}
183
184fn shell_aliases_outcome() -> Outcome {
185    let home = match dirs::home_dir() {
186        Some(h) => h,
187        None => {
188            return Outcome {
189                ok: false,
190                line: format!(
191                    "{BOLD}Shell aliases{RST}  {RED}could not resolve home directory{RST}"
192                ),
193            };
194        }
195    };
196
197    let mut parts = Vec::new();
198    let mut needs_update = Vec::new();
199
200    let zsh = home.join(".zshrc");
201    if rc_contains_lean_ctx(&zsh) {
202        parts.push(format!("{DIM}~/.zshrc{RST}"));
203        if !rc_has_pipe_guard(&zsh) {
204            needs_update.push("~/.zshrc");
205        }
206    }
207    let bash = home.join(".bashrc");
208    if rc_contains_lean_ctx(&bash) {
209        parts.push(format!("{DIM}~/.bashrc{RST}"));
210        if !rc_has_pipe_guard(&bash) {
211            needs_update.push("~/.bashrc");
212        }
213    }
214
215    let fish = home.join(".config").join("fish").join("config.fish");
216    if rc_contains_lean_ctx(&fish) {
217        parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
218        if !rc_has_pipe_guard(&fish) {
219            needs_update.push("~/.config/fish/config.fish");
220        }
221    }
222
223    #[cfg(windows)]
224    {
225        let ps_profile = home
226            .join("Documents")
227            .join("PowerShell")
228            .join("Microsoft.PowerShell_profile.ps1");
229        let ps_profile_legacy = home
230            .join("Documents")
231            .join("WindowsPowerShell")
232            .join("Microsoft.PowerShell_profile.ps1");
233        if rc_contains_lean_ctx(&ps_profile) {
234            parts.push(format!("{DIM}PowerShell profile{RST}"));
235            if !rc_has_pipe_guard(&ps_profile) {
236                needs_update.push("PowerShell profile");
237            }
238        } else if rc_contains_lean_ctx(&ps_profile_legacy) {
239            parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
240            if !rc_has_pipe_guard(&ps_profile_legacy) {
241                needs_update.push("WindowsPowerShell profile");
242            }
243        }
244    }
245
246    if parts.is_empty() {
247        let hint = if cfg!(windows) {
248            "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
249        } else {
250            "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
251        };
252        Outcome {
253            ok: false,
254            line: format!("{BOLD}Shell aliases{RST}  {RED}{hint}{RST}"),
255        }
256    } else if !needs_update.is_empty() {
257        Outcome {
258            ok: false,
259            line: format!(
260                "{BOLD}Shell aliases{RST}  {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
261                needs_update.join(", ")
262            ),
263        }
264    } else {
265        Outcome {
266            ok: true,
267            line: format!(
268                "{BOLD}Shell aliases{RST}  {GREEN}lean-ctx referenced in {}{RST}",
269                parts.join(", ")
270            ),
271        }
272    }
273}
274
275struct McpLocation {
276    name: &'static str,
277    display: String,
278    path: PathBuf,
279}
280
281fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
282    let mut locations = vec![
283        McpLocation {
284            name: "Cursor",
285            display: "~/.cursor/mcp.json".into(),
286            path: home.join(".cursor").join("mcp.json"),
287        },
288        McpLocation {
289            name: "Claude Code",
290            display: format!(
291                "{}",
292                crate::core::editor_registry::claude_mcp_json_path(home).display()
293            ),
294            path: crate::core::editor_registry::claude_mcp_json_path(home),
295        },
296        McpLocation {
297            name: "Windsurf",
298            display: "~/.codeium/windsurf/mcp_config.json".into(),
299            path: home
300                .join(".codeium")
301                .join("windsurf")
302                .join("mcp_config.json"),
303        },
304        McpLocation {
305            name: "Codex",
306            display: "~/.codex/config.toml".into(),
307            path: home.join(".codex").join("config.toml"),
308        },
309        McpLocation {
310            name: "Gemini CLI",
311            display: "~/.gemini/settings/mcp.json".into(),
312            path: home.join(".gemini").join("settings").join("mcp.json"),
313        },
314        McpLocation {
315            name: "Antigravity",
316            display: "~/.gemini/antigravity/mcp_config.json".into(),
317            path: home
318                .join(".gemini")
319                .join("antigravity")
320                .join("mcp_config.json"),
321        },
322    ];
323
324    #[cfg(unix)]
325    {
326        let zed_cfg = home.join(".config").join("zed").join("settings.json");
327        locations.push(McpLocation {
328            name: "Zed",
329            display: "~/.config/zed/settings.json".into(),
330            path: zed_cfg,
331        });
332    }
333
334    locations.push(McpLocation {
335        name: "Qwen Code",
336        display: "~/.qwen/mcp.json".into(),
337        path: home.join(".qwen").join("mcp.json"),
338    });
339    locations.push(McpLocation {
340        name: "Trae",
341        display: "~/.trae/mcp.json".into(),
342        path: home.join(".trae").join("mcp.json"),
343    });
344    locations.push(McpLocation {
345        name: "Amazon Q",
346        display: "~/.aws/amazonq/mcp.json".into(),
347        path: home.join(".aws").join("amazonq").join("mcp.json"),
348    });
349    locations.push(McpLocation {
350        name: "JetBrains",
351        display: "~/.jb-mcp.json".into(),
352        path: home.join(".jb-mcp.json"),
353    });
354    locations.push(McpLocation {
355        name: "AWS Kiro",
356        display: "~/.kiro/settings/mcp.json".into(),
357        path: home.join(".kiro").join("settings").join("mcp.json"),
358    });
359    locations.push(McpLocation {
360        name: "Verdent",
361        display: "~/.verdent/mcp.json".into(),
362        path: home.join(".verdent").join("mcp.json"),
363    });
364    locations.push(McpLocation {
365        name: "Crush",
366        display: "~/.config/crush/crush.json".into(),
367        path: home.join(".config").join("crush").join("crush.json"),
368    });
369
370    {
371        #[cfg(unix)]
372        let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
373        #[cfg(unix)]
374        let opencode_display = "~/.config/opencode/opencode.json";
375
376        #[cfg(windows)]
377        let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
378            std::path::PathBuf::from(appdata)
379                .join("opencode")
380                .join("opencode.json")
381        } else {
382            home.join(".config").join("opencode").join("opencode.json")
383        };
384        #[cfg(windows)]
385        let opencode_display = "%APPDATA%/opencode/opencode.json";
386
387        locations.push(McpLocation {
388            name: "OpenCode",
389            display: opencode_display.into(),
390            path: opencode_cfg,
391        });
392    }
393
394    #[cfg(target_os = "macos")]
395    {
396        let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
397        locations.push(McpLocation {
398            name: "VS Code / Copilot",
399            display: "~/Library/Application Support/Code/User/mcp.json".into(),
400            path: vscode_mcp,
401        });
402    }
403    #[cfg(target_os = "linux")]
404    {
405        let vscode_mcp = home.join(".config/Code/User/mcp.json");
406        locations.push(McpLocation {
407            name: "VS Code / Copilot",
408            display: "~/.config/Code/User/mcp.json".into(),
409            path: vscode_mcp,
410        });
411    }
412    #[cfg(target_os = "windows")]
413    {
414        if let Ok(appdata) = std::env::var("APPDATA") {
415            let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
416            locations.push(McpLocation {
417                name: "VS Code / Copilot",
418                display: "%APPDATA%/Code/User/mcp.json".into(),
419                path: vscode_mcp,
420            });
421        }
422    }
423
424    locations
425}
426
427fn docker_bash_env_outcome() -> Option<Outcome> {
428    if !crate::shell::is_container() {
429        return None;
430    }
431    let shell_name = std::env::var("SHELL").unwrap_or_default();
432    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
433    if !is_bash {
434        return None;
435    }
436    let has_bash_env = std::env::var("BASH_ENV").is_ok();
437    if has_bash_env {
438        Some(Outcome {
439            ok: true,
440            line: format!(
441                "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
442                std::env::var("BASH_ENV").unwrap_or_default()
443            ),
444        })
445    } else {
446        let env_sh = crate::core::data_dir::lean_ctx_data_dir()
447            .map(|d| d.join("env.sh").to_string_lossy().to_string())
448            .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
449        Some(Outcome {
450            ok: false,
451            line: format!(
452                "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(Docker detected — add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
453            ),
454        })
455    }
456}
457
458fn mcp_config_outcome() -> Outcome {
459    let home = match dirs::home_dir() {
460        Some(h) => h,
461        None => {
462            return Outcome {
463                ok: false,
464                line: format!("{BOLD}MCP config{RST}  {RED}could not resolve home directory{RST}"),
465            };
466        }
467    };
468
469    let locations = mcp_config_locations(&home);
470    let mut found: Vec<String> = Vec::new();
471    let mut exists_no_ref: Vec<String> = Vec::new();
472
473    for loc in &locations {
474        if let Ok(content) = std::fs::read_to_string(&loc.path) {
475            if has_lean_ctx_mcp_entry(&content) {
476                found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
477            } else {
478                exists_no_ref.push(loc.name.to_string());
479            }
480        }
481    }
482
483    found.sort();
484    found.dedup();
485    exists_no_ref.sort();
486    exists_no_ref.dedup();
487
488    if !found.is_empty() {
489        Outcome {
490            ok: true,
491            line: format!(
492                "{BOLD}MCP config{RST}  {GREEN}lean-ctx found in: {}{RST}",
493                found.join(", ")
494            ),
495        }
496    } else if !exists_no_ref.is_empty() {
497        let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
498        let cause = if has_claude {
499            format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
500        } else {
501            String::new()
502        };
503        let hint = if has_claude {
504            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
505        } else {
506            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
507        };
508        Outcome {
509            ok: false,
510            line: format!(
511                "{BOLD}MCP config{RST}  {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST}  {cause} {hint}",
512                exists_no_ref.join(", "),
513            ),
514        }
515    } else {
516        Outcome {
517            ok: false,
518            line: format!(
519                "{BOLD}MCP config{RST}  {YELLOW}no MCP config found{RST}  {DIM}(run: lean-ctx setup){RST}"
520            ),
521        }
522    }
523}
524
525fn has_lean_ctx_mcp_entry(content: &str) -> bool {
526    if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
527        if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
528            return servers.contains_key("lean-ctx");
529        }
530        if let Some(servers) = json
531            .get("mcp")
532            .and_then(|v| v.get("servers"))
533            .and_then(|v| v.as_object())
534        {
535            return servers.contains_key("lean-ctx");
536        }
537    }
538    content.contains("lean-ctx")
539}
540
541fn port_3333_outcome() -> Outcome {
542    match TcpListener::bind("127.0.0.1:3333") {
543        Ok(_listener) => Outcome {
544            ok: true,
545            line: format!("{BOLD}Dashboard port 3333{RST}  {GREEN}available on 127.0.0.1{RST}"),
546        },
547        Err(e) => Outcome {
548            ok: false,
549            line: format!("{BOLD}Dashboard port 3333{RST}  {RED}not available: {e}{RST}"),
550        },
551    }
552}
553
554fn pi_outcome() -> Option<Outcome> {
555    let pi_result = std::process::Command::new("pi").arg("--version").output();
556
557    match pi_result {
558        Ok(output) if output.status.success() => {
559            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
560            let has_plugin = std::process::Command::new("pi")
561                .args(["list"])
562                .output()
563                .map(|o| String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx"))
564                .unwrap_or(false);
565
566            let has_mcp = dirs::home_dir()
567                .map(|h| h.join(".pi/agent/mcp.json"))
568                .and_then(|p| std::fs::read_to_string(p).ok())
569                .map(|c| c.contains("lean-ctx"))
570                .unwrap_or(false);
571
572            if has_plugin && has_mcp {
573                Some(Outcome {
574                    ok: true,
575                    line: format!(
576                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
577                    ),
578                })
579            } else if has_plugin {
580                Some(Outcome {
581                    ok: true,
582                    line: format!(
583                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx installed{RST}  {DIM}(MCP not configured — embedded bridge active){RST}"
584                    ),
585                })
586            } else {
587                Some(Outcome {
588                    ok: false,
589                    line: format!(
590                        "{BOLD}Pi Coding Agent{RST}  {YELLOW}{version}, but pi-lean-ctx not installed{RST}  {DIM}(run: pi install npm:pi-lean-ctx){RST}"
591                    ),
592                })
593            }
594        }
595        _ => None,
596    }
597}
598
599fn session_state_outcome() -> Outcome {
600    use crate::core::session::SessionState;
601
602    match SessionState::load_latest() {
603        Some(session) => {
604            let root = session
605                .project_root
606                .as_deref()
607                .unwrap_or("(not set)");
608            let cwd = session
609                .shell_cwd
610                .as_deref()
611                .unwrap_or("(not tracked)");
612            Outcome {
613                ok: true,
614                line: format!(
615                    "{BOLD}Session state{RST}  {GREEN}active{RST}  {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
616                    session.version
617                ),
618            }
619        }
620        None => Outcome {
621            ok: true,
622            line: format!(
623                "{BOLD}Session state{RST}  {YELLOW}no active session{RST}  {DIM}(will be created on first tool call){RST}"
624            ),
625        },
626    }
627}
628
629fn docker_env_outcomes() -> Vec<Outcome> {
630    if !crate::shell::is_container() {
631        return vec![];
632    }
633    let env_sh = dirs::home_dir()
634        .map(|h| {
635            h.join(".lean-ctx")
636                .join("env.sh")
637                .to_string_lossy()
638                .to_string()
639        })
640        .unwrap_or_else(|| "/root/.lean-ctx/env.sh".to_string());
641
642    let mut outcomes = vec![];
643
644    let shell_name = std::env::var("SHELL").unwrap_or_default();
645    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
646
647    if is_bash {
648        let has_bash_env = std::env::var("BASH_ENV").is_ok();
649        outcomes.push(if has_bash_env {
650            Outcome {
651                ok: true,
652                line: format!(
653                    "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
654                    std::env::var("BASH_ENV").unwrap_or_default()
655                ),
656            }
657        } else {
658            Outcome {
659                ok: false,
660                line: format!(
661                    "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
662                ),
663            }
664        });
665    }
666
667    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
668    outcomes.push(if has_claude_env {
669        Outcome {
670            ok: true,
671            line: format!(
672                "{BOLD}CLAUDE_ENV_FILE{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
673                std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
674            ),
675        }
676    } else {
677        Outcome {
678            ok: false,
679            line: format!(
680                "{BOLD}CLAUDE_ENV_FILE{RST}  {RED}not set{RST}  {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
681            ),
682        }
683    });
684
685    outcomes
686}
687
688/// Run diagnostic checks and print colored results to stdout.
689pub fn run() {
690    let mut passed = 0u32;
691    let total = 8u32;
692
693    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
694
695    // 1) Binary on PATH
696    let path_bin = resolve_lean_ctx_binary();
697    let also_in_path_dirs = path_in_path_env();
698    let bin_ok = path_bin.is_some() || also_in_path_dirs;
699    if bin_ok {
700        passed += 1;
701    }
702    let bin_line = if let Some(p) = path_bin {
703        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
704    } else if also_in_path_dirs {
705        format!(
706            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
707        )
708    } else {
709        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
710    };
711    print_check(&Outcome {
712        ok: bin_ok,
713        line: bin_line,
714    });
715
716    // 2) Version from PATH binary
717    let ver = if bin_ok {
718        lean_ctx_version_from_path()
719    } else {
720        Outcome {
721            ok: false,
722            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
723        }
724    };
725    if ver.ok {
726        passed += 1;
727    }
728    print_check(&ver);
729
730    // 3) ~/.lean-ctx directory
731    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
732    let dir_outcome = match &lean_dir {
733        Some(p) if p.is_dir() => {
734            passed += 1;
735            Outcome {
736                ok: true,
737                line: format!(
738                    "{BOLD}~/.lean-ctx/{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
739                    p.display()
740                ),
741            }
742        }
743        Some(p) => Outcome {
744            ok: false,
745            line: format!(
746                "{BOLD}~/.lean-ctx/{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
747                p.display()
748            ),
749        },
750        None => Outcome {
751            ok: false,
752            line: format!("{BOLD}~/.lean-ctx/{RST}  {RED}could not resolve home directory{RST}"),
753        },
754    };
755    print_check(&dir_outcome);
756
757    // 4) stats.json + size
758    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
759    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
760        Some(m) if m.is_file() => {
761            passed += 1;
762            let size = m.len();
763            Outcome {
764                ok: true,
765                line: format!(
766                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{}{RST}",
767                    stats_path.as_ref().unwrap().display()
768                ),
769            }
770        }
771        Some(_m) => Outcome {
772            ok: false,
773            line: format!(
774                "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{}{RST}",
775                stats_path.as_ref().unwrap().display()
776            ),
777        },
778        None => {
779            passed += 1;
780            Outcome {
781                ok: true,
782                line: match &stats_path {
783                    Some(p) => format!(
784                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
785                        p.display()
786                    ),
787                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
788                },
789            }
790        }
791    };
792    print_check(&stats_outcome);
793
794    // 5) config.toml (missing is OK)
795    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
796    let config_outcome = match &config_path {
797        Some(p) => match std::fs::metadata(p) {
798            Ok(m) if m.is_file() => {
799                passed += 1;
800                Outcome {
801                    ok: true,
802                    line: format!(
803                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
804                        p.display()
805                    ),
806                }
807            }
808            Ok(_) => Outcome {
809                ok: false,
810                line: format!(
811                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
812                    p.display()
813                ),
814            },
815            Err(_) => {
816                passed += 1;
817                Outcome {
818                    ok: true,
819                    line: format!(
820                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
821                        p.display()
822                    ),
823                }
824            }
825        },
826        None => Outcome {
827            ok: false,
828            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
829        },
830    };
831    print_check(&config_outcome);
832
833    // 6) Shell aliases
834    let aliases = shell_aliases_outcome();
835    if aliases.ok {
836        passed += 1;
837    }
838    print_check(&aliases);
839
840    // 7) Docker BASH_ENV (only shown when in a container)
841    let docker_outcome = docker_bash_env_outcome();
842    if let Some(ref docker_check) = docker_outcome {
843        if docker_check.ok {
844            passed += 1;
845        }
846        print_check(docker_check);
847    }
848
849    // 8) MCP
850    let mcp = mcp_config_outcome();
851    if mcp.ok {
852        passed += 1;
853    }
854    print_check(&mcp);
855
856    // 9) Port
857    let port = port_3333_outcome();
858    if port.ok {
859        passed += 1;
860    }
861    print_check(&port);
862
863    // 9) Session state (project_root + shell_cwd)
864    let session_outcome = session_state_outcome();
865    if session_outcome.ok {
866        passed += 1;
867    }
868    print_check(&session_outcome);
869
870    // 10) Docker env vars (optional, only in containers)
871    let docker_outcomes = docker_env_outcomes();
872    for docker_check in &docker_outcomes {
873        if docker_check.ok {
874            passed += 1;
875        }
876        print_check(docker_check);
877    }
878
879    // 11) Pi Coding Agent (optional)
880    let pi = pi_outcome();
881    if let Some(ref pi_check) = pi {
882        if pi_check.ok {
883            passed += 1;
884        }
885        print_check(pi_check);
886    }
887
888    // 12) Build integrity (canary / origin check)
889    let integrity = crate::core::integrity::check();
890    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
891    if integrity_ok {
892        passed += 1;
893    }
894    let integrity_line = if integrity_ok {
895        format!(
896            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
897            integrity.repo
898        )
899    } else {
900        format!(
901            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
902            integrity.pkg_name, integrity.repo
903        )
904    };
905    print_check(&Outcome {
906        ok: integrity_ok,
907        line: integrity_line,
908    });
909
910    // 13) Claude Code instruction truncation guard
911    let claude_truncation = claude_truncation_outcome();
912    if let Some(ref ct) = claude_truncation {
913        if ct.ok {
914            passed += 1;
915        }
916        print_check(ct);
917    }
918
919    let mut effective_total = total + 2; // session_state + integrity always shown
920    effective_total += docker_outcomes.len() as u32;
921    if pi.is_some() {
922        effective_total += 1;
923    }
924    if claude_truncation.is_some() {
925        effective_total += 1;
926    }
927    println!();
928    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
929    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
930}
931
932fn claude_binary_exists() -> bool {
933    #[cfg(unix)]
934    {
935        std::process::Command::new("which")
936            .arg("claude")
937            .output()
938            .map(|o| o.status.success())
939            .unwrap_or(false)
940    }
941    #[cfg(windows)]
942    {
943        std::process::Command::new("where")
944            .arg("claude")
945            .output()
946            .map(|o| o.status.success())
947            .unwrap_or(false)
948    }
949}
950
951fn claude_truncation_outcome() -> Option<Outcome> {
952    let home = dirs::home_dir()?;
953    let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
954        || crate::core::editor_registry::claude_state_dir(&home).exists()
955        || claude_binary_exists();
956
957    if !claude_detected {
958        return None;
959    }
960
961    let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
962    let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
963
964    let has_rules = rules_path.exists();
965    let has_skill = skill_path.exists();
966
967    if has_rules && has_skill {
968        Some(Outcome {
969            ok: true,
970            line: format!(
971                "{BOLD}Claude Code instructions{RST}  {GREEN}rules + skill installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
972            ),
973        })
974    } else if has_rules {
975        Some(Outcome {
976            ok: true,
977            line: format!(
978                "{BOLD}Claude Code instructions{RST}  {GREEN}rules file installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
979            ),
980        })
981    } else {
982        Some(Outcome {
983            ok: false,
984            line: format!(
985                "{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}"
986            ),
987        })
988    }
989}
990
991pub fn run_compact() {
992    let (passed, total) = compact_score();
993    print_compact_status(passed, total);
994}
995
996pub fn run_cli(args: &[String]) -> i32 {
997    let fix = args.iter().any(|a| a == "--fix");
998    let json = args.iter().any(|a| a == "--json");
999    let help = args.iter().any(|a| a == "--help" || a == "-h");
1000
1001    if help {
1002        println!("Usage:");
1003        println!("  lean-ctx doctor");
1004        println!("  lean-ctx doctor --fix [--json]");
1005        return 0;
1006    }
1007
1008    if !fix {
1009        run();
1010        return 0;
1011    }
1012
1013    match run_fix(DoctorFixOptions { json }) {
1014        Ok(code) => code,
1015        Err(e) => {
1016            eprintln!("{RED}doctor --fix failed:{RST} {e}");
1017            2
1018        }
1019    }
1020}
1021
1022struct DoctorFixOptions {
1023    json: bool,
1024}
1025
1026fn run_fix(opts: DoctorFixOptions) -> Result<i32, String> {
1027    use crate::core::setup_report::{
1028        doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
1029    };
1030
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 = dirs::home_dir().map(|h| h.join(".lean-ctx"));
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}