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