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