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
958    let mut found: Vec<&str> = Vec::new();
959    for (name, path) in &candidates {
960        if path.exists() {
961            found.push(name);
962        }
963    }
964
965    if found.is_empty() {
966        Outcome {
967            ok: false,
968            line: format!(
969                "{BOLD}SKILL.md{RST}  {YELLOW}not installed{RST}  {DIM}(run: lean-ctx setup){RST}"
970            ),
971        }
972    } else {
973        Outcome {
974            ok: true,
975            line: format!(
976                "{BOLD}SKILL.md{RST}  {GREEN}installed for {}{RST}",
977                found.join(", ")
978            ),
979        }
980    }
981}
982
983fn cache_safety_outcome() -> Outcome {
984    use crate::core::neural::cache_alignment::CacheAlignedOutput;
985    use crate::core::provider_cache::ProviderCacheState;
986
987    let mut issues = Vec::new();
988
989    let mut aligned = CacheAlignedOutput::new();
990    aligned.add_stable_block("test", "stable content".into(), 1);
991    aligned.add_variable_block("test_var", "variable content".into(), 1);
992    let rendered = aligned.render();
993    if rendered.find("stable content").unwrap_or(usize::MAX)
994        > rendered.find("variable content").unwrap_or(0)
995    {
996        issues.push("cache_alignment: stable blocks not ordered first");
997    }
998
999    let mut state = ProviderCacheState::new();
1000    let section = crate::core::provider_cache::CacheableSection::new(
1001        "doctor_test",
1002        "test content".into(),
1003        crate::core::provider_cache::SectionPriority::System,
1004        true,
1005    );
1006    state.mark_sent(&section);
1007    if state.needs_update(&section) {
1008        issues.push("provider_cache: hash tracking broken");
1009    }
1010
1011    if issues.is_empty() {
1012        Outcome {
1013            ok: true,
1014            line: format!(
1015                "{BOLD}Cache safety{RST}  {GREEN}cache_alignment + provider_cache operational{RST}"
1016            ),
1017        }
1018    } else {
1019        Outcome {
1020            ok: false,
1021            line: format!("{BOLD}Cache safety{RST}  {RED}{}{RST}", issues.join("; ")),
1022        }
1023    }
1024}
1025
1026fn claude_binary_exists() -> bool {
1027    #[cfg(unix)]
1028    {
1029        std::process::Command::new("which")
1030            .arg("claude")
1031            .output()
1032            .is_ok_and(|o| o.status.success())
1033    }
1034    #[cfg(windows)]
1035    {
1036        std::process::Command::new("where")
1037            .arg("claude")
1038            .output()
1039            .is_ok_and(|o| o.status.success())
1040    }
1041}
1042
1043fn claude_truncation_outcome() -> Option<Outcome> {
1044    let home = dirs::home_dir()?;
1045    let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1046        || crate::core::editor_registry::claude_state_dir(&home).exists()
1047        || claude_binary_exists();
1048
1049    if !claude_detected {
1050        return None;
1051    }
1052
1053    let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1054    let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1055
1056    let has_rules = rules_path.exists();
1057    let has_skill = skill_path.exists();
1058
1059    if has_rules && has_skill {
1060        Some(Outcome {
1061            ok: true,
1062            line: format!(
1063                "{BOLD}Claude Code instructions{RST}  {GREEN}rules + skill installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1064            ),
1065        })
1066    } else if has_rules {
1067        Some(Outcome {
1068            ok: true,
1069            line: format!(
1070                "{BOLD}Claude Code instructions{RST}  {GREEN}rules file installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1071            ),
1072        })
1073    } else {
1074        Some(Outcome {
1075            ok: false,
1076            line: format!(
1077                "{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}"
1078            ),
1079        })
1080    }
1081}
1082
1083pub fn run_compact() {
1084    let (passed, total) = compact_score();
1085    print_compact_status(passed, total);
1086}
1087
1088pub fn run_cli(args: &[String]) -> i32 {
1089    let (sub, rest) = match args.first().map(String::as_str) {
1090        Some("integrations") => ("integrations", &args[1..]),
1091        _ => ("", args),
1092    };
1093
1094    let fix = rest.iter().any(|a| a == "--fix");
1095    let json = rest.iter().any(|a| a == "--json");
1096    let help = rest.iter().any(|a| a == "--help" || a == "-h");
1097
1098    if help {
1099        println!("Usage:");
1100        println!("  lean-ctx doctor");
1101        println!("  lean-ctx doctor integrations [--json]");
1102        println!("  lean-ctx doctor --fix [--json]");
1103        return 0;
1104    }
1105
1106    if sub == "integrations" {
1107        if fix {
1108            let _ = run_fix(&DoctorFixOptions { json: false });
1109        }
1110        return run_integrations(&IntegrationsOptions { json });
1111    }
1112
1113    if !fix {
1114        run();
1115        return 0;
1116    }
1117
1118    match run_fix(&DoctorFixOptions { json }) {
1119        Ok(code) => code,
1120        Err(e) => {
1121            tracing::error!("doctor --fix failed: {e}");
1122            2
1123        }
1124    }
1125}
1126
1127#[derive(Debug, Clone, Copy)]
1128struct IntegrationsOptions {
1129    json: bool,
1130}
1131
1132#[derive(Debug, Serialize)]
1133#[serde(rename_all = "camelCase")]
1134struct IntegrationCheckReport {
1135    schema_version: u32,
1136    created_at: String,
1137    binary: String,
1138    integrations: Vec<IntegrationStatus>,
1139    ok: bool,
1140    repair_command: String,
1141}
1142
1143#[derive(Debug, Serialize)]
1144#[serde(rename_all = "camelCase")]
1145struct IntegrationStatus {
1146    name: String,
1147    detected: bool,
1148    checks: Vec<NamedCheck>,
1149    ok: bool,
1150}
1151
1152#[derive(Debug, Serialize)]
1153#[serde(rename_all = "camelCase")]
1154struct NamedCheck {
1155    name: String,
1156    ok: bool,
1157    detail: String,
1158}
1159
1160fn run_integrations(opts: &IntegrationsOptions) -> i32 {
1161    let Some(home) = dirs::home_dir() else {
1162        eprintln!("Cannot determine home directory");
1163        return 2;
1164    };
1165    let binary = crate::core::portable_binary::resolve_portable_binary();
1166    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1167        .map(|d| d.to_string_lossy().to_string())
1168        .unwrap_or_default();
1169
1170    let mut integrations = vec![
1171        integration_cursor(&home, &binary, &data_dir),
1172        integration_claude(&home, &binary, &data_dir),
1173    ];
1174    for t in crate::core::editor_registry::build_targets(&home) {
1175        if matches!(t.name, "Cursor" | "Claude Code") {
1176            continue;
1177        }
1178        integrations.push(integration_generic(&home, &binary, &data_dir, &t));
1179    }
1180    let ok = integrations.iter().all(|i| !i.detected || i.ok);
1181
1182    let report = IntegrationCheckReport {
1183        schema_version: 1,
1184        created_at: Utc::now().to_rfc3339(),
1185        binary: binary.clone(),
1186        integrations,
1187        ok,
1188        repair_command: "lean-ctx setup --fix".to_string(),
1189    };
1190
1191    if opts.json {
1192        println!(
1193            "{}",
1194            serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
1195        );
1196    } else {
1197        println!();
1198        println!("  {BOLD}{WHITE}Integration health:{RST}");
1199        for i in &report.integrations {
1200            if !i.detected {
1201                continue;
1202            }
1203            let mark = if i.ok {
1204                format!("{GREEN}✓{RST}")
1205            } else {
1206                format!("{YELLOW}✗{RST}")
1207            };
1208            println!("  {mark}  {BOLD}{}{RST}", i.name);
1209            for c in &i.checks {
1210                let m = if c.ok {
1211                    format!("{GREEN}✓{RST}")
1212                } else {
1213                    format!("{YELLOW}✗{RST}")
1214                };
1215                println!("       {m}  {}  {DIM}{}{RST}", c.name, c.detail);
1216            }
1217        }
1218        if !report.ok {
1219            println!();
1220            println!(
1221                "  {YELLOW}Repair:{RST} run {BOLD}{}{RST}",
1222                report.repair_command
1223            );
1224        }
1225    }
1226
1227    i32::from(!report.ok)
1228}
1229
1230fn integration_generic(
1231    home: &std::path::Path,
1232    binary: &str,
1233    data_dir: &str,
1234    target: &crate::core::editor_registry::types::EditorTarget,
1235) -> IntegrationStatus {
1236    let detected = target.detect_path.exists() || target.config_path.exists();
1237    if !detected {
1238        return IntegrationStatus {
1239            name: target.name.to_string(),
1240            detected: false,
1241            checks: Vec::new(),
1242            ok: true,
1243        };
1244    }
1245
1246    let mut checks = Vec::new();
1247    match target.config_type {
1248        crate::core::editor_registry::types::ConfigType::McpJson
1249        | crate::core::editor_registry::types::ConfigType::JetBrains
1250        | crate::core::editor_registry::types::ConfigType::QoderSettings => {
1251            checks.push(check_mcp_json(&target.config_path, binary, data_dir));
1252        }
1253        crate::core::editor_registry::types::ConfigType::Zed => {
1254            checks.push(check_zed_settings(&target.config_path, binary));
1255        }
1256        crate::core::editor_registry::types::ConfigType::Codex => {
1257            checks.push(check_codex_toml(&target.config_path, binary));
1258            checks.push(check_codex_hooks_enabled(home));
1259            checks.push(check_codex_hooks_json(home));
1260        }
1261        crate::core::editor_registry::types::ConfigType::VsCodeMcp => {
1262            checks.push(check_vscode_mcp(&target.config_path, binary, data_dir));
1263        }
1264        crate::core::editor_registry::types::ConfigType::OpenCode => {
1265            checks.push(check_opencode_config(&target.config_path, binary, data_dir));
1266        }
1267        crate::core::editor_registry::types::ConfigType::Crush => {
1268            checks.push(check_crush_config(&target.config_path, binary, data_dir));
1269        }
1270        crate::core::editor_registry::types::ConfigType::Amp => {
1271            checks.push(check_amp_config(&target.config_path, binary, data_dir));
1272        }
1273        crate::core::editor_registry::types::ConfigType::HermesYaml => {
1274            checks.push(check_hermes_yaml(&target.config_path, binary, data_dir));
1275        }
1276        crate::core::editor_registry::types::ConfigType::GeminiSettings => {
1277            checks.push(check_mcp_json(&target.config_path, binary, data_dir));
1278            checks.push(check_gemini_trust_and_hooks(home, binary));
1279        }
1280    }
1281
1282    // Optional rules files we manage per integration.
1283    if let Some(rules_path) = rules_path_for(target.name, home) {
1284        checks.push(check_rules_file(&rules_path));
1285    }
1286
1287    let ok = checks.iter().all(|c| c.ok);
1288    IntegrationStatus {
1289        name: target.name.to_string(),
1290        detected: true,
1291        checks,
1292        ok,
1293    }
1294}
1295
1296fn integration_cursor(home: &std::path::Path, binary: &str, data_dir: &str) -> IntegrationStatus {
1297    let cursor_dir = home.join(".cursor");
1298    if !cursor_dir.exists() {
1299        return IntegrationStatus {
1300            name: "Cursor".to_string(),
1301            detected: false,
1302            checks: Vec::new(),
1303            ok: true,
1304        };
1305    }
1306
1307    let mut checks = Vec::new();
1308    let mcp_path = cursor_dir.join("mcp.json");
1309    checks.push(check_mcp_json(&mcp_path, binary, data_dir));
1310
1311    let hooks_path = cursor_dir.join("hooks.json");
1312    checks.push(check_cursor_hooks(&hooks_path));
1313
1314    let ok = checks.iter().all(|c| c.ok);
1315    IntegrationStatus {
1316        name: "Cursor".to_string(),
1317        detected: true,
1318        checks,
1319        ok,
1320    }
1321}
1322
1323fn integration_claude(home: &std::path::Path, binary: &str, data_dir: &str) -> IntegrationStatus {
1324    let target = crate::core::editor_registry::build_targets(home)
1325        .into_iter()
1326        .find(|t| t.agent_key == "claude");
1327    let detected = target.as_ref().is_some_and(|t| t.detect_path.exists())
1328        || crate::core::editor_registry::claude_state_dir(home).exists()
1329        || claude_binary_exists();
1330
1331    if !detected {
1332        return IntegrationStatus {
1333            name: "Claude Code".to_string(),
1334            detected: false,
1335            checks: Vec::new(),
1336            ok: true,
1337        };
1338    }
1339
1340    let mut checks = Vec::new();
1341    let mcp_path = crate::core::editor_registry::claude_mcp_json_path(home);
1342    checks.push(check_mcp_json(&mcp_path, binary, data_dir));
1343
1344    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
1345    checks.push(check_claude_hooks(&settings_path));
1346
1347    let rules_path = crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md");
1348    let has_rules = rules_path.exists();
1349    checks.push(NamedCheck {
1350        name: "Rules file".to_string(),
1351        ok: has_rules,
1352        detail: if has_rules {
1353            rules_path.display().to_string()
1354        } else {
1355            format!("missing ({})", rules_path.display())
1356        },
1357    });
1358
1359    let ok = checks.iter().all(|c| c.ok);
1360    IntegrationStatus {
1361        name: "Claude Code".to_string(),
1362        detected: true,
1363        checks,
1364        ok,
1365    }
1366}
1367
1368fn check_mcp_json(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1369    if !path.exists() {
1370        return NamedCheck {
1371            name: "MCP config".to_string(),
1372            ok: false,
1373            detail: format!("missing ({})", path.display()),
1374        };
1375    }
1376    let content = std::fs::read_to_string(path).unwrap_or_default();
1377    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1378
1379    let Some(v) = parsed else {
1380        return NamedCheck {
1381            name: "MCP config".to_string(),
1382            ok: false,
1383            detail: format!("invalid JSON ({})", path.display()),
1384        };
1385    };
1386
1387    let entry = v
1388        .get("mcpServers")
1389        .and_then(|m| m.get("lean-ctx"))
1390        .cloned()
1391        .or_else(|| {
1392            v.get("mcp")
1393                .and_then(|m| m.get("servers"))
1394                .and_then(|m| m.get("lean-ctx"))
1395                .cloned()
1396        });
1397
1398    let Some(e) = entry else {
1399        return NamedCheck {
1400            name: "MCP config".to_string(),
1401            ok: false,
1402            detail: format!("lean-ctx missing ({})", path.display()),
1403        };
1404    };
1405
1406    let cmd_ok = e
1407        .get("command")
1408        .and_then(|c| c.as_str())
1409        .is_some_and(|c| cmd_matches_expected(c, binary));
1410    let env_ok = e
1411        .get("env")
1412        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1413        .and_then(|d| d.as_str())
1414        .is_some_and(|d| d.trim() == data_dir.trim());
1415
1416    let ok = cmd_ok && env_ok;
1417    let detail = if ok {
1418        format!("ok ({})", path.display())
1419    } else {
1420        format!("drift ({})", path.display())
1421    };
1422    NamedCheck {
1423        name: "MCP config".to_string(),
1424        ok,
1425        detail,
1426    }
1427}
1428
1429fn cmd_matches_expected(cmd: &str, portable: &str) -> bool {
1430    let cmd = cmd.trim();
1431    if cmd == portable.trim() {
1432        return true;
1433    }
1434    if cmd == "lean-ctx" {
1435        return true;
1436    }
1437    if let Some(resolved) = resolve_lean_ctx_binary() {
1438        if cmd == resolved.to_string_lossy().trim() {
1439            return true;
1440        }
1441    }
1442    false
1443}
1444
1445fn check_rules_file(path: &std::path::Path) -> NamedCheck {
1446    let ok = path.exists();
1447    NamedCheck {
1448        name: "Rules file".to_string(),
1449        ok,
1450        detail: if ok {
1451            path.display().to_string()
1452        } else {
1453            format!("missing ({})", path.display())
1454        },
1455    }
1456}
1457
1458fn rules_path_for(name: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
1459    match name {
1460        "Windsurf" => Some(home.join(".codeium/windsurf/rules/lean-ctx.md")),
1461        "Cline" => Some(home.join(".cline/rules/lean-ctx.md")),
1462        "Roo Code" => Some(home.join(".roo/rules/lean-ctx.md")),
1463        "OpenCode" => Some(home.join(".config/opencode/rules/lean-ctx.md")),
1464        "AWS Kiro" => Some(home.join(".kiro/steering/lean-ctx.md")),
1465        "Verdent" => Some(home.join(".verdent/rules/lean-ctx.md")),
1466        "Trae" => Some(home.join(".trae/rules/lean-ctx.md")),
1467        "Qwen Code" => Some(home.join(".qwen/rules/lean-ctx.md")),
1468        "Amazon Q Developer" => Some(home.join(".aws/amazonq/rules/lean-ctx.md")),
1469        "JetBrains IDEs" => Some(home.join(".jb-rules/lean-ctx.md")),
1470        "Antigravity" => Some(home.join(".gemini/antigravity/rules/lean-ctx.md")),
1471        "Pi Coding Agent" => Some(home.join(".pi/rules/lean-ctx.md")),
1472        "Crush" => Some(home.join(".config/crush/rules/lean-ctx.md")),
1473        _ => None,
1474    }
1475}
1476
1477fn check_zed_settings(path: &std::path::Path, binary: &str) -> NamedCheck {
1478    if !path.exists() {
1479        return NamedCheck {
1480            name: "Zed config".to_string(),
1481            ok: false,
1482            detail: format!("missing ({})", path.display()),
1483        };
1484    }
1485    let content = std::fs::read_to_string(path).unwrap_or_default();
1486    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1487    let Some(v) = parsed else {
1488        return NamedCheck {
1489            name: "Zed config".to_string(),
1490            ok: false,
1491            detail: format!("invalid JSON ({})", path.display()),
1492        };
1493    };
1494    let entry = v
1495        .get("context_servers")
1496        .and_then(|m| m.get("lean-ctx"))
1497        .cloned();
1498    let Some(e) = entry else {
1499        return NamedCheck {
1500            name: "Zed config".to_string(),
1501            ok: false,
1502            detail: format!("lean-ctx missing ({})", path.display()),
1503        };
1504    };
1505
1506    let cmd_ok = e
1507        .get("command")
1508        .and_then(|c| c.as_str())
1509        .is_some_and(|c| cmd_matches_expected(c, binary));
1510
1511    NamedCheck {
1512        name: "Zed config".to_string(),
1513        ok: cmd_ok,
1514        detail: if cmd_ok {
1515            format!("ok ({})", path.display())
1516        } else {
1517            format!("drift ({})", path.display())
1518        },
1519    }
1520}
1521
1522fn check_vscode_mcp(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1523    if !path.exists() {
1524        return NamedCheck {
1525            name: "VS Code MCP".to_string(),
1526            ok: false,
1527            detail: format!("missing ({})", path.display()),
1528        };
1529    }
1530    let content = std::fs::read_to_string(path).unwrap_or_default();
1531    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1532    let Some(v) = parsed else {
1533        return NamedCheck {
1534            name: "VS Code MCP".to_string(),
1535            ok: false,
1536            detail: format!("invalid JSON ({})", path.display()),
1537        };
1538    };
1539    let Some(e) = v.get("servers").and_then(|m| m.get("lean-ctx")) else {
1540        return NamedCheck {
1541            name: "VS Code MCP".to_string(),
1542            ok: false,
1543            detail: format!("lean-ctx missing ({})", path.display()),
1544        };
1545    };
1546
1547    let ty_ok = e.get("type").and_then(|t| t.as_str()) == Some("stdio");
1548    let cmd_ok = e
1549        .get("command")
1550        .and_then(|c| c.as_str())
1551        .is_some_and(|c| cmd_matches_expected(c, binary));
1552    let env_ok = e
1553        .get("env")
1554        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1555        .and_then(|d| d.as_str())
1556        .is_some_and(|d| d.trim() == data_dir.trim());
1557
1558    let ok = ty_ok && cmd_ok && env_ok;
1559    NamedCheck {
1560        name: "VS Code MCP".to_string(),
1561        ok,
1562        detail: if ok {
1563            format!("ok ({})", path.display())
1564        } else {
1565            format!("drift ({})", path.display())
1566        },
1567    }
1568}
1569
1570fn check_opencode_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1571    if !path.exists() {
1572        return NamedCheck {
1573            name: "OpenCode MCP".to_string(),
1574            ok: false,
1575            detail: format!("missing ({})", path.display()),
1576        };
1577    }
1578    let content = std::fs::read_to_string(path).unwrap_or_default();
1579    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1580    let Some(v) = parsed else {
1581        return NamedCheck {
1582            name: "OpenCode MCP".to_string(),
1583            ok: false,
1584            detail: format!("invalid JSON ({})", path.display()),
1585        };
1586    };
1587    let Some(e) = v.get("mcp").and_then(|m| m.get("lean-ctx")) else {
1588        return NamedCheck {
1589            name: "OpenCode MCP".to_string(),
1590            ok: false,
1591            detail: format!("lean-ctx missing ({})", path.display()),
1592        };
1593    };
1594
1595    let cmd = e
1596        .get("command")
1597        .and_then(|c| c.as_array())
1598        .and_then(|a| a.first())
1599        .and_then(|x| x.as_str());
1600    let cmd_ok = cmd.is_some_and(|c| cmd_matches_expected(c, binary));
1601    let env_ok = e
1602        .get("environment")
1603        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1604        .and_then(|d| d.as_str())
1605        .is_some_and(|d| d.trim() == data_dir.trim());
1606    let ok = cmd_ok && env_ok;
1607    NamedCheck {
1608        name: "OpenCode MCP".to_string(),
1609        ok,
1610        detail: if ok {
1611            format!("ok ({})", path.display())
1612        } else {
1613            format!("drift ({})", path.display())
1614        },
1615    }
1616}
1617
1618fn check_crush_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1619    if !path.exists() {
1620        return NamedCheck {
1621            name: "Crush MCP".to_string(),
1622            ok: false,
1623            detail: format!("missing ({})", path.display()),
1624        };
1625    }
1626    let content = std::fs::read_to_string(path).unwrap_or_default();
1627    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1628    let Some(v) = parsed else {
1629        return NamedCheck {
1630            name: "Crush MCP".to_string(),
1631            ok: false,
1632            detail: format!("invalid JSON ({})", path.display()),
1633        };
1634    };
1635    let Some(e) = v.get("mcp").and_then(|m| m.get("lean-ctx")) else {
1636        return NamedCheck {
1637            name: "Crush MCP".to_string(),
1638            ok: false,
1639            detail: format!("lean-ctx missing ({})", path.display()),
1640        };
1641    };
1642
1643    let cmd_ok = e
1644        .get("command")
1645        .and_then(|c| c.as_str())
1646        .is_some_and(|c| cmd_matches_expected(c, binary));
1647    let env_ok = e
1648        .get("env")
1649        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1650        .and_then(|d| d.as_str())
1651        .is_some_and(|d| d.trim() == data_dir.trim());
1652    let ok = cmd_ok && env_ok;
1653    NamedCheck {
1654        name: "Crush MCP".to_string(),
1655        ok,
1656        detail: if ok {
1657            format!("ok ({})", path.display())
1658        } else {
1659            format!("drift ({})", path.display())
1660        },
1661    }
1662}
1663
1664fn check_amp_config(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1665    if !path.exists() {
1666        return NamedCheck {
1667            name: "Amp MCP".to_string(),
1668            ok: false,
1669            detail: format!("missing ({})", path.display()),
1670        };
1671    }
1672    let content = std::fs::read_to_string(path).unwrap_or_default();
1673    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1674    let Some(v) = parsed else {
1675        return NamedCheck {
1676            name: "Amp MCP".to_string(),
1677            ok: false,
1678            detail: format!("invalid JSON ({})", path.display()),
1679        };
1680    };
1681    let Some(e) = v.get("amp.mcpServers").and_then(|m| m.get("lean-ctx")) else {
1682        return NamedCheck {
1683            name: "Amp MCP".to_string(),
1684            ok: false,
1685            detail: format!("lean-ctx missing ({})", path.display()),
1686        };
1687    };
1688
1689    let cmd_ok = e
1690        .get("command")
1691        .and_then(|c| c.as_str())
1692        .is_some_and(|c| cmd_matches_expected(c, binary));
1693    let env_ok = e
1694        .get("env")
1695        .and_then(|env| env.get("LEAN_CTX_DATA_DIR"))
1696        .and_then(|d| d.as_str())
1697        .is_some_and(|d| d.trim() == data_dir.trim());
1698    let ok = cmd_ok && env_ok;
1699    NamedCheck {
1700        name: "Amp MCP".to_string(),
1701        ok,
1702        detail: if ok {
1703            format!("ok ({})", path.display())
1704        } else {
1705            format!("drift ({})", path.display())
1706        },
1707    }
1708}
1709
1710fn check_codex_toml(path: &std::path::Path, binary: &str) -> NamedCheck {
1711    if !path.exists() {
1712        return NamedCheck {
1713            name: "Codex MCP".to_string(),
1714            ok: false,
1715            detail: format!("missing ({})", path.display()),
1716        };
1717    }
1718    let content = std::fs::read_to_string(path).unwrap_or_default();
1719    let parsed: Result<toml::Value, _> = toml::from_str(&content);
1720    let Ok(v) = parsed else {
1721        return NamedCheck {
1722            name: "Codex MCP".to_string(),
1723            ok: false,
1724            detail: format!("invalid TOML ({})", path.display()),
1725        };
1726    };
1727    let cmd = v
1728        .get("mcp_servers")
1729        .and_then(|t| t.get("lean-ctx"))
1730        .and_then(|t| t.get("command"))
1731        .and_then(|c| c.as_str());
1732    let ok = cmd.is_some_and(|c| cmd_matches_expected(c, binary));
1733    NamedCheck {
1734        name: "Codex MCP".to_string(),
1735        ok,
1736        detail: if ok {
1737            format!("ok ({})", path.display())
1738        } else {
1739            format!("drift ({})", path.display())
1740        },
1741    }
1742}
1743
1744fn check_codex_hooks_enabled(home: &std::path::Path) -> NamedCheck {
1745    let path = home.join(".codex").join("config.toml");
1746    if !path.exists() {
1747        return NamedCheck {
1748            name: "Codex hooks".to_string(),
1749            ok: false,
1750            detail: format!("missing ({})", path.display()),
1751        };
1752    }
1753    let content = std::fs::read_to_string(&path).unwrap_or_default();
1754    let parsed: Result<toml::Value, _> = toml::from_str(&content);
1755    let Ok(v) = parsed else {
1756        return NamedCheck {
1757            name: "Codex hooks".to_string(),
1758            ok: false,
1759            detail: format!("invalid TOML ({})", path.display()),
1760        };
1761    };
1762    let ok = v
1763        .get("features")
1764        .and_then(|t| t.get("codex_hooks"))
1765        .and_then(toml::Value::as_bool)
1766        == Some(true);
1767    NamedCheck {
1768        name: "Codex hooks".to_string(),
1769        ok,
1770        detail: if ok {
1771            format!("enabled ({})", path.display())
1772        } else {
1773            format!("disabled ({})", path.display())
1774        },
1775    }
1776}
1777
1778fn check_codex_hooks_json(home: &std::path::Path) -> NamedCheck {
1779    let path = home.join(".codex").join("hooks.json");
1780    if !path.exists() {
1781        return NamedCheck {
1782            name: "Codex hooks.json".to_string(),
1783            ok: false,
1784            detail: format!("missing ({})", path.display()),
1785        };
1786    }
1787    let content = std::fs::read_to_string(&path).unwrap_or_default();
1788    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1789    let Some(v) = parsed else {
1790        return NamedCheck {
1791            name: "Codex hooks.json".to_string(),
1792            ok: false,
1793            detail: format!("invalid JSON ({})", path.display()),
1794        };
1795    };
1796    let hooks = v.get("hooks");
1797    let mut saw_session_start = false;
1798    let mut saw_pretool = false;
1799    if let Some(h) = hooks {
1800        for event in ["SessionStart", "PreToolUse"] {
1801            if let Some(arr) = h.get(event).and_then(|x| x.as_array()) {
1802                for entry in arr {
1803                    let Some(hooks_arr) = entry.get("hooks").and_then(|x| x.as_array()) else {
1804                        continue;
1805                    };
1806                    for he in hooks_arr {
1807                        let Some(cmd) = he.get("command").and_then(|c| c.as_str()) else {
1808                            continue;
1809                        };
1810                        if cmd.contains("hook codex-session-start") {
1811                            saw_session_start = true;
1812                        }
1813                        if cmd.contains("hook codex-pretooluse") {
1814                            saw_pretool = true;
1815                        }
1816                    }
1817                }
1818            }
1819        }
1820    }
1821    let ok = saw_session_start && saw_pretool;
1822    NamedCheck {
1823        name: "Codex hooks.json".to_string(),
1824        ok,
1825        detail: if ok {
1826            format!("ok ({})", path.display())
1827        } else {
1828            format!("missing managed entries ({})", path.display())
1829        },
1830    }
1831}
1832
1833fn check_hermes_yaml(path: &std::path::Path, binary: &str, data_dir: &str) -> NamedCheck {
1834    if !path.exists() {
1835        return NamedCheck {
1836            name: "Hermes MCP".to_string(),
1837            ok: false,
1838            detail: format!("missing ({})", path.display()),
1839        };
1840    }
1841    let content = std::fs::read_to_string(path).unwrap_or_default();
1842    let has_mcp = content.contains("mcp_servers:") && content.contains("lean-ctx:");
1843    let has_cmd =
1844        content.contains("command:") && (content.contains(binary) || content.contains("lean-ctx"));
1845    let has_env = content.contains("LEAN_CTX_DATA_DIR") && content.contains(data_dir);
1846    let ok = has_mcp && has_cmd && has_env;
1847    NamedCheck {
1848        name: "Hermes MCP".to_string(),
1849        ok,
1850        detail: if ok {
1851            format!("ok ({})", path.display())
1852        } else {
1853            format!("drift ({})", path.display())
1854        },
1855    }
1856}
1857
1858fn check_gemini_trust_and_hooks(home: &std::path::Path, binary: &str) -> NamedCheck {
1859    let settings = home.join(".gemini").join("settings.json");
1860    if !settings.exists() {
1861        return NamedCheck {
1862            name: "Gemini hooks".to_string(),
1863            ok: false,
1864            detail: format!("missing ({})", settings.display()),
1865        };
1866    }
1867    let content = std::fs::read_to_string(&settings).unwrap_or_default();
1868    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1869    let Some(v) = parsed else {
1870        return NamedCheck {
1871            name: "Gemini hooks".to_string(),
1872            ok: false,
1873            detail: format!("invalid JSON ({})", settings.display()),
1874        };
1875    };
1876
1877    let trust_ok = v
1878        .get("mcpServers")
1879        .and_then(|m| m.get("lean-ctx"))
1880        .and_then(|e| e.get("trust"))
1881        .and_then(serde_json::Value::as_bool)
1882        == Some(true);
1883
1884    let hooks_ok = v
1885        .get("hooks")
1886        .and_then(|h| h.get("BeforeTool"))
1887        .and_then(|x| x.as_array())
1888        .is_some_and(|arr| {
1889            let mut saw_rewrite = false;
1890            let mut saw_redirect = false;
1891            for entry in arr {
1892                let hooks = entry
1893                    .get("hooks")
1894                    .and_then(|x| x.as_array())
1895                    .cloned()
1896                    .unwrap_or_default();
1897                for h in hooks {
1898                    let cmd = h
1899                        .get("command")
1900                        .and_then(|c| c.as_str())
1901                        .unwrap_or_default();
1902                    let first = cmd.split_whitespace().next().unwrap_or_default();
1903                    if cmd.contains("hook rewrite") && cmd_matches_expected(first, binary) {
1904                        saw_rewrite = true;
1905                    }
1906                    if cmd.contains("hook redirect") && cmd_matches_expected(first, binary) {
1907                        saw_redirect = true;
1908                    }
1909                }
1910            }
1911            saw_rewrite && saw_redirect
1912        });
1913
1914    let scripts_ok = home
1915        .join(".gemini")
1916        .join("hooks")
1917        .join("lean-ctx-rewrite-gemini.sh")
1918        .exists()
1919        && home
1920            .join(".gemini")
1921            .join("hooks")
1922            .join("lean-ctx-redirect-gemini.sh")
1923            .exists();
1924
1925    let ok = trust_ok && hooks_ok && scripts_ok;
1926    NamedCheck {
1927        name: "Gemini hooks".to_string(),
1928        ok,
1929        detail: if ok {
1930            format!("ok ({})", settings.display())
1931        } else {
1932            "drift (hooks/trust/scripts)".to_string()
1933        },
1934    }
1935}
1936
1937fn check_cursor_hooks(path: &std::path::Path) -> NamedCheck {
1938    if !path.exists() {
1939        return NamedCheck {
1940            name: "Hooks".to_string(),
1941            ok: false,
1942            detail: format!("missing ({})", path.display()),
1943        };
1944    }
1945    let content = std::fs::read_to_string(path).unwrap_or_default();
1946    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1947    let Some(v) = parsed else {
1948        return NamedCheck {
1949            name: "Hooks".to_string(),
1950            ok: false,
1951            detail: format!("invalid JSON ({})", path.display()),
1952        };
1953    };
1954    let pre = v
1955        .get("hooks")
1956        .and_then(|h| h.get("preToolUse"))
1957        .and_then(|x| x.as_array())
1958        .cloned()
1959        .unwrap_or_default();
1960    let has_rewrite = pre.iter().any(|e| {
1961        e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
1962            && e.get("command")
1963                .and_then(|c| c.as_str())
1964                .is_some_and(|c| c.contains(" hook rewrite"))
1965    });
1966    let has_redirect = pre.iter().any(|e| {
1967        matches!(
1968            e.get("matcher").and_then(|m| m.as_str()),
1969            Some("Read|Grep" | "Read" | "Grep")
1970        ) && e
1971            .get("command")
1972            .and_then(|c| c.as_str())
1973            .is_some_and(|c| c.contains(" hook redirect"))
1974    });
1975    NamedCheck {
1976        name: "Hooks".to_string(),
1977        ok: has_rewrite && has_redirect,
1978        detail: if has_rewrite && has_redirect {
1979            format!("ok ({})", path.display())
1980        } else {
1981            format!("drift ({})", path.display())
1982        },
1983    }
1984}
1985
1986fn check_claude_hooks(path: &std::path::Path) -> NamedCheck {
1987    if !path.exists() {
1988        return NamedCheck {
1989            name: "Hooks".to_string(),
1990            ok: false,
1991            detail: format!("missing ({})", path.display()),
1992        };
1993    }
1994    let content = std::fs::read_to_string(path).unwrap_or_default();
1995    let parsed = crate::core::jsonc::parse_jsonc(&content).ok();
1996    let Some(v) = parsed else {
1997        return NamedCheck {
1998            name: "Hooks".to_string(),
1999            ok: false,
2000            detail: format!("invalid JSON ({})", path.display()),
2001        };
2002    };
2003    let pre = v
2004        .get("hooks")
2005        .and_then(|h| h.get("PreToolUse"))
2006        .and_then(|x| x.as_array())
2007        .cloned()
2008        .unwrap_or_default();
2009    let joined = serde_json::to_string(&pre).unwrap_or_default();
2010    let ok = joined.contains(" hook rewrite") && joined.contains(" hook redirect");
2011    NamedCheck {
2012        name: "Hooks".to_string(),
2013        ok,
2014        detail: if ok {
2015            format!("ok ({})", path.display())
2016        } else {
2017            format!("drift ({})", path.display())
2018        },
2019    }
2020}
2021
2022struct DoctorFixOptions {
2023    json: bool,
2024}
2025
2026fn run_fix(opts: &DoctorFixOptions) -> Result<i32, String> {
2027    use crate::core::setup_report::{
2028        doctor_report_path, PlatformInfo, SetupItem, SetupReport, SetupStepReport,
2029    };
2030
2031    let _quiet_guard = opts
2032        .json
2033        .then(|| crate::setup::EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
2034    let started_at = Utc::now();
2035    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
2036
2037    let mut steps: Vec<SetupStepReport> = Vec::new();
2038
2039    // Step: shell hook repair
2040    let mut shell_step = SetupStepReport {
2041        name: "shell_hook".to_string(),
2042        ok: true,
2043        items: Vec::new(),
2044        warnings: Vec::new(),
2045        errors: Vec::new(),
2046    };
2047    let before = shell_aliases_outcome();
2048    if before.ok {
2049        shell_step.items.push(SetupItem {
2050            name: "init --global".to_string(),
2051            status: "already".to_string(),
2052            path: None,
2053            note: None,
2054        });
2055    } else {
2056        if opts.json {
2057            crate::cli::cmd_init_quiet(&["--global".to_string()]);
2058        } else {
2059            crate::cli::cmd_init(&["--global".to_string()]);
2060        }
2061        let after = shell_aliases_outcome();
2062        shell_step.ok = after.ok;
2063        shell_step.items.push(SetupItem {
2064            name: "init --global".to_string(),
2065            status: if after.ok {
2066                "fixed".to_string()
2067            } else {
2068                "failed".to_string()
2069            },
2070            path: None,
2071            note: if after.ok {
2072                None
2073            } else {
2074                Some("shell hook still not detected by doctor checks".to_string())
2075            },
2076        });
2077        if !after.ok {
2078            shell_step
2079                .warnings
2080                .push("shell hook not detected after init --global".to_string());
2081        }
2082    }
2083    steps.push(shell_step);
2084
2085    // Step: MCP config repair (detected tools)
2086    let mut mcp_step = SetupStepReport {
2087        name: "mcp_config".to_string(),
2088        ok: true,
2089        items: Vec::new(),
2090        warnings: Vec::new(),
2091        errors: Vec::new(),
2092    };
2093    let binary = crate::core::portable_binary::resolve_portable_binary();
2094    let targets = crate::core::editor_registry::build_targets(&home);
2095    for t in &targets {
2096        if !t.detect_path.exists() {
2097            continue;
2098        }
2099        let short = t.config_path.to_string_lossy().to_string();
2100
2101        let mode = if t.agent_key.is_empty() {
2102            crate::hooks::HookMode::Mcp
2103        } else {
2104            crate::hooks::recommend_hook_mode(&t.agent_key)
2105        };
2106
2107        let res = if mode == crate::hooks::HookMode::CliRedirect {
2108            crate::core::editor_registry::remove_lean_ctx_server(
2109                t,
2110                crate::core::editor_registry::WriteOptions {
2111                    overwrite_invalid: true,
2112                },
2113            )
2114        } else {
2115            crate::core::editor_registry::write_config_with_options(
2116                t,
2117                &binary,
2118                crate::core::editor_registry::WriteOptions {
2119                    overwrite_invalid: true,
2120                },
2121            )
2122        };
2123
2124        match res {
2125            Ok(r) => {
2126                let status = match r.action {
2127                    crate::core::editor_registry::WriteAction::Created => "created",
2128                    crate::core::editor_registry::WriteAction::Updated => "updated",
2129                    crate::core::editor_registry::WriteAction::Already => "already",
2130                };
2131                let note_parts: Vec<String> = [Some(format!("mode={mode}")), r.note]
2132                    .into_iter()
2133                    .flatten()
2134                    .collect();
2135                mcp_step.items.push(SetupItem {
2136                    name: t.name.to_string(),
2137                    status: status.to_string(),
2138                    path: Some(short),
2139                    note: Some(note_parts.join("; ")),
2140                });
2141            }
2142            Err(e) => {
2143                mcp_step.ok = false;
2144                mcp_step.items.push(SetupItem {
2145                    name: t.name.to_string(),
2146                    status: "error".to_string(),
2147                    path: Some(short),
2148                    note: Some(e.clone()),
2149                });
2150                mcp_step.errors.push(format!("{}: {e}", t.name));
2151            }
2152        }
2153    }
2154    if mcp_step.items.is_empty() {
2155        mcp_step
2156            .warnings
2157            .push("no supported AI tools detected; skipped MCP config repair".to_string());
2158    }
2159    steps.push(mcp_step);
2160
2161    // Step: agent rules injection
2162    let mut rules_step = SetupStepReport {
2163        name: "agent_rules".to_string(),
2164        ok: true,
2165        items: Vec::new(),
2166        warnings: Vec::new(),
2167        errors: Vec::new(),
2168    };
2169    let inj = crate::rules_inject::inject_all_rules(&home);
2170    if !inj.injected.is_empty() {
2171        rules_step.items.push(SetupItem {
2172            name: "injected".to_string(),
2173            status: inj.injected.len().to_string(),
2174            path: None,
2175            note: Some(inj.injected.join(", ")),
2176        });
2177    }
2178    if !inj.updated.is_empty() {
2179        rules_step.items.push(SetupItem {
2180            name: "updated".to_string(),
2181            status: inj.updated.len().to_string(),
2182            path: None,
2183            note: Some(inj.updated.join(", ")),
2184        });
2185    }
2186    if !inj.already.is_empty() {
2187        rules_step.items.push(SetupItem {
2188            name: "already".to_string(),
2189            status: inj.already.len().to_string(),
2190            path: None,
2191            note: Some(inj.already.join(", ")),
2192        });
2193    }
2194    if !inj.errors.is_empty() {
2195        rules_step.ok = false;
2196        rules_step.errors.extend(inj.errors.clone());
2197    }
2198    steps.push(rules_step);
2199
2200    // Step: agent hooks repair (all detected agents)
2201    let mut hooks_step = SetupStepReport {
2202        name: "agent_hooks".to_string(),
2203        ok: true,
2204        items: Vec::new(),
2205        warnings: Vec::new(),
2206        errors: Vec::new(),
2207    };
2208    let targets = crate::core::editor_registry::build_targets(&home);
2209    for t in &targets {
2210        if !t.detect_path.exists() || t.agent_key.trim().is_empty() {
2211            continue;
2212        }
2213        let mode = crate::hooks::recommend_hook_mode(&t.agent_key);
2214        crate::hooks::install_agent_hook_with_mode(&t.agent_key, true, mode);
2215        hooks_step.items.push(SetupItem {
2216            name: format!("{} hooks", t.name),
2217            status: "installed".to_string(),
2218            path: Some(t.detect_path.to_string_lossy().to_string()),
2219            note: Some(format!("mode={mode}; merge-based install/repair")),
2220        });
2221    }
2222    if !hooks_step.items.is_empty() {
2223        steps.push(hooks_step);
2224    }
2225
2226    // Step: SKILL.md repair
2227    let mut skill_step = SetupStepReport {
2228        name: "skill_files".to_string(),
2229        ok: true,
2230        items: Vec::new(),
2231        warnings: Vec::new(),
2232        errors: Vec::new(),
2233    };
2234    let skill_result = crate::setup::install_skill_files(&home);
2235    for (name, installed) in &skill_result {
2236        skill_step.items.push(SetupItem {
2237            name: name.clone(),
2238            status: if *installed {
2239                "installed".to_string()
2240            } else {
2241                "already".to_string()
2242            },
2243            path: None,
2244            note: Some("SKILL.md".to_string()),
2245        });
2246    }
2247    if !skill_result.is_empty() {
2248        steps.push(skill_step);
2249    }
2250
2251    // Step: verify (compact)
2252    let mut verify_step = SetupStepReport {
2253        name: "verify".to_string(),
2254        ok: true,
2255        items: Vec::new(),
2256        warnings: Vec::new(),
2257        errors: Vec::new(),
2258    };
2259    let (passed, total) = compact_score();
2260    verify_step.items.push(SetupItem {
2261        name: "doctor_compact".to_string(),
2262        status: format!("{passed}/{total}"),
2263        path: None,
2264        note: None,
2265    });
2266    if passed != total {
2267        verify_step.warnings.push(format!(
2268            "doctor compact not fully passing: {passed}/{total}"
2269        ));
2270    }
2271    steps.push(verify_step);
2272
2273    let finished_at = Utc::now();
2274    let success = steps.iter().all(|s| s.ok);
2275
2276    let report = SetupReport {
2277        schema_version: 1,
2278        started_at,
2279        finished_at,
2280        success,
2281        platform: PlatformInfo {
2282            os: std::env::consts::OS.to_string(),
2283            arch: std::env::consts::ARCH.to_string(),
2284        },
2285        steps,
2286        warnings: Vec::new(),
2287        errors: Vec::new(),
2288    };
2289
2290    let path = doctor_report_path()?;
2291    let json_text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?;
2292    crate::config_io::write_atomic_with_backup(&path, &json_text)?;
2293
2294    if opts.json {
2295        println!("{json_text}");
2296    } else {
2297        let (passed, total) = compact_score();
2298        print_compact_status(passed, total);
2299        println!("  {DIM}report saved:{RST} {}", path.display());
2300    }
2301
2302    Ok(i32::from(!report.success))
2303}
2304
2305pub fn compact_score() -> (u32, u32) {
2306    let mut passed = 0u32;
2307    let total = 6u32;
2308
2309    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
2310        passed += 1;
2311    }
2312    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
2313    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
2314        passed += 1;
2315    }
2316    if lean_dir
2317        .as_ref()
2318        .map(|d| d.join("stats.json"))
2319        .and_then(|p| std::fs::metadata(p).ok())
2320        .is_some_and(|m| m.is_file())
2321    {
2322        passed += 1;
2323    }
2324    if shell_aliases_outcome().ok {
2325        passed += 1;
2326    }
2327    if mcp_config_outcome().ok {
2328        passed += 1;
2329    }
2330    if skill_files_outcome().ok {
2331        passed += 1;
2332    }
2333
2334    (passed, total)
2335}
2336
2337fn print_compact_status(passed: u32, total: u32) {
2338    let status = if passed == total {
2339        format!("{GREEN}✓ All {total} checks passed{RST}")
2340    } else {
2341        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
2342    };
2343    println!("  {status}");
2344}