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            let floor = db.wal_horizon_floor();
622            let total = db.wal_total_commits().unwrap_or(floor);
623            out.push(Check::ok(
624                "store",
625                format!(
626                    "{} — {} nodes live ({} tombstoned), {} edges, history from commit {} of {}{}",
627                    db_dir.display(),
628                    stats.nodes_live,
629                    stats.nodes_tombstoned,
630                    stats.edges,
631                    floor,
632                    total,
633                    if stale {
634                        ", stale (newer commits pending refresh)"
635                    } else {
636                        ""
637                    }
638                ),
639            ));
640            drop(db);
641
642            // Briefly try to take the write lock. Success means nobody else
643            // holds it; the handle is dropped immediately, before this
644            // function returns, so the lock is never held past the check.
645            match GraphDb::open_with_options(db_dir, OpenOptions::default()) {
646                Ok(handle) => {
647                    drop(handle);
648                    out.push(Check::ok(
649                        "lock",
650                        "free — no other process is writing".to_string(),
651                    ));
652                }
653                Err(GraphError::Busy { .. }) => out.push(Check::warn(
654                    "lock",
655                    "another process is writing".to_string(),
656                    Some("re-run once the other process finishes".to_string()),
657                )),
658                Err(e) => out.push(Check::warn("lock", format!("could not verify: {e}"), None)),
659            }
660        }
661        Err(e) => out.push(Check::fail(
662            "store",
663            format!("cannot open {}: {e}", db_dir.display()),
664            Some(format!("mushroomdb verify {}", db_dir.display())),
665        )),
666    }
667    out
668}
669
670// ---------------------------------------------------------------------------
671// hooks
672// ---------------------------------------------------------------------------
673
674fn check_hooks(project_root: &Path, home: &Path, scope: Scope, store: &StoreRef) -> Check {
675    let settings_file = match scope {
676        Scope::Project => project_root.join(".claude").join("settings.json"),
677        Scope::User => home.join(".claude").join("settings.json"),
678    };
679    let root = read_json(&settings_file).unwrap_or(Js::Null);
680    let has_recall = has_hook_matching(&root, HOOK_EVENT, "recall", store);
681    let has_touch = has_hook_matching(&root, TOUCH_EVENT, "touch", store);
682    let has_brief = has_hook_matching(&root, BRIEF_EVENT, "brief", store);
683    if has_recall && has_touch && has_brief {
684        Check::ok(
685            "hooks",
686            format!(
687                "{HOOK_EVENT} + {TOUCH_EVENT} + {BRIEF_EVENT} present in {}",
688                settings_file.display()
689            ),
690        )
691    } else {
692        let mut missing = Vec::new();
693        if !has_recall {
694            missing.push(HOOK_EVENT);
695        }
696        if !has_touch {
697            missing.push(TOUCH_EVENT);
698        }
699        if !has_brief {
700            missing.push(BRIEF_EVENT);
701        }
702        Check::warn(
703            "hooks",
704            format!(
705                "missing {} in {}",
706                missing.join(", "),
707                settings_file.display()
708            ),
709            Some("mushroomdb install --platform claude-code".to_string()),
710        )
711    }
712}
713
714/// `alwaysLoad` on the registered server, for an install whose manifest asked
715/// for it.
716///
717/// Reads the file rather than trusting the flag. The two can disagree for
718/// ordinary reasons — the entry hand-edited, a `--delivery cli` install that
719/// records the flag but registers no server for it to sit on, another tool
720/// rewriting `.mcp.json` — and in every one of them the manifest says the
721/// experiment is on while the host is deferring the server exactly as before.
722/// That is the failure a benchmark arm would silently record as "no effect",
723/// so it is a `warn` naming the file, not an `ok`.
724fn check_always_load(project_root: &Path, home: &Path, scope: Scope) -> Check {
725    let mcp_file = claude_mcp_file(project_root, home, scope);
726    let entry = read_json(&mcp_file).unwrap_or(Js::Null);
727    if entry["mcpServers"][SERVER_NAME]["alwaysLoad"] == Js::Bool(true) {
728        Check::ok(
729            "always-load",
730            format!(
731                "mcpServers.{SERVER_NAME} is marked alwaysLoad in {}",
732                mcp_file.display()
733            ),
734        )
735    } else {
736        Check::warn(
737            "always-load",
738            format!("manifest records it but {} does not", mcp_file.display()),
739            Some("mushroomdb install --platform claude-code --always-load".to_string()),
740        )
741    }
742}
743
744/// The `settings.json` a Claude Code install at this scope writes its hooks to.
745fn settings_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
746    match scope {
747        Scope::Project => project_root.join(".claude").join("settings.json"),
748        Scope::User => home.join(".claude").join("settings.json"),
749    }
750}
751
752/// One experimental hook, for an install whose manifest asked for it.
753///
754/// `sub` is the subcommand word, which is what tells two hooks sharing an
755/// event apart; `matcher` and `flag` only ever reach the reported text. The
756/// parsed settings arrive from the caller so three checks cost one read.
757fn check_opt_in_hook(
758    sub: &'static str,
759    event: &str,
760    matcher: &str,
761    flag: &str,
762    settings_file: &Path,
763    root: &Js,
764    store: &StoreRef,
765) -> Check {
766    if has_hook_matching(root, event, sub, store) {
767        Check::ok(
768            sub,
769            format!("{event} ({matcher}) present in {}", settings_file.display()),
770        )
771    } else {
772        Check::warn(
773            sub,
774            format!("missing {event} in {}", settings_file.display()),
775            Some(format!("mushroomdb install --platform claude-code {flag}")),
776        )
777    }
778}
779
780fn has_hook_matching(root: &Js, event: &str, sub: &str, store: &StoreRef) -> bool {
781    root["hooks"][event]
782        .as_array()
783        .map(|groups| {
784            groups.iter().any(|g| {
785                g["hooks"]
786                    .as_array()
787                    .map(|hs| {
788                        hs.iter().any(|h| {
789                            h["command"]
790                                .as_str()
791                                .is_some_and(|c| is_our_hook_command(c, sub, store))
792                        })
793                    })
794                    .unwrap_or(false)
795            })
796        })
797        .unwrap_or(false)
798}
799
800// ---------------------------------------------------------------------------
801// git hooks
802// ---------------------------------------------------------------------------
803
804fn check_git_hooks(project_root: &Path, store: &StoreRef) -> Option<Check> {
805    let dir = git_hooks_dir(project_root)?;
806    // The block belongs to this store if it names it either way. An `--auto`
807    // block in a checkout whose store is the default is the same store, and
808    // that is the shape every project install now writes.
809    let missing: Vec<&str> = GIT_HOOKS
810        .iter()
811        .filter(|name| {
812            let content = std::fs::read_to_string(dir.join(name)).unwrap_or_default();
813            let names_store = content
814                .lines()
815                .any(|l| line_runs_for_store(l, "sync", store));
816            !(content.contains(HOOK_BEGIN) && names_store)
817        })
818        .copied()
819        .collect();
820    Some(if missing.is_empty() {
821        Check::ok(
822            "git-hooks",
823            format!("{} present in {}", GIT_HOOKS.join("/"), dir.display()),
824        )
825    } else {
826        Check::warn(
827            "git-hooks",
828            format!("missing in {}: {}", dir.display(), missing.join(", ")),
829            Some("mushroomdb install --project (omit --no-git-hooks)".to_string()),
830        )
831    })
832}
833
834// ---------------------------------------------------------------------------
835// duplicate scope
836// ---------------------------------------------------------------------------
837
838fn check_scope_conflict(project_root: &Path, home: &Path, scope: Scope) -> Check {
839    let (other_file, other_label, other_flag) = match scope {
840        Scope::Project => (
841            claude_mcp_file(project_root, home, Scope::User),
842            "user",
843            "--user",
844        ),
845        Scope::User => (
846            claude_mcp_file(project_root, home, Scope::Project),
847            "project",
848            "--project",
849        ),
850    };
851    if has_our_server(&other_file) {
852        Check::warn(
853            "scope",
854            format!(
855                "a {other_label}-scope mushroomdb server also exists ({}) — both will load",
856                other_file.display()
857            ),
858            Some(format!("mushroomdb uninstall {other_flag}")),
859        )
860    } else {
861        Check::ok(
862            "scope",
863            "no duplicate server in the other scope".to_string(),
864        )
865    }
866}
867
868// ---------------------------------------------------------------------------
869// self-handshake
870// ---------------------------------------------------------------------------
871
872const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
873
874struct HandshakeOk {
875    version: String,
876    tool_count: usize,
877    /// The repository task tool the listing carried — see [`TASK_PATH_TOOLS`].
878    task_tool: String,
879}
880
881fn check_handshake(entry: &ConfigEntry) -> Check {
882    match self_handshake(&entry.command, &entry.args) {
883        Ok(HandshakeOk {
884            version,
885            tool_count,
886            task_tool,
887        }) => Check::ok(
888            "handshake",
889            format!(
890                "initialize + tools/list ok — version {version}, {tool_count} tools \
891                 ({task_tool} present)"
892            ),
893        ),
894        Err(msg) => Check::fail(
895            "handshake",
896            msg,
897            Some(format!(
898                "verify `{} {}` runs mushroomdb's MCP server, or re-run `mushroomdb install` \
899                 to rewrite the command",
900                entry.command,
901                entry.args.join(" ")
902            )),
903        ),
904    }
905}
906
907/// Spawn `command args…`, speak one `initialize` and one `tools/list` request
908/// over its stdio, and check the response.
909///
910/// Reads with a 10s deadline; closes stdin once both responses are in (or the
911/// deadline passes) so a well-behaved server exits on EOF, then reaps it with
912/// a short bounded wait — a broken server never hangs `doctor`.
913fn self_handshake(command: &str, args: &[String]) -> Result<HandshakeOk, String> {
914    let mut child = Command::new(command)
915        .args(args)
916        .stdin(Stdio::piped())
917        .stdout(Stdio::piped())
918        .stderr(Stdio::null())
919        .spawn()
920        .map_err(|e| format!("cannot spawn `{command}`: {e}"))?;
921
922    let mut stdin = child.stdin.take().expect("piped stdin");
923    let stdout = child.stdout.take().expect("piped stdout");
924
925    let (tx, rx) = std::sync::mpsc::channel::<String>();
926    std::thread::spawn(move || {
927        let mut reader = BufReader::new(stdout);
928        let mut line = String::new();
929        loop {
930            line.clear();
931            match reader.read_line(&mut line) {
932                Ok(0) | Err(_) => break,
933                Ok(_) => {
934                    if tx.send(line.trim().to_string()).is_err() {
935                        break;
936                    }
937                }
938            }
939        }
940    });
941
942    let sent = writeln!(
943        stdin,
944        r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mushroomdb-doctor","version":"1"}}}}}}"#
945    )
946    .and_then(|()| writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#))
947    .and_then(|()| stdin.flush());
948
949    let mut init_resp: Option<Js> = None;
950    let mut list_resp: Option<Js> = None;
951    if sent.is_ok() {
952        let deadline = Instant::now() + HANDSHAKE_TIMEOUT;
953        while (init_resp.is_none() || list_resp.is_none()) && Instant::now() < deadline {
954            let remaining = deadline.saturating_duration_since(Instant::now());
955            match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
956                Ok(line) if !line.is_empty() => {
957                    if let Ok(v) = serde_json::from_str::<Js>(&line) {
958                        match v.get("id").and_then(Js::as_i64) {
959                            Some(1) => init_resp = Some(v),
960                            Some(2) => list_resp = Some(v),
961                            _ => {}
962                        }
963                    }
964                }
965                Ok(_) => {}
966                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
967                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
968            }
969        }
970    }
971
972    // EOF on stdin is how a well-behaved server knows to exit; then reap it
973    // with a short bounded wait so a broken one cannot hang doctor.
974    drop(stdin);
975    let reap_deadline = Instant::now() + Duration::from_secs(2);
976    loop {
977        match child.try_wait() {
978            Ok(Some(_)) | Err(_) => break,
979            Ok(None) if Instant::now() >= reap_deadline => {
980                let _ = child.kill();
981                let _ = child.wait();
982                break;
983            }
984            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
985        }
986    }
987
988    if sent.is_err() {
989        return Err(format!("cannot write to `{command}`'s stdin"));
990    }
991    let init = init_resp.ok_or_else(|| {
992        format!("`{command}` did not answer `initialize` within {HANDSHAKE_TIMEOUT:?}")
993    })?;
994    let list = list_resp.ok_or_else(|| {
995        format!("`{command}` did not answer `tools/list` within {HANDSHAKE_TIMEOUT:?}")
996    })?;
997
998    let version = init["result"]["serverInfo"]["version"]
999        .as_str()
1000        .ok_or_else(|| format!("`{command}`: initialize response has no serverInfo.version"))?
1001        .to_string();
1002    if version != crate::VERSION {
1003        return Err(format!(
1004            "`{command}` reports version {version}, expected {}",
1005            crate::VERSION
1006        ));
1007    }
1008    let tools = list["result"]["tools"]
1009        .as_array()
1010        .ok_or_else(|| format!("`{command}`: tools/list response has no tools array"))?;
1011    let task_tool = tools
1012        .iter()
1013        .filter_map(|t| t["name"].as_str())
1014        .find(|name| TASK_PATH_TOOLS.contains(name))
1015        .ok_or_else(|| {
1016            format!(
1017                "`{command}`: tools/list includes none of {}",
1018                TASK_PATH_TOOLS.join(", ")
1019            )
1020        })?
1021        .to_string();
1022
1023    Ok(HandshakeOk {
1024        version,
1025        tool_count: tools.len(),
1026        task_tool,
1027    })
1028}
1029
1030/// The tool names that prove the repository task path is served rather than the
1031/// graph API alone.
1032///
1033/// Either is enough, because which one is listed follows the store: a store
1034/// `ingest-git` built advertises `explore` and hides the rest, and any other
1035/// store advertises `explain_association` among its fifteen. Requiring one
1036/// particular name would fail `doctor` on exactly the stores the other surface
1037/// exists for.
1038const TASK_PATH_TOOLS: [&str; 2] = ["explore", "explain_association"];