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