Skip to main content

cli/
doctor.rs

1//! `mushroomdb doctor` — verify an install end to end: the config entry, the
2//! store, the hooks, and a real stdio handshake with the configured MCP
3//! command.
4//!
5//! # Design
6//!
7//! Every check reads what is actually on disk — the same files `install`
8//! wrote — rather than recomputing what an install *should* look like, so
9//! doctor catches drift (a hand-edited config, a stale command) that a
10//! `run_install_with` re-derivation would paper over. Checks run in a fixed
11//! order and each prints exactly one line: `ok|warn|fail  <name>  <message>`,
12//! with a trailing `fix: …` when there is something to run. The same store
13//! state always produces the same output.
14//!
15//! `fail` on any check is the only thing that sets the process exit code;
16//! `warn` is informational.
17
18use crate::install::{
19    claude_mcp_file, cursor_mcp_file, default_db, entry_db, expand_platform, git_hooks_dir,
20    has_our_server, installed_shape, is_disabled, is_our_hook_command, line_runs_for_store,
21    opt_ins, resolve_platform, resolve_scope, Externals, Platform, Scope, StoreRef, AUTO_ARG,
22    BRIEF_EVENT, ENRICH_EVENT, GIT_HOOKS, HOOK_BEGIN, HOOK_EVENT, IMPACT_EVENT, INTERCEPT_EVENT,
23    SERVER_NAME, TOUCH_EVENT,
24};
25use crate::CliError;
26use core_api::{GraphDb, GraphError, OpenOptions};
27use serde_json::Value as Js;
28use std::io::{BufRead, BufReader, Read as _, Write as _};
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, Instant};
32
33/// Options parsed from `mushroomdb doctor [flags]`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DoctorOpts {
36    /// Which platform's config to check. `None` = auto-detect, same as `install`.
37    pub platform: Option<Platform>,
38    /// Project or user scope. `None` = auto: project inside a git checkout.
39    pub scope: Option<Scope>,
40}
41
42/// Outcome of `mushroomdb doctor`: the rendered report and whether to exit 1.
43pub struct DoctorReport {
44    /// One line per check, already newline-terminated.
45    pub output: String,
46    /// True when any check is `fail`. The caller exits 1 on this and only this.
47    pub had_fail: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Status {
52    Ok,
53    /// The check does not apply to this install — not a finding either way.
54    Skip,
55    Warn,
56    Fail,
57}
58
59impl Status {
60    fn word(self) -> &'static str {
61        match self {
62            Status::Ok => "ok",
63            Status::Skip => "skip",
64            Status::Warn => "warn",
65            Status::Fail => "fail",
66        }
67    }
68}
69
70/// One printed line: a status, the check's name, a message, and an optional
71/// one-line fix.
72struct Check {
73    status: Status,
74    name: &'static str,
75    message: String,
76    fix: Option<String>,
77}
78
79impl Check {
80    fn ok(name: &'static str, message: impl Into<String>) -> Self {
81        Check {
82            status: Status::Ok,
83            name,
84            message: message.into(),
85            fix: None,
86        }
87    }
88    fn skip(name: &'static str, message: impl Into<String>) -> Self {
89        Check {
90            status: Status::Skip,
91            name,
92            message: message.into(),
93            fix: None,
94        }
95    }
96    fn warn(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
97        Check {
98            status: Status::Warn,
99            name,
100            message: message.into(),
101            fix,
102        }
103    }
104    fn fail(name: &'static str, message: impl Into<String>, fix: Option<String>) -> Self {
105        Check {
106            status: Status::Fail,
107            name,
108            message: message.into(),
109            fix,
110        }
111    }
112    fn render(&self) -> String {
113        // 11 is the longest check name (`impact-hook`, `always-load`), so the
114        // message column lines up for every row rather than for most of them.
115        let mut line = format!(
116            "{:<4} {:<11} {}",
117            self.status.word(),
118            self.name,
119            self.message
120        );
121        if let Some(fix) = &self.fix {
122            line.push_str(&format!("  fix: {fix}"));
123        }
124        line.push('\n');
125        line
126    }
127}
128
129/// Run `doctor` against the real environment: `HOME` and the process PATH.
130pub fn run_doctor(
131    project_root: &Path,
132    home: &Path,
133    opts: &DoctorOpts,
134) -> Result<DoctorReport, CliError> {
135    run_doctor_with(project_root, home, opts, &Externals::from_env())
136}
137
138/// Like [`run_doctor`], with the external environment (PATH) supplied by the
139/// caller. Tests use this to stay deterministic and to point `npx` lookups at
140/// a directory of stand-ins.
141pub fn run_doctor_with(
142    project_root: &Path,
143    home: &Path,
144    opts: &DoctorOpts,
145    ext: &Externals,
146) -> Result<DoctorReport, CliError> {
147    let (scope, _auto_scope) = resolve_scope(project_root, opts.scope);
148    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
149    let platforms = expand_platform(&resolved);
150
151    // A disabled install has no config to check — every check below it would
152    // report exactly what `disable` intentionally removed, which is not a
153    // failure. Report the state and stop; `warn` never sets the exit code.
154    if is_disabled(project_root, home, scope, &platforms) {
155        return Ok(DoctorReport {
156            output: Check::warn("state", "disabled — enable with: mushroomdb enable", None)
157                .render(),
158            had_fail: false,
159        });
160    }
161
162    // A `cli` install registers no MCP server on purpose, so the two checks
163    // that read one have nothing to read and no fault to report; the store it
164    // is wired to comes from the hooks it did write. Everything else — the
165    // store, the hooks, the git hooks — is as breakable here as anywhere and
166    // still runs.
167    let (delivery, recorded_store) = installed_shape(project_root, home, scope, &platforms);
168    let mcp_delivery = delivery.wires_mcp();
169
170    let mut checks: Vec<Check> = Vec::new();
171    let mut primary: Option<(Platform, ConfigEntry)> = None;
172
173    // 1. config — one line per requested platform; the first entry that reads
174    //    cleanly becomes the target of every check below it.
175    for plat in &platforms {
176        // A `cli` delivery closes Claude Code's door and no other's: Cursor
177        // and Codex are registered as servers whatever it asked for, so their
178        // entries are still there to check (see [`install::Delivery`]).
179        if !mcp_delivery && matches!(plat, Platform::ClaudeCode) {
180            checks.push(Check::skip(
181                "config",
182                format!(
183                    "delivery: {} — the skill teaches the binary, no MCP entry to check",
184                    delivery.label()
185                ),
186            ));
187            continue;
188        }
189        match mcp_file_for(plat, project_root, home, scope) {
190            None => checks.push(Check::warn(
191                "config",
192                format!(
193                    "{}'s configuration is owned by its own CLI — not checked here",
194                    plat.label()
195                ),
196                None,
197            )),
198            Some(mcp_file) => match read_config_entry(&mcp_file, project_root, home) {
199                Ok(entry) => {
200                    checks.push(Check::ok(
201                        "config",
202                        format!(
203                            "{} — {} -> {}",
204                            plat.label(),
205                            mcp_file.display(),
206                            entry.describe_store()
207                        ),
208                    ));
209                    if primary.is_none() {
210                        primary = Some((plat.clone(), entry));
211                    }
212                }
213                Err(msg) => checks.push(Check::fail("config", msg, Some(install_fix(scope, plat)))),
214            },
215        }
216    }
217
218    // The store every check below reads: the one the config entry names, or —
219    // with no entry to name it — the one this install's hooks were written for.
220    let store: Option<StoreRef> = match &primary {
221        Some((_, entry)) => Some(entry.store.clone()),
222        None if !mcp_delivery => recorded_store,
223        None => None,
224    };
225
226    // 2. how the server is spawned — `npx` fetches the package, a resolved
227    //    binary or launcher is a file that has to still be there.
228    if let Some((_, entry)) = &primary {
229        if entry.command == "npx" {
230            checks.push(check_npx(entry, ext));
231        } else if let Some(check) = check_resolved_path(entry) {
232            checks.push(check);
233        }
234    }
235
236    // 3. store, then the write-lock probe (same check family, adjacent lines).
237    match &store {
238        Some(store) => checks.extend(check_store_and_lock(store.path())),
239        None => checks.push(Check::fail(
240            "store",
241            no_store_message(mcp_delivery),
242            Some(install_fix_for_scope(scope)),
243        )),
244    }
245
246    // 4. hooks — Claude Code only; Cursor has no prompt/tool-use hooks to check.
247    if platforms.contains(&Platform::ClaudeCode) {
248        if let Some(store) = &store {
249            checks.push(check_hooks(project_root, home, scope, store));
250            // The opt-in hooks each earn a line only where the manifest says
251            // this install asked for one. Reporting them otherwise would say
252            // something about every install that is true of none.
253            let opted = opt_ins(project_root, home, scope, &platforms);
254            let settings_file = settings_file(project_root, home, scope);
255            let settings = read_json(&settings_file).unwrap_or(Js::Null);
256            for (asked, sub, event, matcher, flag) in [
257                (
258                    opted.intercept_grep,
259                    "intercept",
260                    INTERCEPT_EVENT,
261                    "Grep",
262                    "--intercept-grep",
263                ),
264                (
265                    opted.impact_before_edit,
266                    "impact-hook",
267                    IMPACT_EVENT,
268                    "Edit|Write|MultiEdit",
269                    "--impact-before-edit",
270                ),
271                (
272                    opted.enrich_grep,
273                    "enrich",
274                    ENRICH_EVENT,
275                    "Grep",
276                    "--enrich-grep",
277                ),
278            ] {
279                if asked {
280                    checks.push(check_opt_in_hook(
281                        sub,
282                        event,
283                        matcher,
284                        flag,
285                        &settings_file,
286                        &settings,
287                        store,
288                    ));
289                }
290            }
291            if opted.always_load {
292                checks.push(check_always_load(project_root, home, scope));
293            }
294        }
295    }
296
297    // 5. git hooks — project scope, and only for the platforms whose install
298    //    wires the repository (matches `install::write_everything`).
299    if scope == Scope::Project
300        && platforms
301            .iter()
302            .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor))
303    {
304        if let Some(store) = &store {
305            if let Some(check) = check_git_hooks(project_root, store) {
306                checks.push(check);
307            }
308        }
309    }
310
311    // 6. self-handshake — spawn the configured command for real.
312    match &primary {
313        Some((_, entry)) => checks.push(check_handshake(entry)),
314        // No entry and none expected: nothing to spawn, and nothing wrong.
315        None if !mcp_delivery => checks.push(Check::skip(
316            "handshake",
317            format!("delivery: {} — no server to spawn", delivery.label()),
318        )),
319        None => checks.push(Check::fail(
320            "handshake",
321            "no usable config entry — nothing to spawn",
322            Some(install_fix_for_scope(scope)),
323        )),
324    }
325
326    // 7. duplicate scope — a second Claude Code server in the other scope.
327    if platforms.contains(&Platform::ClaudeCode) {
328        checks.push(check_scope_conflict(project_root, home, scope));
329    }
330
331    let had_fail = checks.iter().any(|c| c.status == Status::Fail);
332    let mut output = String::new();
333    for c in &checks {
334        output.push_str(&c.render());
335    }
336    Ok(DoctorReport { output, had_fail })
337}
338
339/// Why doctor could not find a store to check, in the terms of whichever
340/// artifact was supposed to name one.
341fn no_store_message(mcp_delivery: bool) -> &'static str {
342    if mcp_delivery {
343        "no usable config entry — cannot locate a database to check"
344    } else {
345        "no recorded SessionStart hook — cannot locate a database to check"
346    }
347}
348
349fn install_fix_for_scope(scope: Scope) -> String {
350    format!(
351        "mushroomdb install {}",
352        match scope {
353            Scope::Project => "--project",
354            Scope::User => "--user",
355        }
356    )
357}
358
359fn install_fix(scope: Scope, plat: &Platform) -> String {
360    format!(
361        "mushroomdb install --platform {} {}",
362        plat.label(),
363        match scope {
364            Scope::Project => "--project",
365            Scope::User => "--user",
366        }
367    )
368}
369
370fn mcp_file_for(
371    plat: &Platform,
372    project_root: &Path,
373    home: &Path,
374    scope: Scope,
375) -> Option<PathBuf> {
376    match plat {
377        Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
378        Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
379        Platform::Codex | Platform::All => None,
380    }
381}
382
383// ---------------------------------------------------------------------------
384// config
385// ---------------------------------------------------------------------------
386
387/// The MCP server entry doctor found: what it points at and how it is spawned.
388struct ConfigEntry {
389    /// The store the entry names, as written: a path, or `--auto`.
390    store: StoreRef,
391    command: String,
392    args: Vec<String>,
393}
394
395impl ConfigEntry {
396    /// How the config line reports the store. An `--auto` entry says what it
397    /// resolves to as well, since that is the directory every check below it
398    /// reads and the one the reader wants to see.
399    fn describe_store(&self) -> String {
400        if self.store.is_auto() {
401            format!("{AUTO_ARG} -> {}", self.store.path().display())
402        } else {
403            self.store.path().display().to_string()
404        }
405    }
406}
407
408/// The store an entry's argument names, resolved the way the command it was
409/// written for would resolve it.
410///
411/// `--auto` is resolved from `project_root` rather than doctor's own working
412/// directory: doctor was already told which project it is checking, and a
413/// report that changes depending on which subdirectory it was typed in would
414/// be a worse report. That is the same directory a hook resolves to, since a
415/// hook walks up to the working tree root from wherever it fired.
416fn entry_store(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
417    if arg == AUTO_ARG {
418        return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
419    }
420    let path = PathBuf::from(arg);
421    if path == default_db(Scope::Project, project_root, home) {
422        return StoreRef::pinned(path).also_auto();
423    }
424    StoreRef::pinned(path)
425}
426
427fn read_json(path: &Path) -> Result<Js, String> {
428    let raw = std::fs::read_to_string(path)
429        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
430    serde_json::from_str(&raw).map_err(|e| format!("invalid JSON in {}: {e}", path.display()))
431}
432
433fn read_config_entry(
434    mcp_file: &Path,
435    project_root: &Path,
436    home: &Path,
437) -> Result<ConfigEntry, String> {
438    if !mcp_file.exists() {
439        return Err(format!("{} does not exist", mcp_file.display()));
440    }
441    let root = read_json(mcp_file)?;
442    let entry = &root["mcpServers"]["mushroomdb"];
443    if entry.is_null() {
444        return Err(format!(
445            "no mcpServers.mushroomdb entry in {}",
446            mcp_file.display()
447        ));
448    }
449    let store = entry_store(
450        entry_db(entry).ok_or_else(|| {
451            format!(
452                "{}: mushroomdb entry has no `mcp <db>|--auto` argument",
453                mcp_file.display()
454            )
455        })?,
456        project_root,
457        home,
458    );
459    let command = entry["command"]
460        .as_str()
461        .ok_or_else(|| format!("{}: mushroomdb entry has no `command`", mcp_file.display()))?
462        .to_string();
463    let args = entry["args"]
464        .as_array()
465        .map(|a| {
466            a.iter()
467                .filter_map(|v| v.as_str().map(str::to_string))
468                .collect()
469        })
470        .unwrap_or_default();
471    Ok(ConfigEntry {
472        store,
473        command,
474        args,
475    })
476}
477
478// ---------------------------------------------------------------------------
479// npx
480// ---------------------------------------------------------------------------
481
482const NPX_TIMEOUT: Duration = Duration::from_secs(60);
483
484fn check_npx(entry: &ConfigEntry, ext: &Externals) -> Check {
485    let pinned = entry
486        .args
487        .iter()
488        .find_map(|a| a.strip_prefix("mushroomdb@"))
489        .unwrap_or(crate::VERSION);
490    let Some(npx) = ext.which("npx") else {
491        return Check::fail(
492            "npx",
493            "npx is not on PATH",
494            Some(
495                "install Node.js (which provides npx), or re-install with --command <path>"
496                    .to_string(),
497            ),
498        );
499    };
500    let args = vec![
501        "-y".to_string(),
502        format!("mushroomdb@{pinned}"),
503        "--version".to_string(),
504    ];
505    match run_capturing(&npx, &args, NPX_TIMEOUT) {
506        RunOutcome::Done(out) if out.contains(pinned) => Check::ok(
507            "npx",
508            format!("npx -y mushroomdb@{pinned} --version -> {}", out.trim()),
509        ),
510        RunOutcome::Done(out) => Check::fail(
511            "npx",
512            format!(
513                "npx -y mushroomdb@{pinned} --version printed {:?}, expected to contain {pinned}",
514                out.trim()
515            ),
516            Some("re-run `mushroomdb install` to repin the version".to_string()),
517        ),
518        RunOutcome::TimedOut => Check::warn(
519            "npx",
520            format!("npx -y mushroomdb@{pinned} --version timed out after {NPX_TIMEOUT:?}"),
521            Some("check network access to the npm registry".to_string()),
522        ),
523        RunOutcome::Failed(e) => Check::fail("npx", e, None),
524    }
525}
526
527/// The resolved-path counterpart to [`check_npx`].
528///
529/// `install` writes a path rather than `npx` so the hooks do not spawn `npx`
530/// on every prompt: the package's native binary, or `node <launcher.js>` when
531/// the binary was not fetched. Both live in npm's cache, and pruning it — or
532/// npx evicting an old version — leaves a command that cannot run. The file
533/// existing is the whole check; what it does once it runs is the handshake's
534/// business.
535///
536/// A bare `command` is a PATH lookup, not a file, and is left to the handshake.
537fn check_resolved_path(entry: &ConfigEntry) -> Option<Check> {
538    let (name, path) = if entry.command == "node" {
539        ("launcher", Path::new(entry.args.first()?))
540    } else if Path::new(&entry.command).is_absolute() {
541        ("binary", Path::new(&entry.command))
542    } else {
543        return None;
544    };
545    Some(if path.is_file() {
546        Check::ok(name, path.display().to_string())
547    } else {
548        Check::fail(
549            name,
550            format!("{} no longer exists", path.display()),
551            Some("mushroomdb install (re-resolves it)".to_string()),
552        )
553    })
554}
555
556enum RunOutcome {
557    Done(String),
558    TimedOut,
559    Failed(String),
560}
561
562/// Run `bin`, capturing stdout, giving up after `timeout`.
563fn run_capturing(bin: &Path, args: &[String], timeout: Duration) -> RunOutcome {
564    let mut child = match Command::new(bin)
565        .args(args)
566        .stdin(Stdio::null())
567        .stdout(Stdio::piped())
568        .stderr(Stdio::null())
569        .spawn()
570    {
571        Ok(c) => c,
572        Err(e) => return RunOutcome::Failed(format!("cannot run {}: {e}", bin.display())),
573    };
574    let mut stdout = child.stdout.take().expect("piped stdout");
575    let (tx, rx) = std::sync::mpsc::channel::<String>();
576    std::thread::spawn(move || {
577        let mut out = String::new();
578        let _ = stdout.read_to_string(&mut out);
579        let _ = tx.send(out);
580    });
581    let deadline = Instant::now() + timeout;
582    loop {
583        match child.try_wait() {
584            Ok(Some(status)) => {
585                let out = rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default();
586                return if status.success() {
587                    RunOutcome::Done(out)
588                } else {
589                    RunOutcome::Failed(format!("{} exited with {status}", bin.display()))
590                };
591            }
592            Ok(None) if Instant::now() >= deadline => {
593                let _ = child.kill();
594                let _ = child.wait();
595                return RunOutcome::TimedOut;
596            }
597            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
598            Err(e) => return RunOutcome::Failed(format!("cannot wait for {}: {e}", bin.display())),
599        }
600    }
601}
602
603// ---------------------------------------------------------------------------
604// store + lock
605// ---------------------------------------------------------------------------
606
607fn check_store_and_lock(db_dir: &Path) -> Vec<Check> {
608    let mut out = Vec::new();
609    let store = GraphDb::open_with_options(
610        db_dir,
611        OpenOptions {
612            read_only: true,
613            auto_migrate: true,
614            repair_wal: true,
615        },
616    );
617    match store {
618        Ok(db) => {
619            let stats = db.stats();
620            let stale = db.is_stale().unwrap_or(false);
621            out.push(Check::ok(
622                "store",
623                format!(
624                    "{} — {} nodes live ({} tombstoned), {} edges{}",
625                    db_dir.display(),
626                    stats.nodes_live,
627                    stats.nodes_tombstoned,
628                    stats.edges,
629                    if stale {
630                        ", stale (newer commits pending refresh)"
631                    } else {
632                        ""
633                    }
634                ),
635            ));
636            drop(db);
637
638            // Briefly try to take the write lock. Success means nobody else
639            // holds it; the handle is dropped immediately, before this
640            // function returns, so the lock is never held past the check.
641            match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
642                Ok(handle) => {
643                    drop(handle);
644                    out.push(Check::ok(
645                        "lock",
646                        "free — no other process is writing".to_string(),
647                    ));
648                }
649                Err(GraphError::Busy { .. }) => out.push(Check::warn(
650                    "lock",
651                    "another process is writing".to_string(),
652                    Some("re-run once the other process finishes".to_string()),
653                )),
654                Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
655            }
656        }
657        Err(e) => out.push(Check::fail(
658            "store",
659            format!("cannot open {}: {e}", db_dir.display()),
660            Some(format!("mushroomdb verify {}", db_dir.display())),
661        )),
662    }
663    out
664}
665
666// ---------------------------------------------------------------------------
667// hooks
668// ---------------------------------------------------------------------------
669
670fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
671    let settings_file = match scope {
672        Scope::Project => project_root.join(".claude").join("settings.json"),
673        Scope::User => home.join(".claude").join("settings.json"),
674    };
675    let root = read_json(&settings_file).unwrap_or(Js::Null);
676    let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
677    let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
678    let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
679    if has_recall && has_touch && has_brief {
680        Check::ok(
681            "hooks",
682            format!(
683                "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
684                settings_file.display()
685            ),
686        )
687    } else {
688        let mut missing = Vec::new();
689        if !has_recall {
690            missing.push(HOOK_EVENT);
691        }
692        if !has_touch {
693            missing.push(TOUCH_EVENT);
694        }
695        if !has_brief {
696            missing.push(BRIEF_EVENT);
697        }
698        Check::warn(
699            "hooks",
700            format!(
701                "missing {} in {}",
702                missing.join(", "),
703                settings_file.display()
704            ),
705            Some("mushroomdb install --platform claude-code".to_string()),
706        )
707    }
708}
709
710/// `alwaysLoad` on the registered server, for an install whose manifest asked
711/// for it.
712///
713/// Reads the file rather than trusting the flag. The two can disagree for
714/// ordinary reasons — the entry hand-edited, a `--delivery cli` install that
715/// records the flag but registers no server for it to sit on, another tool
716/// rewriting `.mcp.json` — and in every one of them the manifest says the
717/// experiment is on while the host is deferring the server exactly as before.
718/// That is the failure a benchmark arm would silently record as "no effect",
719/// so it is a `warn` naming the file, not an `ok`.
720fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
721    let mcp_file = claude_mcp_file(project_root, home, scope);
722    let entry = read_json(&mcp_file).unwrap_or(Js::Null);
723    if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
724        Check::ok(
725            "always-load",
726            format!(
727                "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
728                mcp_file.display()
729            ),
730        )
731    } else {
732        Check::warn(
733            "always-load",
734            format!("manifest records it but {} does not", mcp_file.display()),
735            Some("mushroomdb install --platform claude-code --always-load".to_string()),
736        )
737    }
738}
739
740/// The `settings.json` a Claude Code install at this scope writes its hooks to.
741fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
742    match scope {
743        Scope::Project => project_root.join(".claude").join("settings.json"),
744        Scope::User => home.join(".claude").join("settings.json"),
745    }
746}
747
748/// One experimental hook, for an install whose manifest asked for it.
749///
750/// `sub` is the subcommand word, which is what tells two hooks sharing an
751/// event apart; `matcher` and `flag` only ever reach the reported text. The
752/// parsed settings arrive from the caller so three checks cost one read.
753fn check_opt_in_hook(
754    sub: &'static str,
755    event: &str,
756    matcher: &str,
757    flag: &str,
758    settings_file: &Path,
759    root: &Js,
760    store: &StoreRef,
761) -> Check {
762    if has_hook_matching(root, event, sub, store) {
763        Check::ok(
764            sub,
765            format!("{event} ({matcher}) present in {}", settings_file.display()),
766        )
767    } else {
768        Check::warn(
769            sub,
770            format!("missing {event} in {}", settings_file.display()),
771            Some(format!("mushroomdb install --platform claude-code {flag}")),
772        )
773    }
774}
775
776fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
777    root["hooks"][event]
778        .as_array()
779        .map(|groups| {
780            groups.iter().any(|g| {
781                g["hooks"]
782                    .as_array()
783                    .map(|hs| {
784                        hs.iter().any(|h| {
785                            h["command"]
786                                .as_str()
787                                .is_some_and(|c| is_our_hook_command(c, sub, store))
788                        })
789                    })
790                    .unwrap_or(false)
791            })
792        })
793        .unwrap_or(false)
794}
795
796// ---------------------------------------------------------------------------
797// git hooks
798// ---------------------------------------------------------------------------
799
800fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
801    let dir = git_hooks_dir(project_root)?;
802    // The block belongs to this store if it names it either way. An `--auto`
803    // block in a checkout whose store is the default is the same store, and
804    // that is the shape every project install now writes.
805    let missing: Vec<&str> = GIT_HOOKS
806        .iter()
807        .filter(|name| {
808            let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
809            let names_store = content
810                .lines()
811                .any(|l| line_runs_for_store(l, "sync", store));
812            !(content.contains(HOOK_BEGIN) && names_store)
813        })
814        .copied()
815        .collect();
816    Some(if missing.is_empty() {
817        Check::ok(
818            "git-hooks",
819            format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
820        )
821    } else {
822        Check::warn(
823            "git-hooks",
824            format!("missing in {}: {}", dir.display(), missing.join(", ")),
825            Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
826        )
827    })
828}
829
830// ---------------------------------------------------------------------------
831// duplicate scope
832// ---------------------------------------------------------------------------
833
834fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
835    let (other_file, other_label, other_flag) = match scope {
836        Scope::Project => (
837            claude_mcp_file(project_root, home, Scope::User),
838            "user",
839            "--user",
840        ),
841        Scope::User => (
842            claude_mcp_file(project_root, home, Scope::Project),
843            "project",
844            "--project",
845        ),
846    };
847    if has_our_server(&other_file) {
848        Check::warn(
849            "scope",
850            format!(
851                "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
852                other_file.display()
853            ),
854            Some(format!("mushroomdb uninstall {other_flag}")),
855        )
856    } else {
857        Check::ok(
858            "scope",
859            "no duplicate server in the other scope".to_string(),
860        )
861    }
862}
863
864// ---------------------------------------------------------------------------
865// self-handshake
866// ---------------------------------------------------------------------------
867
868const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
869
870struct HandshakeOk {
871    version: String,
872    tool_count: usize,
873    /// The repository task tool the listing carried — see [`TASK_PATH_TOOLS`].
874    task_tool: String,
875}
876
877fn check_handshake(entry: &ConfigEntry) -> Check {
878    match self_handshake(&entry.command, &entry.args) {
879        Ok(HandshakeOk {
880            version,
881            tool_count,
882            task_tool,
883        }) => Check::ok(
884            "handshake",
885            format!(
886                "initialize + tools/list ok — version {version}, {tool_count} tools \
887                 ({task_tool} present)"
888            ),
889        ),
890        Err(msg) => Check::fail(
891            "handshake",
892            msg,
893            Some(format!(
894                "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
895                 to rewrite the command",
896                entry.command,
897                entry.args.join(" ")
898            )),
899        ),
900    }
901}
902
903/// Spawn `command args…`, speak one `initialize` and one `tools/list` request
904/// over its stdio, and check the response.
905///
906/// Reads with a 10s deadline; closes stdin once both responses are in (or the
907/// deadline passes) so a well-behaved server exits on EOF, then reaps it with
908/// a short bounded wait — a broken server never hangs `doctor`.
909fn self_handshake(command: &str, args: &[String]) -> Result<HandshakeOk, String> {
910    let mut child = Command::new(command)
911        .args(args)
912        .stdin(Stdio::piped())
913        .stdout(Stdio::piped())
914        .stderr(Stdio::null())
915        .spawn()
916        .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
917
918    let mut stdin = child.stdin.take().expect("piped stdin");
919    let stdout = child.stdout.take().expect("piped stdout");
920
921    let (tx, rx) = std::sync::mpsc::channel::<String>();
922    std::thread::spawn(move || {
923        let mut reader = BufReader::new(stdout);
924        let mut line = String::new();
925        loop {
926            line.clear();
927            match reader.read_line(&mut line) {
928                Ok(0) | Err(_) => break,
929                Ok(_) => {
930                    if tx.send(line.trim().to_string()).is_err() {
931                        break;
932                    }
933                }
934            }
935        }
936    });
937
938    let sent = writeln!(
939        stdin,
940        r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
941    )
942    .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
943    .and_then(|()| stdin.flush());
944
945    let mut init_resp: Option<Js> = None;
946    let mut list_resp: Option<Js> = None;
947    if sent.is_ok() {
948        let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
949        while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
950            let remaining = deadline.saturating_duration_since(Instant::now());
951            match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
952                Ok(line) if !line.is_empty() => {
953                    if let Ok(v) = serde_json::from_str::<Js>(&line) {
954                        match v.get("id").and_then(Js::as_i64) {
955                            Some(1) => init_resp = Some(v),
956                            Some(2) => list_resp = Some(v),
957                            _ => {}
958                        }
959                    }
960                }
961                Ok(_) => {}
962                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
963                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
964            }
965        }
966    }
967
968    // EOF on stdin is how a well-behaved server knows to exit; then reap it
969    // with a short bounded wait so a broken one cannot hang doctor.
970    drop(stdin);
971    let reap_deadline = Instant::now() + Duration::from_secs(2);
972    loop {
973        match child.try_wait() {
974            Ok(Some(_)) | Err(_) => break,
975            Ok(None) if Instant::now() >= reap_deadline => {
976                let _ = child.kill();
977                let _ = child.wait();
978                break;
979            }
980            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
981        }
982    }
983
984    if sent.is_err() {
985        return Err(format!("cannot write to `{command}`'s stdin"));
986    }
987    let init = init_resp.ok_or_else(|| {
988        format!("`{command}` did not answer `initialize` within {HANDSHAKE_TIMEOUT:?}")
989    })?;
990    let list = list_resp.ok_or_else(|| {
991        format!("`{command}` did not answer `tools/list` within {HANDSHAKE_TIMEOUT:?}")
992    })?;
993
994    let version = init["result"]["serverInfo"]["version"]
995        .as_str()
996        .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
997        .to_string();
998    if version != crate::VERSION {
999        return Err(format!(
1000            "`{command}` reports version {version}, expected {}",
1001            crate::VERSION
1002        ));
1003    }
1004    let tools = list["result"]["tools"]
1005        .as_array()
1006        .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1007    let task_tool = tools
1008        .iter()
1009        .filter_map(|t| t["name"].as_str())
1010        .find(|name| TASK_PATH_TOOLS.contains(name))
1011        .ok_or_else(|| {
1012            format!(
1013                "`{command}`: tools/list includes none of {}",
1014                TASK_PATH_TOOLS.join(", ")
1015            )
1016        })?
1017        .to_string();
1018
1019    Ok(HandshakeOk {
1020        version,
1021        tool_count: tools.len(),
1022        task_tool,
1023    })
1024}
1025
1026/// The tool names that prove the repository task path is served rather than the
1027/// graph API alone.
1028///
1029/// Either is enough, because which one is listed follows the store: a store
1030/// `ingest-git` built advertises `explore` and hides the rest, and any other
1031/// store advertises `explain_association` among its fifteen. Requiring one
1032/// particular name would fail `doctor` on exactly the stores the other surface
1033/// exists for.
1034const TASK_PATH_TOOLS: [&str; 2] = ["explore", "explain_association"];