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;
7use serde::Serialize;
8
9const GREEN: &str = "\x1b[32m";
10const RED: &str = "\x1b[31m";
11const BOLD: &str = "\x1b[1m";
12const RST: &str = "\x1b[0m";
13const DIM: &str = "\x1b[2m";
14const WHITE: &str = "\x1b[97m";
15const YELLOW: &str = "\x1b[33m";
16
17struct Outcome {
18    ok: bool,
19    line: String,
20}
21
22fn print_check(outcome: &Outcome) {
23    let mark = if outcome.ok {
24        format!("{GREEN}✓{RST}")
25    } else {
26        format!("{RED}✗{RST}")
27    };
28    println!("  {mark}  {}", outcome.line);
29}
30
31fn path_in_path_env() -> bool {
32    if let Ok(path) = std::env::var("PATH") {
33        for dir in std::env::split_paths(&path) {
34            if dir.join("lean-ctx").is_file() {
35                return true;
36            }
37            if cfg!(windows)
38                && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
39            {
40                return true;
41            }
42        }
43    }
44    false
45}
46
47fn resolve_lean_ctx_binary() -> Option<PathBuf> {
48    if let Ok(path) = std::env::var("PATH") {
49        for dir in std::env::split_paths(&path) {
50            if cfg!(windows) {
51                let exe = dir.join("lean-ctx.exe");
52                if exe.is_file() {
53                    return Some(exe);
54                }
55                let cmd = dir.join("lean-ctx.cmd");
56                if cmd.is_file() {
57                    return Some(cmd);
58                }
59            } else {
60                let bin = dir.join("lean-ctx");
61                if bin.is_file() {
62                    return Some(bin);
63                }
64            }
65        }
66    }
67    None
68}
69
70fn lean_ctx_version_from_path() -> Outcome {
71    let resolved = resolve_lean_ctx_binary();
72    let bin = resolved
73        .clone()
74        .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
75
76    let v = env!("CARGO_PKG_VERSION");
77    let note = match std::env::current_exe() {
78        Ok(exe) if exe == bin => format!("{DIM}(this binary){RST}"),
79        Ok(_) | 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 has_pipe_guard_in_content(content: &str) -> bool {
95    content.contains("! -t 1")
96        || content.contains("isatty stdout")
97        || content.contains("IsOutputRedirected")
98}
99
100fn rc_references_shell_hook(content: &str) -> bool {
101    content.contains("lean-ctx/shell-hook.") || content.contains("lean-ctx\\shell-hook.")
102}
103
104fn rc_has_pipe_guard(path: &PathBuf) -> bool {
105    match std::fs::read_to_string(path) {
106        Ok(s) => {
107            if has_pipe_guard_in_content(&s) {
108                return true;
109            }
110            if rc_references_shell_hook(&s) {
111                let dirs_to_check = hook_dirs();
112                for dir in &dirs_to_check {
113                    for ext in &["zsh", "bash", "fish", "ps1"] {
114                        let hook = dir.join(format!("shell-hook.{ext}"));
115                        if let Ok(h) = std::fs::read_to_string(&hook) {
116                            if has_pipe_guard_in_content(&h) {
117                                return true;
118                            }
119                        }
120                    }
121                }
122            }
123            false
124        }
125        Err(_) => false,
126    }
127}
128
129fn hook_dirs() -> Vec<std::path::PathBuf> {
130    let mut dirs = Vec::new();
131    if let Ok(d) = crate::core::data_dir::lean_ctx_data_dir() {
132        dirs.push(d);
133    }
134    if let Some(home) = dirs::home_dir() {
135        let legacy = home.join(".lean-ctx");
136        if !dirs.iter().any(|d| d == &legacy) {
137            dirs.push(legacy);
138        }
139        let xdg = home.join(".config").join("lean-ctx");
140        if !dirs.iter().any(|d| d == &xdg) {
141            dirs.push(xdg);
142        }
143    }
144    dirs
145}
146
147fn is_active_shell(rc_name: &str) -> bool {
148    let shell = std::env::var("SHELL").unwrap_or_default();
149    match rc_name {
150        "~/.zshrc" => shell.contains("zsh"),
151        "~/.bashrc" => shell.contains("bash") || shell.is_empty(),
152        "~/.config/fish/config.fish" => shell.contains("fish"),
153        _ => true,
154    }
155}
156
157fn shell_aliases_outcome() -> Outcome {
158    let Some(home) = dirs::home_dir() else {
159        return Outcome {
160            ok: false,
161            line: format!("{BOLD}Shell aliases{RST}  {RED}could not resolve home directory{RST}"),
162        };
163    };
164
165    let mut parts = Vec::new();
166    let mut needs_update = Vec::new();
167
168    let zsh = home.join(".zshrc");
169    if rc_contains_lean_ctx(&zsh) {
170        parts.push(format!("{DIM}~/.zshrc{RST}"));
171        if !rc_has_pipe_guard(&zsh) && is_active_shell("~/.zshrc") {
172            needs_update.push("~/.zshrc");
173        }
174    }
175    let bash = home.join(".bashrc");
176    if rc_contains_lean_ctx(&bash) {
177        parts.push(format!("{DIM}~/.bashrc{RST}"));
178        if !rc_has_pipe_guard(&bash) && is_active_shell("~/.bashrc") {
179            needs_update.push("~/.bashrc");
180        }
181    }
182
183    let fish = home.join(".config").join("fish").join("config.fish");
184    if rc_contains_lean_ctx(&fish) {
185        parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
186        if !rc_has_pipe_guard(&fish) && is_active_shell("~/.config/fish/config.fish") {
187            needs_update.push("~/.config/fish/config.fish");
188        }
189    }
190
191    #[cfg(windows)]
192    {
193        let ps_profile = home
194            .join("Documents")
195            .join("PowerShell")
196            .join("Microsoft.PowerShell_profile.ps1");
197        let ps_profile_legacy = home
198            .join("Documents")
199            .join("WindowsPowerShell")
200            .join("Microsoft.PowerShell_profile.ps1");
201        if rc_contains_lean_ctx(&ps_profile) {
202            parts.push(format!("{DIM}PowerShell profile{RST}"));
203            if !rc_has_pipe_guard(&ps_profile) {
204                needs_update.push("PowerShell profile");
205            }
206        } else if rc_contains_lean_ctx(&ps_profile_legacy) {
207            parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
208            if !rc_has_pipe_guard(&ps_profile_legacy) {
209                needs_update.push("WindowsPowerShell profile");
210            }
211        }
212    }
213
214    if parts.is_empty() {
215        let hint = if cfg!(windows) {
216            "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
217        } else {
218            "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
219        };
220        Outcome {
221            ok: false,
222            line: format!("{BOLD}Shell aliases{RST}  {RED}{hint}{RST}"),
223        }
224    } else if !needs_update.is_empty() {
225        Outcome {
226            ok: false,
227            line: format!(
228                "{BOLD}Shell aliases{RST}  {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
229                needs_update.join(", ")
230            ),
231        }
232    } else {
233        Outcome {
234            ok: true,
235            line: format!(
236                "{BOLD}Shell aliases{RST}  {GREEN}lean-ctx referenced in {}{RST}",
237                parts.join(", ")
238            ),
239        }
240    }
241}
242
243struct McpLocation {
244    name: &'static str,
245    display: String,
246    path: PathBuf,
247}
248
249fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
250    let mut locations = vec![
251        McpLocation {
252            name: "Cursor",
253            display: "~/.cursor/mcp.json".into(),
254            path: home.join(".cursor").join("mcp.json"),
255        },
256        McpLocation {
257            name: "Claude Code",
258            display: format!(
259                "{}",
260                crate::core::editor_registry::claude_mcp_json_path(home).display()
261            ),
262            path: crate::core::editor_registry::claude_mcp_json_path(home),
263        },
264        McpLocation {
265            name: "Windsurf",
266            display: "~/.codeium/windsurf/mcp_config.json".into(),
267            path: home
268                .join(".codeium")
269                .join("windsurf")
270                .join("mcp_config.json"),
271        },
272        McpLocation {
273            name: "Codex",
274            display: "~/.codex/config.toml".into(),
275            path: home.join(".codex").join("config.toml"),
276        },
277        McpLocation {
278            name: "Gemini CLI",
279            display: "~/.gemini/settings.json".into(),
280            path: home.join(".gemini").join("settings.json"),
281        },
282        McpLocation {
283            name: "Antigravity",
284            display: "~/.gemini/antigravity/mcp_config.json".into(),
285            path: home
286                .join(".gemini")
287                .join("antigravity")
288                .join("mcp_config.json"),
289        },
290    ];
291
292    #[cfg(unix)]
293    {
294        let zed_cfg = home.join(".config").join("zed").join("settings.json");
295        locations.push(McpLocation {
296            name: "Zed",
297            display: "~/.config/zed/settings.json".into(),
298            path: zed_cfg,
299        });
300    }
301
302    locations.push(McpLocation {
303        name: "Qwen Code",
304        display: "~/.qwen/settings.json".into(),
305        path: home.join(".qwen").join("settings.json"),
306    });
307    locations.push(McpLocation {
308        name: "Trae",
309        display: "~/.trae/mcp.json".into(),
310        path: home.join(".trae").join("mcp.json"),
311    });
312    locations.push(McpLocation {
313        name: "Amazon Q",
314        display: "~/.aws/amazonq/default.json".into(),
315        path: home.join(".aws").join("amazonq").join("default.json"),
316    });
317    locations.push(McpLocation {
318        name: "JetBrains",
319        display: "~/.jb-mcp.json".into(),
320        path: home.join(".jb-mcp.json"),
321    });
322    locations.push(McpLocation {
323        name: "AWS Kiro",
324        display: "~/.kiro/settings/mcp.json".into(),
325        path: home.join(".kiro").join("settings").join("mcp.json"),
326    });
327    locations.push(McpLocation {
328        name: "Verdent",
329        display: "~/.verdent/mcp.json".into(),
330        path: home.join(".verdent").join("mcp.json"),
331    });
332    locations.push(McpLocation {
333        name: "Crush",
334        display: "~/.config/crush/crush.json".into(),
335        path: home.join(".config").join("crush").join("crush.json"),
336    });
337    locations.push(McpLocation {
338        name: "Pi",
339        display: "~/.pi/agent/mcp.json".into(),
340        path: home.join(".pi").join("agent").join("mcp.json"),
341    });
342    locations.push(McpLocation {
343        name: "Amp",
344        display: "~/.config/amp/settings.json".into(),
345        path: home.join(".config").join("amp").join("settings.json"),
346    });
347
348    {
349        #[cfg(unix)]
350        let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
351        #[cfg(unix)]
352        let opencode_display = "~/.config/opencode/opencode.json";
353
354        #[cfg(windows)]
355        let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
356            std::path::PathBuf::from(appdata)
357                .join("opencode")
358                .join("opencode.json")
359        } else {
360            home.join(".config").join("opencode").join("opencode.json")
361        };
362        #[cfg(windows)]
363        let opencode_display = "%APPDATA%/opencode/opencode.json";
364
365        locations.push(McpLocation {
366            name: "OpenCode",
367            display: opencode_display.into(),
368            path: opencode_cfg,
369        });
370    }
371
372    #[cfg(target_os = "macos")]
373    {
374        let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
375        locations.push(McpLocation {
376            name: "VS Code / Copilot",
377            display: "~/Library/Application Support/Code/User/mcp.json".into(),
378            path: vscode_mcp,
379        });
380    }
381    #[cfg(target_os = "linux")]
382    {
383        let vscode_mcp = home.join(".config/Code/User/mcp.json");
384        locations.push(McpLocation {
385            name: "VS Code / Copilot",
386            display: "~/.config/Code/User/mcp.json".into(),
387            path: vscode_mcp,
388        });
389    }
390    #[cfg(target_os = "windows")]
391    {
392        if let Ok(appdata) = std::env::var("APPDATA") {
393            let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
394            locations.push(McpLocation {
395                name: "VS Code / Copilot",
396                display: "%APPDATA%/Code/User/mcp.json".into(),
397                path: vscode_mcp,
398            });
399        }
400    }
401
402    locations.push(McpLocation {
403        name: "Hermes Agent",
404        display: "~/.hermes/config.yaml".into(),
405        path: home.join(".hermes").join("config.yaml"),
406    });
407
408    {
409        let cline_path = crate::core::editor_registry::cline_mcp_path();
410        if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
411            locations.push(McpLocation {
412                name: "Cline",
413                display: cline_path.display().to_string(),
414                path: cline_path,
415            });
416        }
417    }
418    {
419        let roo_path = crate::core::editor_registry::roo_mcp_path();
420        if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
421            locations.push(McpLocation {
422                name: "Roo Code",
423                display: roo_path.display().to_string(),
424                path: roo_path,
425            });
426        }
427    }
428
429    locations
430}
431
432fn mcp_config_outcome() -> Outcome {
433    let Some(home) = dirs::home_dir() else {
434        return Outcome {
435            ok: false,
436            line: format!("{BOLD}MCP config{RST}  {RED}could not resolve home directory{RST}"),
437        };
438    };
439
440    let locations = mcp_config_locations(&home);
441    let mut found: Vec<String> = Vec::new();
442    let mut exists_no_ref: Vec<String> = Vec::new();
443
444    for loc in &locations {
445        if let Ok(content) = std::fs::read_to_string(&loc.path) {
446            if has_lean_ctx_mcp_entry(&content) {
447                found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
448            } else {
449                exists_no_ref.push(loc.name.to_string());
450            }
451        }
452    }
453
454    found.sort();
455    found.dedup();
456    exists_no_ref.sort();
457    exists_no_ref.dedup();
458
459    if !found.is_empty() {
460        Outcome {
461            ok: true,
462            line: format!(
463                "{BOLD}MCP config{RST}  {GREEN}lean-ctx found in: {}{RST}",
464                found.join(", ")
465            ),
466        }
467    } else if !exists_no_ref.is_empty() {
468        let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
469        let cause = if has_claude {
470            format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
471        } else {
472            String::new()
473        };
474        let hint = if has_claude {
475            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
476        } else {
477            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
478        };
479        Outcome {
480            ok: false,
481            line: format!(
482                "{BOLD}MCP config{RST}  {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST}  {cause} {hint}",
483                exists_no_ref.join(", "),
484            ),
485        }
486    } else {
487        Outcome {
488            ok: false,
489            line: format!(
490                "{BOLD}MCP config{RST}  {YELLOW}no MCP config found{RST}  {DIM}(run: lean-ctx setup){RST}"
491            ),
492        }
493    }
494}
495
496fn has_lean_ctx_mcp_entry(content: &str) -> bool {
497    if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
498        if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
499            return servers.contains_key("lean-ctx");
500        }
501        if let Some(servers) = json
502            .get("mcp")
503            .and_then(|v| v.get("servers"))
504            .and_then(|v| v.as_object())
505        {
506            return servers.contains_key("lean-ctx");
507        }
508    }
509    content.contains("lean-ctx")
510}
511
512fn port_3333_outcome() -> Outcome {
513    match TcpListener::bind("127.0.0.1:3333") {
514        Ok(_listener) => Outcome {
515            ok: true,
516            line: format!("{BOLD}Dashboard port 3333{RST}  {GREEN}available on 127.0.0.1{RST}"),
517        },
518        Err(e) => Outcome {
519            ok: false,
520            line: format!("{BOLD}Dashboard port 3333{RST}  {RED}not available: {e}{RST}"),
521        },
522    }
523}
524
525fn pi_outcome() -> Option<Outcome> {
526    let pi_result = std::process::Command::new("pi").arg("--version").output();
527
528    match pi_result {
529        Ok(output) if output.status.success() => {
530            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
531            let has_plugin = std::process::Command::new("pi")
532                .args(["list"])
533                .output()
534                .is_ok_and(|o| {
535                    o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
536                });
537
538            let has_mcp = dirs::home_dir()
539                .map(|h| h.join(".pi/agent/mcp.json"))
540                .and_then(|p| std::fs::read_to_string(p).ok())
541                .is_some_and(|c| c.contains("lean-ctx"));
542
543            if has_plugin && has_mcp {
544                Some(Outcome {
545                    ok: true,
546                    line: format!(
547                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
548                    ),
549                })
550            } else if has_plugin {
551                Some(Outcome {
552                    ok: true,
553                    line: format!(
554                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx installed{RST}  {DIM}(MCP not configured — embedded bridge active){RST}"
555                    ),
556                })
557            } else {
558                Some(Outcome {
559                    ok: false,
560                    line: format!(
561                        "{BOLD}Pi Coding Agent{RST}  {YELLOW}{version}, but pi-lean-ctx not installed{RST}  {DIM}(run: pi install npm:pi-lean-ctx){RST}"
562                    ),
563                })
564            }
565        }
566        _ => None,
567    }
568}
569
570fn session_state_outcome() -> Outcome {
571    use crate::core::session::SessionState;
572
573    match SessionState::load_latest() {
574        Some(session) => {
575            let root = session
576                .project_root
577                .as_deref()
578                .unwrap_or("(not set)");
579            let cwd = session
580                .shell_cwd
581                .as_deref()
582                .unwrap_or("(not tracked)");
583            Outcome {
584                ok: true,
585                line: format!(
586                    "{BOLD}Session state{RST}  {GREEN}active{RST}  {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
587                    session.version
588                ),
589            }
590        }
591        None => Outcome {
592            ok: true,
593            line: format!(
594                "{BOLD}Session state{RST}  {YELLOW}no active session{RST}  {DIM}(will be created on first tool call){RST}"
595            ),
596        },
597    }
598}
599
600fn docker_env_outcomes() -> Vec<Outcome> {
601    if !crate::shell::is_container() {
602        return vec![];
603    }
604    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
605        |_| "/root/.lean-ctx/env.sh".to_string(),
606        |d| d.join("env.sh").to_string_lossy().to_string(),
607    );
608
609    let mut outcomes = vec![];
610
611    let shell_name = std::env::var("SHELL").unwrap_or_default();
612    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
613
614    if is_bash {
615        let has_bash_env = std::env::var("BASH_ENV").is_ok();
616        outcomes.push(if has_bash_env {
617            Outcome {
618                ok: true,
619                line: format!(
620                    "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
621                    std::env::var("BASH_ENV").unwrap_or_default()
622                ),
623            }
624        } else {
625            Outcome {
626                ok: false,
627                line: format!(
628                    "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
629                ),
630            }
631        });
632    }
633
634    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
635    outcomes.push(if has_claude_env {
636        Outcome {
637            ok: true,
638            line: format!(
639                "{BOLD}CLAUDE_ENV_FILE{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
640                std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
641            ),
642        }
643    } else {
644        Outcome {
645            ok: false,
646            line: format!(
647                "{BOLD}CLAUDE_ENV_FILE{RST}  {RED}not set{RST}  {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
648            ),
649        }
650    });
651
652    outcomes
653}
654
655/// Run diagnostic checks and print colored results to stdout.
656pub fn run() {
657    let mut passed = 0u32;
658    let total = 9u32;
659
660    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
661
662    // 1) Binary on PATH
663    let path_bin = resolve_lean_ctx_binary();
664    let also_in_path_dirs = path_in_path_env();
665    let bin_ok = path_bin.is_some() || also_in_path_dirs;
666    if bin_ok {
667        passed += 1;
668    }
669    let bin_line = if let Some(p) = path_bin {
670        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
671    } else if also_in_path_dirs {
672        format!(
673            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
674        )
675    } else {
676        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
677    };
678    print_check(&Outcome {
679        ok: bin_ok,
680        line: bin_line,
681    });
682
683    // 2) Version from PATH binary
684    let ver = if bin_ok {
685        lean_ctx_version_from_path()
686    } else {
687        Outcome {
688            ok: false,
689            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
690        }
691    };
692    if ver.ok {
693        passed += 1;
694    }
695    print_check(&ver);
696
697    // 3) data directory (respects LEAN_CTX_DATA_DIR)
698    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
699    let dir_outcome = match &lean_dir {
700        Some(p) if p.is_dir() => {
701            passed += 1;
702            Outcome {
703                ok: true,
704                line: format!(
705                    "{BOLD}data dir{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
706                    p.display()
707                ),
708            }
709        }
710        Some(p) => Outcome {
711            ok: false,
712            line: format!(
713                "{BOLD}data dir{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
714                p.display()
715            ),
716        },
717        None => Outcome {
718            ok: false,
719            line: format!("{BOLD}data dir{RST}  {RED}could not resolve data directory{RST}"),
720        },
721    };
722    print_check(&dir_outcome);
723
724    // 4) stats.json + size
725    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
726    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
727        Some(m) if m.is_file() => {
728            passed += 1;
729            let size = m.len();
730            let path_display = if let Some(p) = stats_path.as_ref() {
731                p.display().to_string()
732            } else {
733                String::new()
734            };
735            Outcome {
736                ok: true,
737                line: format!(
738                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{path_display}{RST}",
739                ),
740            }
741        }
742        Some(_m) => {
743            let path_display = if let Some(p) = stats_path.as_ref() {
744                p.display().to_string()
745            } else {
746                String::new()
747            };
748            Outcome {
749                ok: false,
750                line: format!(
751                    "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{path_display}{RST}",
752                ),
753            }
754        }
755        None => {
756            passed += 1;
757            Outcome {
758                ok: true,
759                line: match &stats_path {
760                    Some(p) => format!(
761                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
762                        p.display()
763                    ),
764                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
765                },
766            }
767        }
768    };
769    print_check(&stats_outcome);
770
771    // 5) config.toml (missing is OK)
772    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
773    let config_outcome = match &config_path {
774        Some(p) => match std::fs::metadata(p) {
775            Ok(m) if m.is_file() => {
776                passed += 1;
777                Outcome {
778                    ok: true,
779                    line: format!(
780                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
781                        p.display()
782                    ),
783                }
784            }
785            Ok(_) => Outcome {
786                ok: false,
787                line: format!(
788                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
789                    p.display()
790                ),
791            },
792            Err(_) => {
793                passed += 1;
794                Outcome {
795                    ok: true,
796                    line: format!(
797                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
798                        p.display()
799                    ),
800                }
801            }
802        },
803        None => Outcome {
804            ok: false,
805            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
806        },
807    };
808    print_check(&config_outcome);
809
810    // 6) Shell aliases
811    let aliases = shell_aliases_outcome();
812    if aliases.ok {
813        passed += 1;
814    }
815    print_check(&aliases);
816
817    // 7) MCP
818    let mcp = mcp_config_outcome();
819    if mcp.ok {
820        passed += 1;
821    }
822    print_check(&mcp);
823
824    // 9) SKILL.md
825    let skill = skill_files_outcome();
826    if skill.ok {
827        passed += 1;
828    }
829    print_check(&skill);
830
831    // 10) Port
832    let port = port_3333_outcome();
833    if port.ok {
834        passed += 1;
835    }
836    print_check(&port);
837
838    // Daemon status
839    #[cfg(unix)]
840    let daemon_outcome = if crate::daemon::is_daemon_running() {
841        let pid_path = crate::daemon::daemon_pid_path();
842        let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
843        Outcome {
844            ok: true,
845            line: format!(
846                "{BOLD}Daemon{RST}  {GREEN}running (PID {}){RST}",
847                pid_str.trim()
848            ),
849        }
850    } else {
851        Outcome {
852            ok: true,
853            line: format!(
854                "{BOLD}Daemon{RST}  {YELLOW}not running{RST}  {DIM}(run: lean-ctx serve -d){RST}"
855            ),
856        }
857    };
858    #[cfg(not(unix))]
859    let daemon_outcome = Outcome {
860        ok: true,
861        line: format!("{BOLD}Daemon{RST}  {DIM}not supported on this platform{RST}"),
862    };
863    if daemon_outcome.ok {
864        passed += 1;
865    }
866    print_check(&daemon_outcome);
867
868    // 9) Session state (project_root + shell_cwd)
869    let session_outcome = session_state_outcome();
870    if session_outcome.ok {
871        passed += 1;
872    }
873    print_check(&session_outcome);
874
875    // 10) Docker env vars (optional, only in containers)
876    let docker_outcomes = docker_env_outcomes();
877    for docker_check in &docker_outcomes {
878        if docker_check.ok {
879            passed += 1;
880        }
881        print_check(docker_check);
882    }
883
884    // 11) Pi Coding Agent (optional)
885    let pi = pi_outcome();
886    if let Some(ref pi_check) = pi {
887        if pi_check.ok {
888            passed += 1;
889        }
890        print_check(pi_check);
891    }
892
893    // 12) Build integrity (canary / origin check)
894    let integrity = crate::core::integrity::check();
895    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
896    if integrity_ok {
897        passed += 1;
898    }
899    let integrity_line = if integrity_ok {
900        format!(
901            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
902            integrity.repo
903        )
904    } else {
905        format!(
906            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
907            integrity.pkg_name, integrity.repo
908        )
909    };
910    print_check(&Outcome {
911        ok: integrity_ok,
912        line: integrity_line,
913    });
914
915    // 13) Cache safety
916    let cache_safety = cache_safety_outcome();
917    if cache_safety.ok {
918        passed += 1;
919    }
920    print_check(&cache_safety);
921
922    // 14) Claude Code instruction truncation guard
923    let claude_truncation = claude_truncation_outcome();
924    if let Some(ref ct) = claude_truncation {
925        if ct.ok {
926            passed += 1;
927        }
928        print_check(ct);
929    }
930
931    let mut effective_total = total + 3; // session_state + integrity + cache_safety always shown
932    effective_total += docker_outcomes.len() as u32;
933    if pi.is_some() {
934        effective_total += 1;
935    }
936    if claude_truncation.is_some() {
937        effective_total += 1;
938    }
939    println!();
940    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
941    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
942}
943
944fn skill_files_outcome() -> Outcome {
945    let Some(home) = dirs::home_dir() else {
946        return Outcome {
947            ok: false,
948            line: format!("{BOLD}SKILL.md{RST}  {RED}could not resolve home directory{RST}"),
949        };
950    };
951
952    let candidates = [
953        ("Claude Code", home.join(".claude/skills/lean-ctx/SKILL.md")),
954        ("Cursor", home.join(".cursor/skills/lean-ctx/SKILL.md")),
955        ("Codex CLI", home.join(".codex/skills/lean-ctx/SKILL.md")),
956        (
957            "GitHub Copilot",
958            home.join(".vscode/skills/lean-ctx/SKILL.md"),
959        ),
960    ];
961
962    let mut found: Vec<&str> = Vec::new();
963    for (name, path) in &candidates {
964        if path.exists() {
965            found.push(name);
966        }
967    }
968
969    if found.is_empty() {
970        Outcome {
971            ok: false,
972            line: format!(
973                "{BOLD}SKILL.md{RST}  {YELLOW}not installed{RST}  {DIM}(run: lean-ctx setup){RST}"
974            ),
975        }
976    } else {
977        Outcome {
978            ok: true,
979            line: format!(
980                "{BOLD}SKILL.md{RST}  {GREEN}installed for {}{RST}",
981                found.join(", ")
982            ),
983        }
984    }
985}
986
987fn cache_safety_outcome() -> Outcome {
988    use crate::core::neural::cache_alignment::CacheAlignedOutput;
989    use crate::core::provider_cache::ProviderCacheState;
990
991    let mut issues = Vec::new();
992
993    let mut aligned = CacheAlignedOutput::new();
994    aligned.add_stable_block("test", "stable content".into(), 1);
995    aligned.add_variable_block("test_var", "variable content".into(), 1);
996    let rendered = aligned.render();
997    if rendered.find("stable content").unwrap_or(usize::MAX)
998        > rendered.find("variable content").unwrap_or(0)
999    {
1000        issues.push("cache_alignment: stable blocks not ordered first");
1001    }
1002
1003    let mut state = ProviderCacheState::new();
1004    let section = crate::core::provider_cache::CacheableSection::new(
1005        "doctor_test",
1006        "test content".into(),
1007        crate::core::provider_cache::SectionPriority::System,
1008        true,
1009    );
1010    state.mark_sent(&section);
1011    if state.needs_update(&section) {
1012        issues.push("provider_cache: hash tracking broken");
1013    }
1014
1015    if issues.is_empty() {
1016        Outcome {
1017            ok: true,
1018            line: format!(
1019                "{BOLD}Cache safety{RST}  {GREEN}cache_alignment + provider_cache operational{RST}"
1020            ),
1021        }
1022    } else {
1023        Outcome {
1024            ok: false,
1025            line: format!("{BOLD}Cache safety{RST}  {RED}{}{RST}", issues.join("; ")),
1026        }
1027    }
1028}
1029
1030fn claude_binary_exists() -> bool {
1031    #[cfg(unix)]
1032    {
1033        std::process::Command::new("which")
1034            .arg("claude")
1035            .output()
1036            .is_ok_and(|o| o.status.success())
1037    }
1038    #[cfg(windows)]
1039    {
1040        std::process::Command::new("where")
1041            .arg("claude")
1042            .output()
1043            .is_ok_and(|o| o.status.success())
1044    }
1045}
1046
1047fn claude_truncation_outcome() -> Option<Outcome> {
1048    let home = dirs::home_dir()?;
1049    let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1050        || crate::core::editor_registry::claude_state_dir(&home).exists()
1051        || claude_binary_exists();
1052
1053    if !claude_detected {
1054        return None;
1055    }
1056
1057    let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1058    let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1059
1060    let has_rules = rules_path.exists();
1061    let has_skill = skill_path.exists();
1062
1063    if has_rules && has_skill {
1064        Some(Outcome {
1065            ok: true,
1066            line: format!(
1067                "{BOLD}Claude Code instructions{RST}  {GREEN}rules + skill installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1068            ),
1069        })
1070    } else if has_rules {
1071        Some(Outcome {
1072            ok: true,
1073            line: format!(
1074                "{BOLD}Claude Code instructions{RST}  {GREEN}rules file installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1075            ),
1076        })
1077    } else {
1078        Some(Outcome {
1079            ok: false,
1080            line: format!(
1081                "{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}"
1082            ),
1083        })
1084    }
1085}
1086
1087pub fn run_compact() {
1088    let (passed, total) = compact_score();
1089    print_compact_status(passed, total);
1090}
1091
1092pub fn run_cli(args: &[String]) -> i32 {
1093    let (sub, rest) = match args.first().map(String::as_str) {
1094        Some("integrations") => ("integrations", &args[1..]),
1095        _ => ("", args),
1096    };
1097
1098    let fix = rest.iter().any(|a| a == "--fix");
1099    let json = rest.iter().any(|a| a == "--json");
1100    let help = rest.iter().any(|a| a == "--help" || a == "-h");
1101
1102    if help {
1103        println!("Usage:");
1104        println!("  lean-ctx doctor");
1105        println!("  lean-ctx doctor integrations [--json]");
1106        println!("  lean-ctx doctor --fix [--json]");
1107        return 0;
1108    }
1109
1110    if sub == "integrations" {
1111        if fix {
1112            let _ = run_fix(&DoctorFixOptions { json: false });
1113        }
1114        return run_integrations(&IntegrationsOptions { json });
1115    }
1116
1117    if !fix {
1118        run();
1119        return 0;
1120    }
1121
1122    match run_fix(&DoctorFixOptions { json }) {
1123        Ok(code) => code,
1124        Err(e) => {
1125            tracing::error!("doctor --fix failed: {e}");
1126            2
1127        }
1128    }
1129}
1130
1131#[derive(Debug, Clone, Copy)]
1132struct IntegrationsOptions {
1133    json: bool,
1134}
1135
1136#[derive(Debug, Serialize)]
1137#[serde(rename_all = "camelCase")]
1138struct IntegrationCheckReport {
1139    schema_version: u32,
1140    created_at: String,
1141    binary: String,
1142    integrations: Vec<IntegrationStatus>,
1143    ok: bool,
1144    repair_command: String,
1145}
1146
1147#[derive(Debug, Serialize)]
1148#[serde(rename_all = "camelCase")]
1149struct IntegrationStatus {
1150    name: String,
1151    detected: bool,
1152    checks: Vec<NamedCheck>,
1153    ok: bool,
1154}
1155
1156#[derive(Debug, Serialize)]
1157#[serde(rename_all = "camelCase")]
1158struct NamedCheck {
1159    name: String,
1160    ok: bool,
1161    detail: String,
1162}
1163
1164fn run_integrations(opts: &IntegrationsOptions) -> i32 {
1165    let Some(home) = dirs::home_dir() else {
1166        eprintln!("Cannot determine home directory");
1167        return 2;
1168    };
1169    let binary = crate::core::portable_binary::resolve_portable_binary();
1170    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1171        .map(|d| d.to_string_lossy().to_string())
1172        .unwrap_or_default();
1173
1174    let mut integrations = vec![
1175        integration_cursor(&home, &binary, &data_dir),
1176        integration_claude(&home, &binary, &data_dir),
1177    ];
1178    for t in crate::core::editor_registry::build_targets(&home) {
1179        if matches!(t.name, "Cursor" | "Claude Code") {
1180            continue;
1181        }
1182        integrations.push(integration_generic(&home, &binary, &data_dir, &t));
1183    }
1184    let ok = integrations.iter().all(|i| !i.detected || i.ok);
1185
1186    let report = IntegrationCheckReport {
1187        schema_version: 1,
1188        created_at: Utc::now().to_rfc3339(),
1189        binary: binary.clone(),
1190        integrations,
1191        ok,
1192        repair_command: "lean-ctx setup --fix".to_string(),
1193    };
1194
1195    if opts.json {
1196        println!(
1197            "{}",
1198            serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
1199        );
1200    } else {
1201        println!();
1202        println!("  {BOLD}{WHITE}Integration health:{RST}");
1203        for i in &report.integrations {
1204            if !i.detected {
1205                continue;
1206            }
1207            let mark = if i.ok {
1208                format!("{GREEN}✓{RST}")
1209            } else {
1210                format!("{YELLOW}✗{RST}")
1211            };
1212            println!("  {mark}  {BOLD}{}{RST}", i.name);
1213            for c in &i.checks {
1214                let m = if c.ok {
1215                    format!("{GREEN}✓{RST}")
1216                } else {
1217                    format!("{YELLOW}✗{RST}")
1218                };
1219                println!("       {m}  {}  {DIM}{}{RST}", c.name, c.detail);
1220            }
1221        }
1222        if !report.ok {
1223            println!();
1224            println!(
1225                "  {YELLOW}Repair:{RST} run {BOLD}{}{RST}",
1226                report.repair_command
1227            );
1228        }
1229    }
1230
1231    i32::from(!report.ok)
1232}
1233
1234fn integration_generic(
1235    home: &std::path::Path,
1236    binary: &str,
1237    data_dir: &str,
1238    target: &crate::core::editor_registry::types::EditorTarget,
1239) -> IntegrationStatus {
1240    let detected = target.detect_path.exists() || target.config_path.exists();
1241    if !detected {
1242        return IntegrationStatus {
1243            name: target.name.to_string(),
1244            detected: false,
1245            checks: Vec::new(),
1246            ok: true,
1247        };
1248    }
1249
1250    let mut checks = Vec::new();
1251    match target.config_type {
1252        crate::core::editor_registry::types::ConfigType::McpJson
1253        | crate::core::editor_registry::types::ConfigType::JetBrains
1254        | crate::core::editor_registry::types::ConfigType::QoderSettings => {
1255            checks.push(check_mcp_json(&target.config_path, binary, data_dir));
1256        }
1257        crate::core::editor_registry::types::ConfigType::Zed => {
1258            checks.push(check_zed_settings(&target.config_path, binary));
1259        }
1260        crate::core::editor_registry::types::ConfigType::Codex => {
1261            checks.push(check_codex_toml(&target.config_path, binary));
1262            checks.push(check_codex_hooks_enabled(home));
1263            checks.push(check_codex_hooks_json(home));
1264        }
1265        crate::core::editor_registry::types::ConfigType::VsCodeMcp => {
1266            checks.push(check_vscode_mcp(&target.config_path, binary, data_dir));
1267        }
1268        crate::core::editor_registry::types::ConfigType::OpenCode => {
1269            checks.push(check_opencode_config(&target.config_path, binary, data_dir));
1270        }
1271        crate::core::editor_registry::types::ConfigType::Crush => {
1272            checks.push(check_crush_config(&target.config_path, binary, data_dir));
1273        }
1274        crate::core::editor_registry::types::ConfigType::Amp => {
1275            checks.push(check_amp_config(&target.config_path, binary, data_dir));
1276        }
1277        crate::core::editor_registry::types::ConfigType::HermesYaml => {
1278            checks.push(check_hermes_yaml(&target.config_path, binary, data_dir));
1279        }
1280        crate::core::editor_registry::types::ConfigType::GeminiSettings => {
1281            checks.push(check_mcp_json(&target.config_path, binary, data_dir));
1282            checks.push(check_gemini_trust_and_hooks(home, binary));
1283        }
1284    }
1285
1286    // Optional rules files we manage per integration.
1287    if let Some(rules_path) = rules_path_for(target.name, home) {
1288        checks.push(check_rules_file(&rules_path));
1289    }
1290
1291    let ok = checks.iter().all(|c| c.ok);
1292    IntegrationStatus {
1293        name: target.name.to_string(),
1294        detected: true,
1295        checks,
1296        ok,
1297    }
1298}
1299
1300fn integration_cursor(home: &std::path::Path, binary: &str, data_dir: &str) -> IntegrationStatus {
1301    let cursor_dir = home.join(".cursor");
1302    if !cursor_dir.exists() {
1303        return IntegrationStatus {
1304            name: "Cursor".to_string(),
1305            detected: false,
1306            checks: Vec::new(),
1307            ok: true,
1308        };
1309    }
1310
1311    let mut checks = Vec::new();
1312    let mcp_path = cursor_dir.join("mcp.json");
1313    checks.push(check_mcp_json(&mcp_path, binary, data_dir));
1314
1315    let hooks_path = cursor_dir.join("hooks.json");
1316    checks.push(check_cursor_hooks(&hooks_path));
1317
1318    let ok = checks.iter().all(|c| c.ok);
1319    IntegrationStatus {
1320        name: "Cursor".to_string(),
1321        detected: true,
1322        checks,
1323        ok,
1324    }
1325}
1326
1327fn integration_claude(home: &std::path::Path, binary: &str, data_dir: &str) -> IntegrationStatus {
1328    let target = crate::core::editor_registry::build_targets(home)
1329        .into_iter()
1330        .find(|t| t.agent_key == "claude");
1331    let detected = target.as_ref().is_some_and(|t| t.detect_path.exists())
1332        || crate::core::editor_registry::claude_state_dir(home).exists()
1333        || claude_binary_exists();
1334
1335    if !detected {
1336        return IntegrationStatus {
1337            name: "Claude Code".to_string(),
1338            detected: false,
1339            checks: Vec::new(),
1340            ok: true,
1341        };
1342    }
1343
1344    let mut checks = Vec::new();
1345    let mcp_path = crate::core::editor_registry::claude_mcp_json_path(home);
1346    checks.push(check_mcp_json(&mcp_path, binary, data_dir));
1347
1348    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
1349    checks.push(check_claude_hooks(&settings_path));
1350
1351    let rules_path = crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md");
1352    let has_rules = rules_path.exists();
1353    checks.push(NamedCheck {
1354        name: "Rules file".to_string(),
1355        ok: has_rules,
1356        detail: if has_rules {
1357            rules_path.display().to_string()
1358        } else {
1359            format!("missing ({})", rules_path.display())
1360        },
1361    });
1362
1363    let ok = checks.iter().all(|c| c.ok);
1364    IntegrationStatus {
1365        name: "Claude Code".to_string(),
1366        detected: true,
1367        checks,
1368        ok,
1369    }
1370}
1371
1372fn check_mcp_json(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1373    if !path.exists() {
1374        return NamedCheck {
1375            name: "MCP config".to_string(),
1376            ok: false,
1377            detail: format!("missing ({})", path.display()),
1378        };
1379    }
1380    let content = std::fs::read_to_string(path).unwrap_or_default();
1381    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1382
1383    let Some(v) = parsed else {
1384        return NamedCheck {
1385            name: "MCP config".to_string(),
1386            ok: false,
1387            detail: format!("invalid JSON ({})", path.display()),
1388        };
1389    };
1390
1391    let entry = v
1392        .get("mcpServers")
1393        .and_then(|m| m.get("lean-ctx"))
1394        .cloned()
1395        .or_else(|| {
1396            v.get("mcp")
1397                .and_then(|m| m.get("servers"))
1398                .and_then(|m| m.get("lean-ctx"))
1399                .cloned()
1400        });
1401
1402    let Some(e) = entry else {
1403        return NamedCheck {
1404            name: "MCP config".to_string(),
1405            ok: false,
1406            detail: format!("lean-ctx missing ({})", path.display()),
1407        };
1408    };
1409
1410    let cmd_ok = e
1411        .get("command")
1412        .and_then(|c| c.as_str())
1413        .is_some_and(|c| cmd_matches_expected(c, binary));
1414    let env_ok = e
1415        .get("env")
1416        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1417        .and_then(|d| d.as_str())
1418        .is_some_and(|d| d.trim() == data_dir.trim());
1419
1420    let ok = cmd_ok && env_ok;
1421    let detail = if ok {
1422        format!("ok ({})", path.display())
1423    } else {
1424        format!("drift ({})", path.display())
1425    };
1426    NamedCheck {
1427        name: "MCP config".to_string(),
1428        ok,
1429        detail,
1430    }
1431}
1432
1433fn cmd_matches_expected(cmd: &str, portable: &str) -> bool {
1434    let cmd = cmd.trim();
1435    if cmd == portable.trim() {
1436        return true;
1437    }
1438    if cmd == "lean-ctx" {
1439        return true;
1440    }
1441    if let Some(resolved) = resolve_lean_ctx_binary() {
1442        if cmd == resolved.to_string_lossy().trim() {
1443            return true;
1444        }
1445    }
1446    false
1447}
1448
1449fn check_rules_file(path: &std::path::Path) -> NamedCheck {
1450    let ok = path.exists();
1451    NamedCheck {
1452        name: "Rules file".to_string(),
1453        ok,
1454        detail: if ok {
1455            path.display().to_string()
1456        } else {
1457            format!("missing ({})", path.display())
1458        },
1459    }
1460}
1461
1462fn rules_path_for(name: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
1463    match name {
1464        "Windsurf" => Some(home.join(".codeium/windsurf/rules/lean-ctx.md")),
1465        "Cline" => Some(home.join(".cline/rules/lean-ctx.md")),
1466        "Roo Code" => Some(home.join(".roo/rules/lean-ctx.md")),
1467        "OpenCode" => Some(home.join(".config/opencode/rules/lean-ctx.md")),
1468        "AWS Kiro" => Some(home.join(".kiro/steering/lean-ctx.md")),
1469        "Verdent" => Some(home.join(".verdent/rules/lean-ctx.md")),
1470        "Trae" => Some(home.join(".trae/rules/lean-ctx.md")),
1471        "Qwen Code" => Some(home.join(".qwen/rules/lean-ctx.md")),
1472        "Amazon Q Developer" => Some(home.join(".aws/amazonq/rules/lean-ctx.md")),
1473        "JetBrains IDEs" => Some(home.join(".jb-rules/lean-ctx.md")),
1474        "Antigravity" => Some(home.join(".gemini/antigravity/rules/lean-ctx.md")),
1475        "Pi Coding Agent" => Some(home.join(".pi/rules/lean-ctx.md")),
1476        "Crush" => Some(home.join(".config/crush/rules/lean-ctx.md")),
1477        _ => None,
1478    }
1479}
1480
1481fn check_zed_settings(path: &std::path::Path, binary: &str) -> NamedCheck {
1482    if !path.exists() {
1483        return NamedCheck {
1484            name: "Zed config".to_string(),
1485            ok: false,
1486            detail: format!("missing ({})", path.display()),
1487        };
1488    }
1489    let content = std::fs::read_to_string(path).unwrap_or_default();
1490    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1491    let Some(v) = parsed else {
1492        return NamedCheck {
1493            name: "Zed config".to_string(),
1494            ok: false,
1495            detail: format!("invalid JSON ({})", path.display()),
1496        };
1497    };
1498    let entry = v
1499        .get("context_servers")
1500        .and_then(|m| m.get("lean-ctx"))
1501        .cloned();
1502    let Some(e) = entry else {
1503        return NamedCheck {
1504            name: "Zed config".to_string(),
1505            ok: false,
1506            detail: format!("lean-ctx missing ({})", path.display()),
1507        };
1508    };
1509
1510    let cmd_ok = e
1511        .get("command")
1512        .and_then(|c| c.as_str())
1513        .is_some_and(|c| cmd_matches_expected(c, binary));
1514
1515    NamedCheck {
1516        name: "Zed config".to_string(),
1517        ok: cmd_ok,
1518        detail: if cmd_ok {
1519            format!("ok ({})", path.display())
1520        } else {
1521            format!("drift ({})", path.display())
1522        },
1523    }
1524}
1525
1526fn check_vscode_mcp(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1527    if !path.exists() {
1528        return NamedCheck {
1529            name: "VS Code MCP".to_string(),
1530            ok: false,
1531            detail: format!("missing ({})", path.display()),
1532        };
1533    }
1534    let content = std::fs::read_to_string(path).unwrap_or_default();
1535    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1536    let Some(v) = parsed else {
1537        return NamedCheck {
1538            name: "VS Code MCP".to_string(),
1539            ok: false,
1540            detail: format!("invalid JSON ({})", path.display()),
1541        };
1542    };
1543    let Some(e) = v.get("servers").and_then(|m| m.get("lean-ctx")) else {
1544        return NamedCheck {
1545            name: "VS Code MCP".to_string(),
1546            ok: false,
1547            detail: format!("lean-ctx missing ({})", path.display()),
1548        };
1549    };
1550
1551    let ty_ok = e.get("type").and_then(|t| t.as_str()) == Some("stdio");
1552    let cmd_ok = e
1553        .get("command")
1554        .and_then(|c| c.as_str())
1555        .is_some_and(|c| cmd_matches_expected(c, binary));
1556    let env_ok = e
1557        .get("env")
1558        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1559        .and_then(|d| d.as_str())
1560        .is_some_and(|d| d.trim() == data_dir.trim());
1561
1562    let ok = ty_ok && cmd_ok && env_ok;
1563    NamedCheck {
1564        name: "VS Code MCP".to_string(),
1565        ok,
1566        detail: if ok {
1567            format!("ok ({})", path.display())
1568        } else {
1569            format!("drift ({})", path.display())
1570        },
1571    }
1572}
1573
1574fn check_opencode_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1575    if !path.exists() {
1576        return NamedCheck {
1577            name: "OpenCode MCP".to_string(),
1578            ok: false,
1579            detail: format!("missing ({})", path.display()),
1580        };
1581    }
1582    let content = std::fs::read_to_string(path).unwrap_or_default();
1583    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1584    let Some(v) = parsed else {
1585        return NamedCheck {
1586            name: "OpenCode MCP".to_string(),
1587            ok: false,
1588            detail: format!("invalid JSON ({})", path.display()),
1589        };
1590    };
1591    let Some(e) = v.get("mcp").and_then(|m| m.get("lean-ctx")) else {
1592        return NamedCheck {
1593            name: "OpenCode MCP".to_string(),
1594            ok: false,
1595            detail: format!("lean-ctx missing ({})", path.display()),
1596        };
1597    };
1598
1599    let cmd = e
1600        .get("command")
1601        .and_then(|c| c.as_array())
1602        .and_then(|a| a.first())
1603        .and_then(|x| x.as_str());
1604    let cmd_ok = cmd.is_some_and(|c| cmd_matches_expected(c, binary));
1605    let env_ok = e
1606        .get("environment")
1607        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1608        .and_then(|d| d.as_str())
1609        .is_some_and(|d| d.trim() == data_dir.trim());
1610    let ok = cmd_ok && env_ok;
1611    NamedCheck {
1612        name: "OpenCode MCP".to_string(),
1613        ok,
1614        detail: if ok {
1615            format!("ok ({})", path.display())
1616        } else {
1617            format!("drift ({})", path.display())
1618        },
1619    }
1620}
1621
1622fn check_crush_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1623    if !path.exists() {
1624        return NamedCheck {
1625            name: "Crush MCP".to_string(),
1626            ok: false,
1627            detail: format!("missing ({})", path.display()),
1628        };
1629    }
1630    let content = std::fs::read_to_string(path).unwrap_or_default();
1631    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1632    let Some(v) = parsed else {
1633        return NamedCheck {
1634            name: "Crush MCP".to_string(),
1635            ok: false,
1636            detail: format!("invalid JSON ({})", path.display()),
1637        };
1638    };
1639    let Some(e) = v.get("mcp").and_then(|m| m.get("lean-ctx")) else {
1640        return NamedCheck {
1641            name: "Crush MCP".to_string(),
1642            ok: false,
1643            detail: format!("lean-ctx missing ({})", path.display()),
1644        };
1645    };
1646
1647    let cmd_ok = e
1648        .get("command")
1649        .and_then(|c| c.as_str())
1650        .is_some_and(|c| cmd_matches_expected(c, binary));
1651    let env_ok = e
1652        .get("env")
1653        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1654        .and_then(|d| d.as_str())
1655        .is_some_and(|d| d.trim() == data_dir.trim());
1656    let ok = cmd_ok && env_ok;
1657    NamedCheck {
1658        name: "Crush MCP".to_string(),
1659        ok,
1660        detail: if ok {
1661            format!("ok ({})", path.display())
1662        } else {
1663            format!("drift ({})", path.display())
1664        },
1665    }
1666}
1667
1668fn check_amp_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1669    if !path.exists() {
1670        return NamedCheck {
1671            name: "Amp MCP".to_string(),
1672            ok: false,
1673            detail: format!("missing ({})", path.display()),
1674        };
1675    }
1676    let content = std::fs::read_to_string(path).unwrap_or_default();
1677    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1678    let Some(v) = parsed else {
1679        return NamedCheck {
1680            name: "Amp MCP".to_string(),
1681            ok: false,
1682            detail: format!("invalid JSON ({})", path.display()),
1683        };
1684    };
1685    let Some(e) = v.get("amp.mcpServers").and_then(|m| m.get("lean-ctx")) else {
1686        return NamedCheck {
1687            name: "Amp MCP".to_string(),
1688            ok: false,
1689            detail: format!("lean-ctx missing ({})", path.display()),
1690        };
1691    };
1692
1693    let cmd_ok = e
1694        .get("command")
1695        .and_then(|c| c.as_str())
1696        .is_some_and(|c| cmd_matches_expected(c, binary));
1697    let env_ok = e
1698        .get("env")
1699        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1700        .and_then(|d| d.as_str())
1701        .is_some_and(|d| d.trim() == data_dir.trim());
1702    let ok = cmd_ok && env_ok;
1703    NamedCheck {
1704        name: "Amp MCP".to_string(),
1705        ok,
1706        detail: if ok {
1707            format!("ok ({})", path.display())
1708        } else {
1709            format!("drift ({})", path.display())
1710        },
1711    }
1712}
1713
1714fn check_codex_toml(path: &std::path::Path, binary: &str) -> NamedCheck {
1715    if !path.exists() {
1716        return NamedCheck {
1717            name: "Codex MCP".to_string(),
1718            ok: false,
1719            detail: format!("missing ({})", path.display()),
1720        };
1721    }
1722    let content = std::fs::read_to_string(path).unwrap_or_default();
1723    let parsed: Result<toml::Value, _> = toml::from_str(&content);
1724    let Ok(v) = parsed else {
1725        return NamedCheck {
1726            name: "Codex MCP".to_string(),
1727            ok: false,
1728            detail: format!("invalid TOML ({})", path.display()),
1729        };
1730    };
1731    let cmd = v
1732        .get("mcp_servers")
1733        .and_then(|t| t.get("lean-ctx"))
1734        .and_then(|t| t.get("command"))
1735        .and_then(|c| c.as_str());
1736    let ok = cmd.is_some_and(|c| cmd_matches_expected(c, binary));
1737    NamedCheck {
1738        name: "Codex MCP".to_string(),
1739        ok,
1740        detail: if ok {
1741            format!("ok ({})", path.display())
1742        } else {
1743            format!("drift ({})", path.display())
1744        },
1745    }
1746}
1747
1748fn check_codex_hooks_enabled(home: &std::path::Path) -> NamedCheck {
1749    let path = home.join(".codex").join("config.toml");
1750    if !path.exists() {
1751        return NamedCheck {
1752            name: "Codex hooks".to_string(),
1753            ok: false,
1754            detail: format!("missing ({})", path.display()),
1755        };
1756    }
1757    let content = std::fs::read_to_string(&path).unwrap_or_default();
1758    let parsed: Result<toml::Value, _> = toml::from_str(&content);
1759    let Ok(v) = parsed else {
1760        return NamedCheck {
1761            name: "Codex hooks".to_string(),
1762            ok: false,
1763            detail: format!("invalid TOML ({})", path.display()),
1764        };
1765    };
1766    let ok = v
1767        .get("features")
1768        .and_then(|t| t.get("codex_hooks"))
1769        .and_then(toml::Value::as_bool)
1770        == Some(true);
1771    NamedCheck {
1772        name: "Codex hooks".to_string(),
1773        ok,
1774        detail: if ok {
1775            format!("enabled ({})", path.display())
1776        } else {
1777            format!("disabled ({})", path.display())
1778        },
1779    }
1780}
1781
1782fn check_codex_hooks_json(home: &std::path::Path) -> NamedCheck {
1783    let path = home.join(".codex").join("hooks.json");
1784    if !path.exists() {
1785        return NamedCheck {
1786            name: "Codex hooks.json".to_string(),
1787            ok: false,
1788            detail: format!("missing ({})", path.display()),
1789        };
1790    }
1791    let content = std::fs::read_to_string(&path).unwrap_or_default();
1792    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1793    let Some(v) = parsed else {
1794        return NamedCheck {
1795            name: "Codex hooks.json".to_string(),
1796            ok: false,
1797            detail: format!("invalid JSON ({})", path.display()),
1798        };
1799    };
1800    let hooks = v.get("hooks");
1801    let mut saw_session_start = false;
1802    let mut saw_pretool = false;
1803    if let Some(h) = hooks {
1804        for event in ["SessionStart", "PreToolUse"] {
1805            if let Some(arr) = h.get(event).and_then(|x| x.as_array()) {
1806                for entry in arr {
1807                    let Some(hooks_arr) = entry.get("hooks").and_then(|x| x.as_array()) else {
1808                        continue;
1809                    };
1810                    for he in hooks_arr {
1811                        let Some(cmd) = he.get("command").and_then(|c| c.as_str()) else {
1812                            continue;
1813                        };
1814                        if cmd.contains("hook codex-session-start") {
1815                            saw_session_start = true;
1816                        }
1817                        if cmd.contains("hook codex-pretooluse") {
1818                            saw_pretool = true;
1819                        }
1820                    }
1821                }
1822            }
1823        }
1824    }
1825    let ok = saw_session_start && saw_pretool;
1826    NamedCheck {
1827        name: "Codex hooks.json".to_string(),
1828        ok,
1829        detail: if ok {
1830            format!("ok ({})", path.display())
1831        } else {
1832            format!("missing managed entries ({})", path.display())
1833        },
1834    }
1835}
1836
1837fn check_hermes_yaml(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1838    if !path.exists() {
1839        return NamedCheck {
1840            name: "Hermes MCP".to_string(),
1841            ok: false,
1842            detail: format!("missing ({})", path.display()),
1843        };
1844    }
1845    let content = std::fs::read_to_string(path).unwrap_or_default();
1846    let has_mcp = content.contains("mcp_servers:") && content.contains("lean-ctx:");
1847    let has_cmd =
1848        content.contains("command:") && (content.contains(binary) || content.contains("lean-ctx"));
1849    let has_env = content.contains("LEAN_CTX_DATA_DIR") && content.contains(data_dir);
1850    let ok = has_mcp && has_cmd && has_env;
1851    NamedCheck {
1852        name: "Hermes MCP".to_string(),
1853        ok,
1854        detail: if ok {
1855            format!("ok ({})", path.display())
1856        } else {
1857            format!("drift ({})", path.display())
1858        },
1859    }
1860}
1861
1862fn check_gemini_trust_and_hooks(home: &std::path::Path, binary: &str) -> NamedCheck {
1863    let settings = home.join(".gemini").join("settings.json");
1864    if !settings.exists() {
1865        return NamedCheck {
1866            name: "Gemini hooks".to_string(),
1867            ok: false,
1868            detail: format!("missing ({})", settings.display()),
1869        };
1870    }
1871    let content = std::fs::read_to_string(&settings).unwrap_or_default();
1872    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1873    let Some(v) = parsed else {
1874        return NamedCheck {
1875            name: "Gemini hooks".to_string(),
1876            ok: false,
1877            detail: format!("invalid JSON ({})", settings.display()),
1878        };
1879    };
1880
1881    let trust_ok = v
1882        .get("mcpServers")
1883        .and_then(|m| m.get("lean-ctx"))
1884        .and_then(|e| e.get("trust"))
1885        .and_then(serde_json::Value::as_bool)
1886        == Some(true);
1887
1888    let hooks_ok = v
1889        .get("hooks")
1890        .and_then(|h| h.get("BeforeTool"))
1891        .and_then(|x| x.as_array())
1892        .is_some_and(|arr| {
1893            let mut saw_rewrite = false;
1894            let mut saw_redirect = false;
1895            for entry in arr {
1896                let hooks = entry
1897                    .get("hooks")
1898                    .and_then(|x| x.as_array())
1899                    .cloned()
1900                    .unwrap_or_default();
1901                for h in hooks {
1902                    let cmd = h
1903                        .get("command")
1904                        .and_then(|c| c.as_str())
1905                        .unwrap_or_default();
1906                    let first = cmd.split_whitespace().next().unwrap_or_default();
1907                    if cmd.contains("hook rewrite") && cmd_matches_expected(first, binary) {
1908                        saw_rewrite = true;
1909                    }
1910                    if cmd.contains("hook redirect") && cmd_matches_expected(first, binary) {
1911                        saw_redirect = true;
1912                    }
1913                }
1914            }
1915            saw_rewrite && saw_redirect
1916        });
1917
1918    let scripts_ok = home
1919        .join(".gemini")
1920        .join("hooks")
1921        .join("lean-ctx-rewrite-gemini.sh")
1922        .exists()
1923        && home
1924            .join(".gemini")
1925            .join("hooks")
1926            .join("lean-ctx-redirect-gemini.sh")
1927            .exists();
1928
1929    let ok = trust_ok && hooks_ok && scripts_ok;
1930    NamedCheck {
1931        name: "Gemini hooks".to_string(),
1932        ok,
1933        detail: if ok {
1934            format!("ok ({})", settings.display())
1935        } else {
1936            "drift (hooks/trust/scripts)".to_string()
1937        },
1938    }
1939}
1940
1941fn check_cursor_hooks(path: &std::path::Path) -> NamedCheck {
1942    if !path.exists() {
1943        return NamedCheck {
1944            name: "Hooks".to_string(),
1945            ok: false,
1946            detail: format!("missing ({})", path.display()),
1947        };
1948    }
1949    let content = std::fs::read_to_string(path).unwrap_or_default();
1950    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1951    let Some(v) = parsed else {
1952        return NamedCheck {
1953            name: "Hooks".to_string(),
1954            ok: false,
1955            detail: format!("invalid JSON ({})", path.display()),
1956        };
1957    };
1958    let pre = v
1959        .get("hooks")
1960        .and_then(|h| h.get("preToolUse"))
1961        .and_then(|x| x.as_array())
1962        .cloned()
1963        .unwrap_or_default();
1964    let has_rewrite = pre.iter().any(|e| {
1965        e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
1966            && e.get("command")
1967                .and_then(|c| c.as_str())
1968                .is_some_and(|c| c.contains(" hook rewrite"))
1969    });
1970    let has_redirect = pre.iter().any(|e| {
1971        matches!(
1972            e.get("matcher").and_then(|m| m.as_str()),
1973            Some("Read|Grep" | "Read" | "Grep")
1974        ) && e
1975            .get("command")
1976            .and_then(|c| c.as_str())
1977            .is_some_and(|c| c.contains(" hook redirect"))
1978    });
1979    NamedCheck {
1980        name: "Hooks".to_string(),
1981        ok: has_rewrite && has_redirect,
1982        detail: if has_rewrite && has_redirect {
1983            format!("ok ({})", path.display())
1984        } else {
1985            format!("drift ({})", path.display())
1986        },
1987    }
1988}
1989
1990fn check_claude_hooks(path: &std::path::Path) -> NamedCheck {
1991    if !path.exists() {
1992        return NamedCheck {
1993            name: "Hooks".to_string(),
1994            ok: false,
1995            detail: format!("missing ({})", path.display()),
1996        };
1997    }
1998    let content = std::fs::read_to_string(path).unwrap_or_default();
1999    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
2000    let Some(v) = parsed else {
2001        return NamedCheck {
2002            name: "Hooks".to_string(),
2003            ok: false,
2004            detail: format!("invalid JSON ({})", path.display()),
2005        };
2006    };
2007    let pre = v
2008        .get("hooks")
2009        .and_then(|h| h.get("PreToolUse"))
2010        .and_then(|x| x.as_array())
2011        .cloned()
2012        .unwrap_or_default();
2013    let joined = serde_json::to_string(&pre).unwrap_or_default();
2014    let ok = joined.contains(" hook rewrite") && joined.contains(" hook redirect");
2015    NamedCheck {
2016        name: "Hooks".to_string(),
2017        ok,
2018        detail: if ok {
2019            format!("ok ({})", path.display())
2020        } else {
2021            format!("drift ({})", path.display())
2022        },
2023    }
2024}
2025
2026struct DoctorFixOptions {
2027    json: bool,
2028}
2029
2030fn run_fix(opts: &DoctorFixOptions) -> Result<i32, String> {
2031    use crate::core::setup_report::{
2032        doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
2033    };
2034
2035    let _quiet_guard = opts
2036        .json
2037        .then(|| crate::setup::EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
2038    let started_at = Utc::now();
2039    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
2040
2041    let mut steps: Vec<SetupStepReport> = Vec::new();
2042
2043    // Step: shell hook repair
2044    let mut shell_step = SetupStepReport {
2045        name: "shell_hook".to_string(),
2046        ok: true,
2047        items: Vec::new(),
2048        warnings: Vec::new(),
2049        errors: Vec::new(),
2050    };
2051    let before = shell_aliases_outcome();
2052    if before.ok {
2053        shell_step.items.push(SetupItem {
2054            name: "init --global".to_string(),
2055            status: "already".to_string(),
2056            path: None,
2057            note: None,
2058        });
2059    } else {
2060        if opts.json {
2061            crate::cli::cmd_init_quiet(&["--global".to_string()]);
2062        } else {
2063            crate::cli::cmd_init(&["--global".to_string()]);
2064        }
2065        let after = shell_aliases_outcome();
2066        shell_step.ok = after.ok;
2067        shell_step.items.push(SetupItem {
2068            name: "init --global".to_string(),
2069            status: if after.ok {
2070                "fixed".to_string()
2071            } else {
2072                "failed".to_string()
2073            },
2074            path: None,
2075            note: if after.ok {
2076                None
2077            } else {
2078                Some("shell hook still not detected by doctor checks".to_string())
2079            },
2080        });
2081        if !after.ok {
2082            shell_step
2083                .warnings
2084                .push("shell hook not detected after init --global".to_string());
2085        }
2086    }
2087    steps.push(shell_step);
2088
2089    // Step: MCP config repair (detected tools)
2090    let mut mcp_step = SetupStepReport {
2091        name: "mcp_config".to_string(),
2092        ok: true,
2093        items: Vec::new(),
2094        warnings: Vec::new(),
2095        errors: Vec::new(),
2096    };
2097    let binary = crate::core::portable_binary::resolve_portable_binary();
2098    let targets = crate::core::editor_registry::build_targets(&home);
2099    for t in &targets {
2100        if !t.detect_path.exists() {
2101            continue;
2102        }
2103        let short = t.config_path.to_string_lossy().to_string();
2104
2105        let mode = if t.agent_key.is_empty() {
2106            crate::hooks::HookMode::Mcp
2107        } else {
2108            crate::hooks::recommend_hook_mode(&t.agent_key)
2109        };
2110
2111        let res = if mode == crate::hooks::HookMode::CliRedirect {
2112            crate::core::editor_registry::remove_lean_ctx_server(
2113                t,
2114                crate::core::editor_registry::WriteOptions {
2115                    overwrite_invalid: true,
2116                },
2117            )
2118        } else {
2119            crate::core::editor_registry::write_config_with_options(
2120                t,
2121                &binary,
2122                crate::core::editor_registry::WriteOptions {
2123                    overwrite_invalid: true,
2124                },
2125            )
2126        };
2127
2128        match res {
2129            Ok(r) => {
2130                let status = match r.action {
2131                    crate::core::editor_registry::WriteAction::Created => "created",
2132                    crate::core::editor_registry::WriteAction::Updated => "updated",
2133                    crate::core::editor_registry::WriteAction::Already => "already",
2134                };
2135                let note_parts: Vec<String> = [Some(format!("mode={mode}")), r.note]
2136                    .into_iter()
2137                    .flatten()
2138                    .collect();
2139                mcp_step.items.push(SetupItem {
2140                    name: t.name.to_string(),
2141                    status: status.to_string(),
2142                    path: Some(short),
2143                    note: Some(note_parts.join("; ")),
2144                });
2145            }
2146            Err(e) => {
2147                mcp_step.ok = false;
2148                mcp_step.items.push(SetupItem {
2149                    name: t.name.to_string(),
2150                    status: "error".to_string(),
2151                    path: Some(short),
2152                    note: Some(e.clone()),
2153                });
2154                mcp_step.errors.push(format!("{}: {e}", t.name));
2155            }
2156        }
2157    }
2158    if mcp_step.items.is_empty() {
2159        mcp_step
2160            .warnings
2161            .push("no supported AI tools detected; skipped MCP config repair".to_string());
2162    }
2163    steps.push(mcp_step);
2164
2165    // Step: agent rules injection
2166    let mut rules_step = SetupStepReport {
2167        name: "agent_rules".to_string(),
2168        ok: true,
2169        items: Vec::new(),
2170        warnings: Vec::new(),
2171        errors: Vec::new(),
2172    };
2173    let inj = crate::rules_inject::inject_all_rules(&home);
2174    if !inj.injected.is_empty() {
2175        rules_step.items.push(SetupItem {
2176            name: "injected".to_string(),
2177            status: inj.injected.len().to_string(),
2178            path: None,
2179            note: Some(inj.injected.join(", ")),
2180        });
2181    }
2182    if !inj.updated.is_empty() {
2183        rules_step.items.push(SetupItem {
2184            name: "updated".to_string(),
2185            status: inj.updated.len().to_string(),
2186            path: None,
2187            note: Some(inj.updated.join(", ")),
2188        });
2189    }
2190    if !inj.already.is_empty() {
2191        rules_step.items.push(SetupItem {
2192            name: "already".to_string(),
2193            status: inj.already.len().to_string(),
2194            path: None,
2195            note: Some(inj.already.join(", ")),
2196        });
2197    }
2198    if !inj.errors.is_empty() {
2199        rules_step.ok = false;
2200        rules_step.errors.extend(inj.errors.clone());
2201    }
2202    steps.push(rules_step);
2203
2204    // Step: agent hooks repair (all detected agents)
2205    let mut hooks_step = SetupStepReport {
2206        name: "agent_hooks".to_string(),
2207        ok: true,
2208        items: Vec::new(),
2209        warnings: Vec::new(),
2210        errors: Vec::new(),
2211    };
2212    let targets = crate::core::editor_registry::build_targets(&home);
2213    for t in &targets {
2214        if !t.detect_path.exists() || t.agent_key.trim().is_empty() {
2215            continue;
2216        }
2217        let mode = crate::hooks::recommend_hook_mode(&t.agent_key);
2218        crate::hooks::install_agent_hook_with_mode(&t.agent_key, true, mode);
2219        hooks_step.items.push(SetupItem {
2220            name: format!("{} hooks", t.name),
2221            status: "installed".to_string(),
2222            path: Some(t.detect_path.to_string_lossy().to_string()),
2223            note: Some(format!("mode={mode}; merge-based install/repair")),
2224        });
2225    }
2226    if !hooks_step.items.is_empty() {
2227        steps.push(hooks_step);
2228    }
2229
2230    // Step: SKILL.md repair
2231    let mut skill_step = SetupStepReport {
2232        name: "skill_files".to_string(),
2233        ok: true,
2234        items: Vec::new(),
2235        warnings: Vec::new(),
2236        errors: Vec::new(),
2237    };
2238    let skill_result = crate::setup::install_skill_files(&home);
2239    for (name, installed) in &skill_result {
2240        skill_step.items.push(SetupItem {
2241            name: name.clone(),
2242            status: if *installed {
2243                "installed".to_string()
2244            } else {
2245                "already".to_string()
2246            },
2247            path: None,
2248            note: Some("SKILL.md".to_string()),
2249        });
2250    }
2251    if !skill_result.is_empty() {
2252        steps.push(skill_step);
2253    }
2254
2255    // Step: verify (compact)
2256    let mut verify_step = SetupStepReport {
2257        name: "verify".to_string(),
2258        ok: true,
2259        items: Vec::new(),
2260        warnings: Vec::new(),
2261        errors: Vec::new(),
2262    };
2263    let (passed, total) = compact_score();
2264    verify_step.items.push(SetupItem {
2265        name: "doctor_compact".to_string(),
2266        status: format!("{passed}/{total}"),
2267        path: None,
2268        note: None,
2269    });
2270    if passed != total {
2271        verify_step.warnings.push(format!(
2272            "doctor compact not fully passing: {passed}/{total}"
2273        ));
2274    }
2275    steps.push(verify_step);
2276
2277    let finished_at = Utc::now();
2278    let success = steps.iter().all(|s| s.ok);
2279
2280    let report = SetupReport {
2281        schema_version: 1,
2282        started_at,
2283        finished_at,
2284        success,
2285        platform: PlatformInfo {
2286            os: std::env::consts::OS.to_string(),
2287            arch: std::env::consts::ARCH.to_string(),
2288        },
2289        steps,
2290        warnings: Vec::new(),
2291        errors: Vec::new(),
2292    };
2293
2294    let path = doctor_report_path()?;
2295    let json_text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
2296    crate::config_io::write_atomic_with_backup(&path, &json_text)?;
2297
2298    if opts.json {
2299        println!("{json_text}");
2300    } else {
2301        let (passed, total) = compact_score();
2302        print_compact_status(passed, total);
2303        println!("  {DIM}report saved:{RST} {}", path.display());
2304    }
2305
2306    Ok(i32::from(!report.success))
2307}
2308
2309pub fn compact_score() -> (u32, u32) {
2310    let mut passed = 0u32;
2311    let total = 6u32;
2312
2313    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
2314        passed += 1;
2315    }
2316    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
2317    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
2318        passed += 1;
2319    }
2320    if lean_dir
2321        .as_ref()
2322        .map(|d| d.join("stats.json"))
2323        .and_then(|p| std::fs::metadata(p).ok())
2324        .is_some_and(|m| m.is_file())
2325    {
2326        passed += 1;
2327    }
2328    if shell_aliases_outcome().ok {
2329        passed += 1;
2330    }
2331    if mcp_config_outcome().ok {
2332        passed += 1;
2333    }
2334    if skill_files_outcome().ok {
2335        passed += 1;
2336    }
2337
2338    (passed, total)
2339}
2340
2341fn print_compact_status(passed: u32, total: u32) {
2342    let status = if passed == total {
2343        format!("{GREEN}✓ All {total} checks passed{RST}")
2344    } else {
2345        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
2346    };
2347    println!("  {status}");
2348}