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