Skip to main content

cli/
install.rs

1//! `mushroomdb install` / `uninstall` — wire the /mushroom skill, the MCP
2//! server, the prompt hooks and the git hooks into an assistant.
3//!
4//! # Design notes
5//!
6//! - Idempotent: running install twice is a no-op (exit 0).
7//! - Non-destructive: refuses to overwrite user files install didn't create.
8//! - Manifest-driven uninstall: tracks every file, key, hook, ignore line and
9//!   external registration it wrote; removes exactly that.
10//! - The only network access is the optional pre-warm, which is a best-effort
11//!   warm cache and never fails the install.
12//!
13//! # User-scope MCP config location (verified 2026-09-02 by live inspection)
14//!
15//! Claude Code user-level MCP servers live in `~/.claude.json` under the
16//! top-level `"mcpServers"` key. This was verified empirically on a live
17//! Claude Code install: `~/.claude/settings.json` holds env/permissions/hooks
18//! but NO mcpServers key. Cursor uses `~/.cursor/mcp.json` (same format as
19//! project-level `.cursor/mcp.json`). Codex keeps its own config and is
20//! written through the `codex` CLI rather than by editing a file.
21
22use crate::CliError;
23use serde::{Deserialize, Serialize};
24use std::ffi::{OsStr, OsString};
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::time::{Duration, Instant};
28
29// Template files embedded at compile time. Files live inside the crates/cli
30// package so `cargo package` includes them in the published tarball.
31// Path is relative to this source file (crates/cli/src/install.rs).
32const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
33const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
34
35/// Placeholder string replaced with the real db path in embedded templates.
36const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
37
38/// Placeholder string replaced with the command that invokes mushroomdb.
39/// Substituted with [`McpCommand::shell`], which is already shell-quoted, so
40/// the templates must leave it unquoted.
41const BIN_PLACEHOLDER: &str = "{{BIN}}";
42
43/// The MCP server name we write. Must not be changed without a migration.
44const SERVER_NAME: &str = "mushroomdb";
45
46/// The binary name looked up on PATH and used as the bare MCP command.
47const BIN_NAME: &str = "mushroomdb";
48
49/// The npm package the `npx` form runs. Same name as the binary.
50const NPM_PACKAGE: &str = "mushroomdb";
51
52/// The version an `npx` entry pins: the one that wrote it.
53const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
54
55/// How long the optional pre-warm may take before it is abandoned. A cold
56/// `npx` download of a native package is slow on a slow link, and the whole
57/// point is to pay that cost here rather than at the assistant's first prompt.
58const PREWARM_TIMEOUT_SECS: u64 = 180;
59
60/// The Node runtime a resolved launcher is handed to. Looked up on PATH: any
61/// machine with `npx` has it, since npm ships with Node.
62const NODE_BIN: &str = "node";
63
64/// The flag the npm launcher answers with the vendored native binary's path.
65const PRINT_BINARY_FLAG: &str = "--print-binary";
66
67/// The flag the npm launcher answers with its own absolute path.
68const PRINT_LAUNCHER_FLAG: &str = "--print-launcher";
69
70// ---------------------------------------------------------------------------
71// How the server is invoked
72// ---------------------------------------------------------------------------
73
74/// How the MCP server entry (and the skill's bootstrap commands) invoke
75/// mushroomdb.
76///
77/// The assistant host spawns the MCP server by `command`, so that command has
78/// to resolve from *its* process, not from the shell install ran in. A bare
79/// name only works when it resolves on the host's PATH, and the one case where
80/// that is provable is when the `mushroomdb` PATH resolves to is this very
81/// executable. Everything else — npm's Node shim, a different build, a local
82/// `target/release` binary, no hit at all — pins the published package and
83/// lets `npx` fetch it.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum McpCommand {
86    /// `npx -y mushroomdb@<version> …` — the fallback when the package cannot
87    /// be located, and the only form that works from a machine where nothing
88    /// is installed globally.
89    Npx { version: String },
90    /// The published package's own native binary, located once at install time
91    /// and run directly. The fast form, and what a hook gets whenever the
92    /// package has been fetched.
93    ///
94    /// Everything else in this enum that reaches the published package ends up
95    /// running this exact file; the difference is what it costs to get there.
96    /// Measured warm, `--version` end to end: `npx` 514 ms, `node <launcher>`
97    /// 118 ms, this 7 ms. Node's own startup is nearly all of the difference —
98    /// the launcher script's only job is to spawn this binary — and a hook pays
99    /// it on every prompt and every edit.
100    NativeBinary {
101        /// Absolute path to the vendored executable.
102        binary: PathBuf,
103        /// The version it was resolved from; re-resolved on upgrade.
104        version: String,
105    },
106    /// `node <launcher.js> …` — the same published package reached through its
107    /// npm shim. The fallback for an install whose native binary could not be
108    /// located (a postinstall that never fetched it, say): still resolved once
109    /// rather than on every invocation, just through a Node startup.
110    NodeLauncher {
111        /// Absolute path to the package's `bin` script.
112        launcher: PathBuf,
113        /// The version it was resolved from; re-resolved on upgrade.
114        version: String,
115    },
116    /// An absolute path the user named with `--command`.
117    Explicit(PathBuf),
118    /// `mushroomdb` resolves on PATH *and* is this executable: the bare name
119    /// is safe and follows upgrades.
120    OnPath,
121}
122
123impl McpCommand {
124    /// The `npx` form pinned to the version of the binary writing it.
125    #[must_use]
126    pub fn npx() -> Self {
127        McpCommand::Npx {
128            version: CRATE_VERSION.to_string(),
129        }
130    }
131
132    /// The program to exec and the arguments that come before the subcommand.
133    fn program(&self) -> (String, Vec<String>) {
134        match self {
135            McpCommand::Npx { version } => (
136                "npx".to_string(),
137                vec!["-y".to_string(), format!("{NPM_PACKAGE}@{version}")],
138            ),
139            McpCommand::NativeBinary { binary, .. } => {
140                (binary.to_string_lossy().into_owned(), Vec::new())
141            }
142            McpCommand::NodeLauncher { launcher, .. } => (
143                NODE_BIN.to_string(),
144                vec![launcher.to_string_lossy().into_owned()],
145            ),
146            McpCommand::Explicit(p) => (p.to_string_lossy().into_owned(), Vec::new()),
147            McpCommand::OnPath => (BIN_NAME.to_string(), Vec::new()),
148        }
149    }
150
151    /// The MCP server entry: `{"command": …, "args": [… , sub, db]}`.
152    ///
153    /// Nothing here is shell-quoted. An MCP host spawns the command with an
154    /// argv, so a path with a space in it is one element and quoting it would
155    /// make the quotes part of the filename.
156    #[must_use]
157    pub fn json_entry(&self, sub: &str, db: &str) -> serde_json::Value {
158        let (command, mut args) = self.program();
159        args.push(sub.to_string());
160        args.push(db.to_string());
161        serde_json::json!({ "command": command, "args": args })
162    }
163
164    /// The same invocation as a command *prefix* for a POSIX shell, already
165    /// quoted where quoting is needed.
166    ///
167    /// Hook entries and the skill's copy-paste lines are read by a shell, so
168    /// an explicit path — and a resolved binary or launcher path, which lives
169    /// wherever npm put it — has to survive a space in it. The bare name and
170    /// the `npx` form contain no metacharacters and are left as they read.
171    #[must_use]
172    pub fn shell(&self) -> String {
173        let (command, args) = self.program();
174        let mut out = match self {
175            McpCommand::Explicit(_) | McpCommand::NativeBinary { .. } => sh_quote(&command),
176            _ => command,
177        };
178        for a in args {
179            out.push(' ');
180            match self {
181                McpCommand::NodeLauncher { .. } => out.push_str(&sh_quote(&a)),
182                _ => out.push_str(&a),
183            }
184        }
185        out
186    }
187
188    /// The full argv, for handing to another CLI that registers servers.
189    fn argv(&self, sub: &str, db: &str) -> Vec<String> {
190        let (command, mut args) = self.program();
191        args.push(sub.to_string());
192        args.push(db.to_string());
193        let mut out = vec![command];
194        out.extend(args);
195        out
196    }
197}
198
199/// Decide how the MCP entry should invoke mushroomdb, from an explicit
200/// `--command` (if any) and the real environment.
201///
202/// `explicit` is `install`'s `--command` flag; `enable` has no such flag and
203/// always passes `None`, so it re-derives whatever `install` would choose
204/// right now rather than replaying what an earlier install or `disable`
205/// recorded.
206#[must_use]
207pub fn detect_mcp_command(explicit: Option<&Path>) -> McpCommand {
208    if let Some(path) = explicit {
209        return McpCommand::Explicit(path.to_path_buf());
210    }
211    match std::env::current_exe() {
212        Ok(exe) => classify_mcp_command(std::env::var_os("PATH").as_deref(), &exe),
213        // Cannot locate ourselves — the pinned package always resolves.
214        Err(_) => McpCommand::npx(),
215    }
216}
217
218/// Pure classifier behind [`detect_mcp_command`]: decide whether the
219/// `mushroomdb` that PATH resolves to is the executable now running.
220///
221/// A file named `mushroomdb` on PATH is not enough. `npx mushroomdb install`
222/// prepends `~/.npm/_npx/<hash>/node_modules/.bin` to PATH, and the
223/// `mushroomdb` there is npm's Node shim (`#!/usr/bin/env node`), not our
224/// native binary; `npm i -g mushroomdb` installs the same shim. Treating that
225/// as "on PATH" wrote a bare `mushroomdb` command that resolved only inside
226/// the npx-spawned shell, so the MCP server and recall hook died with ENOENT
227/// everywhere else (the v0.5.0 bug).
228///
229/// So: take the first PATH hit — that is what a bare name would resolve to —
230/// and canonicalize both it and `current_exe`. Equal paths mean the bare name
231/// runs this very executable, including via a symlink (how `cargo install` and
232/// Homebrew expose it), which is the one case where the bare name is safe and
233/// survives upgrades. Anything else means pinning the published package.
234#[must_use]
235pub fn classify_mcp_command(path_var: Option<&OsStr>, current_exe: &Path) -> McpCommand {
236    // What a bare `mushroomdb` would resolve to: the first PATH entry holding
237    // a file by that name (`is_file` follows symlinks, so links count).
238    let Some(hit) = path_var.and_then(|p| {
239        std::env::split_paths(p)
240            .map(|dir| dir.join(BIN_NAME))
241            .find(|candidate| candidate.is_file())
242    }) else {
243        return McpCommand::npx();
244    };
245
246    // Identity, not name. Canonicalizing resolves symlinks and `..`, so a link
247    // to us compares equal; if either side cannot be resolved we cannot prove
248    // it is us, and the pinned package is the answer that always works.
249    match (fs::canonicalize(&hit), fs::canonicalize(current_exe)) {
250        (Ok(on_path), Ok(running)) if on_path == running => McpCommand::OnPath,
251        _ => McpCommand::npx(),
252    }
253}
254
255// ---------------------------------------------------------------------------
256// How the store is named
257// ---------------------------------------------------------------------------
258
259/// The argument every written command uses in place of a store path when the
260/// store is to be resolved at run time.
261pub const AUTO_ARG: &str = "--auto";
262
263/// How the config an install writes names the store.
264///
265/// A project install writes `--auto`, not a path. The MCP entry, the three
266/// settings hooks and the three git hook blocks then resolve the store when
267/// they run — `$CLAUDE_PROJECT_DIR/mushroom-memory`, else `mushroom-memory` at
268/// the root of the working tree they were run in.
269///
270/// The reason is `git worktree`. Those config files live in the repository and
271/// get committed, so an absolute path baked into them follows a new worktree
272/// across and points every hook there at the *other* checkout's store: the
273/// graph then describes files that are not the ones being edited. Resolving at
274/// run time gives each working tree its own store, which is the only answer
275/// that is right in both checkouts.
276///
277/// `--db <path>` opts out and pins an absolute path; user scope always pins
278/// `~/.mushroomdb/memory`, since `--auto` inside any checkout would resolve to
279/// that project instead.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct StoreRef {
282    /// Where the store actually is for the checkout install ran in. Every
283    /// local decision — the `.gitignore` line, the skill's prose, the
284    /// pre-flight conflict check — needs a real directory whichever form is
285    /// written into the config.
286    path: PathBuf,
287    /// Whether written config says `--auto` rather than that path.
288    auto: bool,
289    /// Whether `--auto` resolves to this same store in this checkout. Always
290    /// true when `auto` is. Also true for a `--db` that happens to name the
291    /// default store, so an upgrade over an `--auto` install replaces its
292    /// hooks instead of running both.
293    auto_equivalent: bool,
294}
295
296impl StoreRef {
297    /// A store the config names `--auto`, living at `path` for this checkout.
298    #[must_use]
299    pub fn auto(path: impl Into<PathBuf>) -> Self {
300        let path = path.into();
301        StoreRef {
302            path,
303            auto: true,
304            auto_equivalent: true,
305        }
306    }
307
308    /// A store the config names by absolute path.
309    #[must_use]
310    pub fn pinned(path: impl Into<PathBuf>) -> Self {
311        StoreRef {
312            path: path.into(),
313            auto: false,
314            auto_equivalent: false,
315        }
316    }
317
318    /// Mark a pinned store as the one `--auto` also resolves to here.
319    #[must_use]
320    pub fn also_auto(mut self) -> Self {
321        self.auto_equivalent = true;
322        self
323    }
324
325    /// Where the store is on this machine, right now.
326    #[must_use]
327    pub fn path(&self) -> &Path {
328        &self.path
329    }
330
331    /// Whether written config resolves the store at run time.
332    #[must_use]
333    pub fn is_auto(&self) -> bool {
334        self.auto
335    }
336
337    /// The argument written into an MCP entry's `args` array: `--auto`, or the
338    /// path. Not shell-quoted — an MCP host spawns an argv, where quotes would
339    /// become part of the filename.
340    #[must_use]
341    pub fn arg(&self) -> String {
342        if self.auto {
343            AUTO_ARG.to_string()
344        } else {
345            self.path.to_string_lossy().into_owned()
346        }
347    }
348
349    /// The same argument for a command line a shell reads, quoted where
350    /// quoting is needed. `--auto` needs none; a path may contain a space.
351    #[must_use]
352    pub fn shell_arg(&self) -> String {
353        if self.auto {
354            AUTO_ARG.to_string()
355        } else {
356            sh_quote(&self.path.to_string_lossy())
357        }
358    }
359
360    /// How the summary line describes the store.
361    fn describe(&self) -> String {
362        if self.auto {
363            format!("{AUTO_ARG} (resolves to {})", self.path.display())
364        } else {
365            format!("{} (pinned)", self.path.display())
366        }
367    }
368
369    /// Every command tail a hook of ours for `sub` may end with, for this
370    /// store: the path spelling always, and the `--auto` spelling when that
371    /// resolves here too.
372    ///
373    /// Both are needed because an upgrade must recognise what the *previous*
374    /// version wrote. 0.6.0 wrote an absolute path; this version writes
375    /// `--auto`; either one left behind alongside the other means two recall
376    /// digests on every prompt.
377    fn hook_tails(&self, sub: &str) -> Vec<String> {
378        let mut out = vec![format!(" {sub} {}", sh_quote(&self.path.to_string_lossy()))];
379        if self.auto_equivalent {
380            out.push(format!(" {sub} {AUTO_ARG}"));
381        }
382        out
383    }
384
385    /// Whether an existing config argument names this same store: the same
386    /// spelling, the same path, or `--auto` where `--auto` means this store.
387    ///
388    /// This is what makes an upgrade an upgrade rather than a conflict — a
389    /// 0.6.0 entry naming `<project>/mushroom-memory` is the store `--auto`
390    /// now resolves to, so it is rewritten rather than refused.
391    fn names_same_store(&self, existing_arg: &str) -> bool {
392        if existing_arg == AUTO_ARG {
393            return self.auto_equivalent;
394        }
395        Path::new(existing_arg) == self.path
396    }
397}
398
399// ---------------------------------------------------------------------------
400// Options
401// ---------------------------------------------------------------------------
402
403/// Which assistant platform(s) to wire up.
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub enum Platform {
406    ClaudeCode,
407    Cursor,
408    Codex,
409    All,
410}
411
412impl Platform {
413    pub fn parse(s: &str) -> Result<Self, String> {
414        match s {
415            "claude-code" => Ok(Platform::ClaudeCode),
416            "cursor" => Ok(Platform::Cursor),
417            "codex" => Ok(Platform::Codex),
418            "all" => Ok(Platform::All),
419            other => Err(format!(
420                "--platform must be claude-code | cursor | codex | all, got: {other}"
421            )),
422        }
423    }
424
425    pub(crate) fn label(&self) -> &'static str {
426        match self {
427            Platform::ClaudeCode => "claude-code",
428            Platform::Cursor => "cursor",
429            Platform::Codex => "codex",
430            Platform::All => "all",
431        }
432    }
433}
434
435/// Where the install lives: alongside one repository, or once for the user.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum Scope {
438    Project,
439    User,
440}
441
442impl Scope {
443    pub(crate) fn label(self) -> &'static str {
444        match self {
445            Scope::Project => "project",
446            Scope::User => "user",
447        }
448    }
449}
450
451/// Which door an install opens onto the graph.
452///
453/// A session reaches the same store either way; what differs is what it costs
454/// to get there. An MCP tool's schema is fetched before its first call, so the
455/// first question costs a discovery round trip; a plain command through `Bash`
456/// costs none, at the price of the assistant having to know the invocation —
457/// which is exactly what the skill teaches.
458///
459/// Only the Claude Code install honours this: it is the one platform that gets
460/// a skill, and a skill is the only thing that can teach a binary. Cursor and
461/// Codex are registered as MCP servers whatever is asked for, and
462/// [`install_platform`] says so rather than dropping the flag in silence.
463#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
464#[serde(rename_all = "lowercase")]
465pub enum Delivery {
466    /// The skill teaches the binary; no MCP server is registered.
467    Cli,
468    /// Today's install: an MCP entry, and a skill that teaches its tools.
469    Mcp,
470    /// Both doors, and a skill that names both.
471    #[default]
472    Both,
473}
474
475impl Delivery {
476    /// Parse a `--delivery` value.
477    pub fn parse(s: &str) -> Result<Self, String> {
478        match s {
479            "cli" => Ok(Delivery::Cli),
480            "mcp" => Ok(Delivery::Mcp),
481            "both" => Ok(Delivery::Both),
482            other => Err(format!("--delivery must be cli | mcp | both, got: {other}")),
483        }
484    }
485
486    pub(crate) fn label(self) -> &'static str {
487        match self {
488            Delivery::Cli => "cli",
489            Delivery::Mcp => "mcp",
490            Delivery::Both => "both",
491        }
492    }
493
494    /// Whether this delivery registers an MCP server.
495    pub(crate) fn wires_mcp(self) -> bool {
496        !matches!(self, Delivery::Cli)
497    }
498}
499
500/// Options parsed from `mushroomdb install [flags]` or `mushroomdb uninstall [flags]`.
501#[derive(Debug, Clone, PartialEq, Eq)]
502pub struct InstallOpts {
503    /// Which platform to wire up. `None` = auto-detect.
504    pub platform: Option<Platform>,
505    /// Project or user scope. `None` = auto: project inside a git checkout.
506    pub scope: Option<Scope>,
507    /// Database directory. `None` = use the scope default.
508    pub db: Option<PathBuf>,
509    /// `--command <path>`: invoke this binary instead of `npx`/the bare name.
510    pub command: Option<PathBuf>,
511    /// Write the `post-commit` / `post-checkout` / `post-merge` sync hooks.
512    pub git_hooks: bool,
513    /// Run `npx -y mushroomdb@<v> --version` once so the first real spawn is
514    /// not a cold download.
515    pub prewarm: bool,
516    /// `--delivery cli|mcp|both`: which door the Claude Code install opens.
517    pub delivery: Delivery,
518    /// `--intercept-grep`: also write the experimental `PreToolUse` hook that
519    /// redirects a `Grep` for a known symbol name to `explore`. Off by
520    /// default — it is the one hook of ours that can block a tool call.
521    pub intercept_grep: bool,
522}
523
524/// Options parsed from `mushroomdb enable [flags]` or `mushroomdb disable [flags]`.
525///
526/// Deliberately narrower than [`InstallOpts`]: neither command takes `--db`,
527/// `--command` or `--no-git-hooks` — they act on whatever an existing install
528/// already recorded, not on a fresh choice of store or binary.
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct ToggleOpts {
531    /// Which platform to act on. `None` = auto-detect, same as `install`.
532    pub platform: Option<Platform>,
533    /// Project or user scope. `None` = auto: project inside a git checkout.
534    pub scope: Option<Scope>,
535}
536
537/// The store directory an install with no `--db` uses.
538#[must_use]
539pub fn default_db(scope: Scope, project_root: &Path, home: &Path) -> PathBuf {
540    match scope {
541        Scope::Project => project_root.join("mushroom-memory"),
542        Scope::User => home.join(".mushroomdb").join("memory"),
543    }
544}
545
546/// Whether this platform's host guarantees `--auto` resolves to this project.
547///
548/// Only Claude Code does. It sets `$CLAUDE_PROJECT_DIR` for both MCP servers
549/// and hook processes, so the first resolution step always answers, whatever
550/// working directory the process happens to have.
551///
552/// Cursor and Codex set no such variable. `--auto` there would rest entirely
553/// on the host spawning the server inside the checkout, and if it did not,
554/// resolution would fall through to `~/.mushroomdb/memory`: an empty store,
555/// with the `.gitignore` line and the rules file both naming a different
556/// directory, and nothing anywhere reporting an error. The assistant would
557/// simply see a graph with nothing in it. So those two get the path.
558///
559/// The worktree argument is weaker for them in any case. `.mcp.json` and the
560/// three settings hooks are Claude Code's, and they are what a `git worktree`
561/// carries across; a Cursor install's committed artifact is one rules file
562/// that names the store in prose.
563fn resolves_at_runtime(platform: &Platform) -> bool {
564    match platform {
565        Platform::ClaudeCode => true,
566        Platform::Cursor | Platform::Codex => false,
567        // `expand_platform` never produces it; false is the safe reading.
568        Platform::All => false,
569    }
570}
571
572/// How each requested platform will name the store, in the order they were
573/// asked for.
574fn platform_stores(
575    project_root: &Path,
576    home: &Path,
577    scope: Scope,
578    db: Option<&Path>,
579    platforms: &[Platform],
580) -> Vec<(Platform, StoreRef)> {
581    platforms
582        .iter()
583        .map(|p| {
584            (
585                p.clone(),
586                store_ref(project_root, home, scope, db, resolves_at_runtime(p)),
587            )
588        })
589        .collect()
590}
591
592/// The summary's `store` line(s).
593///
594/// One line when every platform names the store the same way, which is every
595/// single-platform install and most `--platform all` ones. When they differ —
596/// Claude Code resolving `--auto` beside a Cursor entry that cannot — each is
597/// labelled, because "which one is pinned" is exactly what a reader needs.
598fn describe_stores(stores: &[(Platform, StoreRef)]) -> String {
599    let all_same = stores.windows(2).all(|w| w[0].1 == w[1].1);
600    match stores.first() {
601        None => String::new(),
602        Some((_, first)) if all_same => format!("  store  {}\n", first.describe()),
603        _ => stores
604            .iter()
605            .map(|(p, s)| format!("  store  {}: {}\n", p.label(), s.describe()))
606            .collect(),
607    }
608}
609
610/// The store the *repository* wiring names: the `.gitignore` line and the
611/// three git hook blocks.
612///
613/// `--auto` is safe here on its own terms, whatever platform asked for the
614/// install: git runs a hook with the working tree it acted on as the working
615/// directory, so the store resolves from that tree with no assistant, and no
616/// `$CLAUDE_PROJECT_DIR`, involved. It is written when any installed platform
617/// writes it, so the git hooks and the assistant's own config agree — and
618/// pinned otherwise, so a Cursor-only install is one store spelled one way.
619fn repo_store_ref(
620    project_root: &Path,
621    home: &Path,
622    scope: Scope,
623    db: Option<&Path>,
624    platforms: &[Platform],
625) -> StoreRef {
626    let runtime_ok = platforms.iter().any(resolves_at_runtime);
627    store_ref(project_root, home, scope, db, runtime_ok)
628}
629
630/// How one platform will name the store in everything written for it.
631///
632/// `--auto` is written only where it provably resolves to the same directory:
633/// the default store, in project scope, inside a git checkout, for a host that
634/// resolves it (`runtime_ok`, from [`resolves_at_runtime`]). The checkout
635/// condition is what makes the fallback safe — a hook that never receives
636/// `$CLAUDE_PROJECT_DIR` still finds the store by walking up to the working
637/// tree root, and there is no working tree root to find without it. Everywhere
638/// else the path is pinned, because a wrong `--auto` would silently build a
639/// second store under the home directory and report nothing.
640fn store_ref(
641    project_root: &Path,
642    home: &Path,
643    scope: Scope,
644    db: Option<&Path>,
645    runtime_ok: bool,
646) -> StoreRef {
647    let default = default_db(scope, project_root, home);
648    let Some(pinned) = db.map(|d| absolutise(d, project_root)) else {
649        if runtime_ok && scope == Scope::Project && project_root.join(".git").exists() {
650            return StoreRef::auto(default);
651        }
652        // Pinned, but `--auto` would still name this same directory in project
653        // scope — so a `--auto` entry an earlier build wrote here is this
654        // install's to rewrite rather than a conflicting one to refuse.
655        let pinned = StoreRef::pinned(default);
656        return if scope == Scope::Project {
657            pinned.also_auto()
658        } else {
659            pinned
660        };
661    };
662    // A `--db` naming the very store `--auto` resolves to is still pinned —
663    // the user asked for a path — but an `--auto` hook from an earlier install
664    // points at the same place and is this install's to replace.
665    let auto_here = default_db(Scope::Project, project_root, home);
666    if pinned == auto_here {
667        return StoreRef::pinned(pinned).also_auto();
668    }
669    StoreRef::pinned(pinned)
670}
671
672/// Resolve the scope, and say whether it was inferred.
673///
674/// A git checkout is a project: its store belongs beside it, is ignored by the
675/// repository, and its hooks fire on its commits. Anywhere else there is no
676/// project to scope to, so the install is the user's.
677pub(crate) fn resolve_scope(project_root: &Path, requested: Option<Scope>) -> (Scope, bool) {
678    match requested {
679        Some(s) => (s, false),
680        None if project_root.join(".git").exists() => (Scope::Project, true),
681        None => (Scope::User, true),
682    }
683}
684
685// ---------------------------------------------------------------------------
686// External programs
687// ---------------------------------------------------------------------------
688
689/// The world outside the two directories install is given: the programs it
690/// shells out to (`codex`, `npx`) and how long it will wait for them.
691///
692/// Carried explicitly rather than read from the process environment at the
693/// point of use, so a test can point PATH at a directory of stand-ins without
694/// mutating global state that its neighbours share.
695#[derive(Debug, Clone)]
696pub struct Externals {
697    /// PATH used to resolve external programs. `None` resolves nothing.
698    pub path: Option<OsString>,
699    /// Budget for the pre-warm.
700    pub prewarm_timeout: Duration,
701}
702
703impl Externals {
704    /// The real process environment.
705    #[must_use]
706    pub fn from_env() -> Self {
707        Self::with_path(std::env::var_os("PATH"))
708    }
709
710    /// The same, with an explicit PATH.
711    #[must_use]
712    pub fn with_path(path: Option<OsString>) -> Self {
713        Self {
714            path,
715            prewarm_timeout: Duration::from_secs(PREWARM_TIMEOUT_SECS),
716        }
717    }
718
719    /// The first executable named `program` on this PATH.
720    pub(crate) fn which(&self, program: &str) -> Option<PathBuf> {
721        let path = self.path.as_ref()?;
722        std::env::split_paths(path)
723            .map(|dir| dir.join(program))
724            .find(|c| is_executable(c))
725    }
726}
727
728fn is_executable(path: &Path) -> bool {
729    let Ok(meta) = fs::metadata(path) else {
730        return false;
731    };
732    if !meta.is_file() {
733        return false;
734    }
735    #[cfg(unix)]
736    {
737        use std::os::unix::fs::PermissionsExt;
738        meta.permissions().mode() & 0o111 != 0
739    }
740    #[cfg(not(unix))]
741    {
742        true
743    }
744}
745
746/// Run `bin` to completion, returning its stderr (trimmed) on a non-zero exit.
747fn run_and_capture(bin: &Path, args: &[String]) -> Result<(), String> {
748    let out = std::process::Command::new(bin)
749        .args(args)
750        .output()
751        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
752    if out.status.success() {
753        return Ok(());
754    }
755    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
756    let detail = if stderr.is_empty() {
757        String::new()
758    } else {
759        format!(": {stderr}")
760    };
761    Err(format!(
762        "{} {} exited with {}{detail}",
763        bin.display(),
764        args.join(" "),
765        out.status
766    ))
767}
768
769/// Run `bin`, giving up after `timeout`. Output is discarded — only the exit
770/// status matters — so the child cannot block on a pipe nobody drains.
771fn run_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<(), String> {
772    let mut child = std::process::Command::new(bin)
773        .args(args)
774        .stdin(std::process::Stdio::null())
775        .stdout(std::process::Stdio::null())
776        .stderr(std::process::Stdio::null())
777        .spawn()
778        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
779    let deadline = Instant::now() + timeout;
780    loop {
781        match child.try_wait() {
782            Ok(Some(status)) if status.success() => return Ok(()),
783            Ok(Some(status)) => return Err(format!("exited with {status}")),
784            Ok(None) => {}
785            Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
786        }
787        if Instant::now() >= deadline {
788            let _ = child.kill();
789            let _ = child.wait();
790            return Err(format!("timed out after {}s", timeout.as_secs()));
791        }
792        std::thread::sleep(Duration::from_millis(25));
793    }
794}
795
796/// Run `bin`, capturing stdout and giving up after `timeout`.
797///
798/// The pipe is drained on another thread so a child that writes more than a
799/// pipe buffer cannot deadlock against the timeout loop watching it.
800fn capture_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<String, String> {
801    let mut child = std::process::Command::new(bin)
802        .args(args)
803        .stdin(std::process::Stdio::null())
804        .stdout(std::process::Stdio::piped())
805        .stderr(std::process::Stdio::null())
806        .spawn()
807        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
808    let mut stdout = child.stdout.take().expect("stdout is piped");
809    let (tx, rx) = std::sync::mpsc::channel::<String>();
810    std::thread::spawn(move || {
811        use std::io::Read as _;
812        let mut out = String::new();
813        let _ = stdout.read_to_string(&mut out);
814        let _ = tx.send(out);
815    });
816    let deadline = Instant::now() + timeout;
817    loop {
818        match child.try_wait() {
819            Ok(Some(status)) if status.success() => {
820                return Ok(rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default());
821            }
822            Ok(Some(status)) => return Err(format!("exited with {status}")),
823            Ok(None) if Instant::now() >= deadline => {
824                let _ = child.kill();
825                let _ = child.wait();
826                return Err(format!("timed out after {}s", timeout.as_secs()));
827            }
828            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
829            Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
830        }
831    }
832}
833
834/// Ask the published package where something of its own is, once.
835///
836/// `flag` is [`PRINT_BINARY_FLAG`] or [`PRINT_LAUNCHER_FLAG`]; the package
837/// answers with an absolute path and exits. Writing that path into the hooks
838/// takes the whole npx resolution — cache check, version resolve, an extra Node
839/// process — off the per-prompt and per-edit path.
840///
841/// Chosen over `npm root -g` (only finds a *global* install, which the npx
842/// route never makes) and over `npm exec --offline` (still pays npm's own
843/// startup on every call). Asking the package itself is the one answer that is
844/// correct for however it was installed, and it is the same fetch the pre-warm
845/// already ran, so it costs an install nothing extra.
846///
847/// Every failure is recoverable: the caller falls back a step.
848fn ask_package(version: &str, flag: &str, ext: &Externals) -> Result<PathBuf, String> {
849    let npx = ext
850        .which("npx")
851        .ok_or_else(|| "npx is not on PATH".to_string())?;
852    let args = vec![
853        "-y".to_string(),
854        format!("{NPM_PACKAGE}@{version}"),
855        flag.to_string(),
856    ];
857    let out = capture_with_timeout(&npx, &args, ext.prewarm_timeout)?;
858    // The last non-blank line: npm is entitled to print notices before it.
859    let path = out
860        .lines()
861        .map(str::trim)
862        .rfind(|l| !l.is_empty())
863        .ok_or_else(|| format!("{NPM_PACKAGE}@{version} {flag} printed nothing"))?;
864    let path = PathBuf::from(path);
865    if !path.is_absolute() {
866        return Err(format!("{} is not an absolute path", path.display()));
867    }
868    if !path.is_file() {
869        return Err(format!("{} does not exist", path.display()));
870    }
871    Ok(path)
872}
873
874/// Turn an `npx` command into a resolved, directly-runnable one, so the hooks
875/// it writes do not spawn `npx` on every invocation.
876///
877/// Three rungs, best first:
878///
879/// 1. **The native binary** (`--print-binary`). What every other form ends up
880///    running anyway, reached without a Node startup in front of it.
881/// 2. **`node <launcher>`** (`--print-launcher`). For a package whose vendored
882///    binary was never fetched, and only when `node` is on PATH to run it.
883/// 3. **`npx`**, unchanged, with a warning saying the hooks will be slow.
884///
885/// Returns the command to write; the one-line warning for the summary when
886/// every rung failed; and whether the package was fetched on the way, which
887/// tells the caller the separate pre-warm has nothing left to do. Anything
888/// other than the `npx` form is already a direct path and is handed back
889/// untouched.
890fn resolve_fast_command(cmd: &McpCommand, ext: &Externals) -> (McpCommand, Option<String>, bool) {
891    let McpCommand::Npx { version } = cmd else {
892        return (cmd.clone(), None, false);
893    };
894    // Asking the package anything downloads it first, so one question warms
895    // the cache exactly as the pre-warm's `--version` would. If `npx` is not
896    // there to ask, nothing was fetched and the pre-warm's own report of that
897    // is worth having.
898    let fetched = ext.which("npx").is_some();
899    let binary_err = match ask_package(version, PRINT_BINARY_FLAG, ext) {
900        Ok(binary) => {
901            return (
902                McpCommand::NativeBinary {
903                    binary,
904                    version: version.clone(),
905                },
906                None,
907                fetched,
908            )
909        }
910        Err(e) => e,
911    };
912    // No binary. The launcher is the same package one Node startup away, and
913    // it is only worth writing if `node` is there to run it.
914    if ext.which(NODE_BIN).is_some() {
915        if let Ok(launcher) = ask_package(version, PRINT_LAUNCHER_FLAG, ext) {
916            return (
917                McpCommand::NodeLauncher {
918                    launcher,
919                    version: version.clone(),
920                },
921                None,
922                fetched,
923            );
924        }
925    }
926    (
927        cmd.clone(),
928        Some(format!(
929            "warning: could not resolve {NPM_PACKAGE}@{version} to a path ({binary_err}) — \
930             the hooks will spawn npx on every prompt and every edit"
931        )),
932        fetched,
933    )
934}
935
936// ---------------------------------------------------------------------------
937// Manifest — tracks everything install wrote so uninstall can undo it.
938// ---------------------------------------------------------------------------
939
940#[derive(Serialize, Deserialize, Default, Debug)]
941struct Manifest {
942    /// Files created by this install (absolute paths).
943    files: Vec<PathBuf>,
944    /// MCP JSON keys added by this install.
945    mcp_keys: Vec<ManagedMcpKey>,
946    /// Hook entries added to a settings.json by this install.
947    #[serde(default)]
948    hooks: Vec<ManagedHook>,
949    /// Git hook files this install put its block into.
950    #[serde(default)]
951    git_hooks: Vec<PathBuf>,
952    /// Single lines added to a file the user owns (the `.gitignore` entry).
953    #[serde(default)]
954    gitignore: Vec<ManagedLine>,
955    /// Whether a Codex MCP server was registered through the `codex` CLI.
956    #[serde(default)]
957    codex: bool,
958    /// Whether `disable` has turned this install off. The tracking fields
959    /// above (`mcp_keys`, `hooks`, `git_hooks`, `codex`) still describe what
960    /// the install owns even while disabled — `disable` does not clear them,
961    /// it only takes the config off disk and sets this flag — so `uninstall`
962    /// needs no disabled-aware branch of its own: every removal it attempts
963    /// is already a no-op for whatever `disable` already removed.
964    #[serde(default)]
965    disabled: bool,
966    /// The exact `mcpServers.mushroomdb` entry `disable` removed from each
967    /// file, captured byte-for-byte before the removal. `enable` reads the
968    /// store argument back out of these (see [`store_from_arg`]) rather than
969    /// replaying the entry itself — the command it writes is re-resolved
970    /// fresh, since the published package may have moved since `disable` ran.
971    #[serde(default)]
972    stashed_mcp: Vec<StashedMcpEntry>,
973    /// The command `install` (or the last successful `enable`) was asked to
974    /// write, *before* [`resolve_fast_command`] turned an `Npx` request into a
975    /// concrete native-binary or launcher path. `enable` reads this back so it
976    /// can tell an explicit `--command` pin apart from an `npx` resolution
977    /// that happened to land on the same shape of value (an absolute path) —
978    /// something the resolved JSON entry alone cannot distinguish. `None` only
979    /// for a manifest written before this field existed.
980    #[serde(default)]
981    requested_cmd: Option<StoredCommand>,
982    /// The door this install opened. `doctor` reads it so it does not report a
983    /// missing MCP entry as a failure on an install that deliberately has
984    /// none, and `enable` reads it so a re-enable rebuilds the same shape of
985    /// install rather than silently adding a server. Defaults to `Both`, which
986    /// is what every manifest written before this field existed described.
987    #[serde(default)]
988    delivery: Delivery,
989    /// Whether this install asked for the experimental grep redirect. The
990    /// hook itself is listed in `hooks` like any other, so `uninstall` and
991    /// `disable` need nothing from this field; `enable` reads it to rebuild
992    /// the same install that was disabled, and `doctor` to know whether a
993    /// missing `PreToolUse` hook is a fault or the default. Defaults to
994    /// false, which is what every manifest written before it existed means.
995    #[serde(default)]
996    intercept_grep: bool,
997}
998
999impl Manifest {
1000    /// Drop entries no version of this install may act on as written. See
1001    /// [`load_manifest`] for why a `.gitignore` in `files` is one of them.
1002    fn sanitised(mut self) -> Self {
1003        self.files
1004            .retain(|f| f.file_name() != Some(OsStr::new(".gitignore")));
1005        self
1006    }
1007
1008    fn is_empty(&self) -> bool {
1009        self.files.is_empty()
1010            && self.mcp_keys.is_empty()
1011            && self.hooks.is_empty()
1012            && self.git_hooks.is_empty()
1013            && self.gitignore.is_empty()
1014            && !self.codex
1015    }
1016}
1017
1018/// One `mcpServers.<server>` entry [`run_disable_with`] took out of a config
1019/// file, kept whole so [`entry_db`] can still read the store argument back out
1020/// of it later.
1021#[derive(Serialize, Deserialize, Debug, Clone)]
1022struct StashedMcpEntry {
1023    /// The JSON file the entry was removed from (absolute path).
1024    file: PathBuf,
1025    /// The key inside `mcpServers`.
1026    server: String,
1027    /// The entry itself, exactly as it read before removal.
1028    entry: serde_json::Value,
1029}
1030
1031/// The three requestable shapes of [`McpCommand`] — the ones a caller can ask
1032/// for, as opposed to [`McpCommand::NativeBinary`]/[`McpCommand::NodeLauncher`],
1033/// which only [`resolve_fast_command`] ever produces, by resolving an `Npx`
1034/// request. Serializable so a manifest can carry it across a `disable`/`enable`
1035/// round trip.
1036#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1037enum StoredCommand {
1038    Npx { version: String },
1039    Explicit(PathBuf),
1040    OnPath,
1041}
1042
1043impl StoredCommand {
1044    /// The request behind `cmd`, or `None` for a value only resolution
1045    /// produces — there is nothing to remember about those beyond the `Npx`
1046    /// request that led to them, which is captured before resolution runs.
1047    fn from_mcp(cmd: &McpCommand) -> Option<Self> {
1048        match cmd {
1049            McpCommand::Npx { version } => Some(StoredCommand::Npx {
1050                version: version.clone(),
1051            }),
1052            McpCommand::Explicit(p) => Some(StoredCommand::Explicit(p.clone())),
1053            McpCommand::OnPath => Some(StoredCommand::OnPath),
1054            McpCommand::NativeBinary { .. } | McpCommand::NodeLauncher { .. } => None,
1055        }
1056    }
1057
1058    fn into_mcp(self) -> McpCommand {
1059        match self {
1060            StoredCommand::Npx { version } => McpCommand::Npx { version },
1061            StoredCommand::Explicit(p) => McpCommand::Explicit(p),
1062            StoredCommand::OnPath => McpCommand::OnPath,
1063        }
1064    }
1065}
1066
1067#[derive(Serialize, Deserialize, Debug, Clone)]
1068struct ManagedMcpKey {
1069    /// The JSON file the key was added to (absolute path).
1070    file: PathBuf,
1071    /// The key inside `mcpServers`.
1072    server: String,
1073}
1074
1075#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1076struct ManagedHook {
1077    /// The settings.json file the hook was added to (absolute path).
1078    file: PathBuf,
1079    /// The hook event name (e.g. `UserPromptSubmit`).
1080    event: String,
1081    /// The exact command string that was added.
1082    command: String,
1083}
1084
1085/// One line this install appended to a text file the user owns.
1086#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1087struct ManagedLine {
1088    /// The file the line was added to (absolute path).
1089    file: PathBuf,
1090    /// The exact line, without its newline.
1091    line: String,
1092    /// Whether the file itself did not exist before this install. Only such a
1093    /// file may be deleted on uninstall, and only if nothing is left in it.
1094    #[serde(default)]
1095    created: bool,
1096}
1097
1098/// Claude Code hook event this install wires: fires before each prompt is
1099/// sent, so the recall digest lands as context ahead of the user's turn.
1100pub(crate) const HOOK_EVENT: &str = "UserPromptSubmit";
1101/// Kept short: the hook must never noticeably slow a prompt.
1102const HOOK_TIMEOUT_SECS: u64 = 5;
1103
1104/// The second hook event: fires after a tool call, so an edit reaches the
1105/// graph while the assistant is still working rather than at the next commit.
1106pub(crate) const TOUCH_EVENT: &str = "PostToolUse";
1107/// The tools that change a file on disk. Anything else — a read, a search, a
1108/// shell command — leaves the working tree as the graph already has it.
1109const TOUCH_MATCHER: &str = "Edit|Write|MultiEdit";
1110/// Longer than the prompt hook's: re-extracting a file costs more than reading
1111/// a digest, and nothing is waiting on the answer. The run is `async`, so this
1112/// bounds a background process rather than the assistant's turn.
1113const TOUCH_TIMEOUT_SECS: u64 = 30;
1114
1115/// The third hook event: fires once as a session opens, so the assistant knows
1116/// what the repository is before it is asked anything.
1117///
1118/// No matcher — a session start is not a tool call — and not `async`: the
1119/// point of the brief is to be there for the first turn, and the host caches
1120/// its output for the rest of the session, so it is read once and paid for
1121/// once. It shares the prompt hook's [`HOOK_TIMEOUT_SECS`] budget.
1122pub(crate) const BRIEF_EVENT: &str = "SessionStart";
1123
1124/// The optional fourth hook event: fires *before* a tool call, so a search the
1125/// graph answers exactly can be turned into an `explore` before it runs.
1126///
1127/// Written only for `install --intercept-grep` (see
1128/// [`InstallOpts::intercept_grep`]). It is the one hook of ours that can block
1129/// a tool call — Claude Code reads exit 2 as "refuse this call, and give the
1130/// model what stderr said" — so it is opt-in, awaited rather than `async`
1131/// (nothing else could block the call), and on the prompt hook's short
1132/// [`HOOK_TIMEOUT_SECS`] budget.
1133pub(crate) const INTERCEPT_EVENT: &str = "PreToolUse";
1134
1135/// The one tool it fires for. A `Read`, an `Edit` or a `Bash` is never
1136/// redirected: the graph has no better answer to those.
1137const INTERCEPT_MATCHER: &str = "Grep";
1138
1139/// Single-quote `s` for embedding in a POSIX shell command line, escaping
1140/// embedded single quotes as `'\''`. Claude Code runs a `type: "command"`
1141/// hook through a shell, so an unquoted path containing whitespace or shell
1142/// metacharacters is word-split and the hook silently receives the wrong
1143/// arguments — quoting keeps the command exact.
1144pub(crate) fn sh_quote(s: &str) -> String {
1145    format!("'{}'", s.replace('\'', r"'\''"))
1146}
1147
1148/// The exact command string written into the hook entry. Both halves arrive
1149/// already quoted where quoting is needed.
1150fn recall_hook_command(shell: &str, store: &StoreRef) -> String {
1151    format!("{shell} recall {}", store.shell_arg())
1152}
1153
1154/// The exact command string written into the post-edit hook entry. `touch` in
1155/// hook mode prints nothing and exits 0 whatever it is handed.
1156fn touch_hook_command(shell: &str, store: &StoreRef) -> String {
1157    format!("{shell} touch {}", store.shell_arg())
1158}
1159
1160/// The exact command string written into the session-start hook entry.
1161fn brief_hook_command(shell: &str, store: &StoreRef) -> String {
1162    format!("{shell} brief {}", store.shell_arg())
1163}
1164
1165/// The exact command string written into the grep-redirect hook entry.
1166fn intercept_hook_command(shell: &str, store: &StoreRef) -> String {
1167    format!("{shell} intercept {}", store.shell_arg())
1168}
1169
1170/// One `hooks.<event>` array entry in Claude Code's settings.json shape.
1171fn hook_entry(command: &str) -> serde_json::Value {
1172    serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
1173}
1174
1175/// The `PostToolUse` entry: matched to the file-editing tools, and `async` so
1176/// the assistant's tool call returns without waiting for the re-extraction.
1177fn touch_hook_entry(command: &str) -> serde_json::Value {
1178    serde_json::json!({
1179        "matcher": TOUCH_MATCHER,
1180        "hooks": [ {
1181            "type": "command",
1182            "command": command,
1183            "timeout": TOUCH_TIMEOUT_SECS,
1184            "async": true
1185        } ]
1186    })
1187}
1188
1189/// The `PreToolUse` entry: matched to `Grep` alone, and awaited — an `async`
1190/// hook has already let the tool call through by the time it decides.
1191fn intercept_hook_entry(command: &str) -> serde_json::Value {
1192    serde_json::json!({
1193        "matcher": INTERCEPT_MATCHER,
1194        "hooks": [ {
1195            "type": "command",
1196            "command": command,
1197            "timeout": HOOK_TIMEOUT_SECS
1198        } ]
1199    })
1200}
1201
1202/// True if any hook group under `event` contains a command hook equal to `command`.
1203pub(crate) fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
1204    root["hooks"][event]
1205        .as_array()
1206        .map(|groups| {
1207            groups.iter().any(|g| {
1208                g["hooks"]
1209                    .as_array()
1210                    .map(|hs| hs.iter().any(|h| h["command"] == command))
1211                    .unwrap_or(false)
1212            })
1213        })
1214        .unwrap_or(false)
1215}
1216
1217/// Add one hook to `settings_file` (created if absent). Idempotent: no-op if
1218/// `command` is already present under `event`. Every other key in the file —
1219/// including other hook events and groups — is preserved. Errors out (no
1220/// write) rather than overwriting if `hooks` or `hooks.<event>` already exists
1221/// with an unexpected JSON type, or if the file's top level is not a JSON
1222/// object.
1223///
1224/// `entry` is the group to append, built by the caller: the two events this
1225/// install wires want different shapes, and only the caller knows which.
1226fn merge_hook_entry(
1227    settings_file: &Path,
1228    event: &str,
1229    command: &str,
1230    entry: serde_json::Value,
1231    manifest: &mut Manifest,
1232) -> Result<(), CliError> {
1233    let mut root: serde_json::Value = if settings_file.exists() {
1234        let raw = fs::read_to_string(settings_file)
1235            .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
1236        serde_json::from_str(&raw)
1237            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
1238    } else {
1239        serde_json::json!({})
1240    };
1241
1242    if !root.is_object() {
1243        return Err(CliError(format!(
1244            "{} is not a JSON object at its top level — refusing to add a hook",
1245            settings_file.display()
1246        )));
1247    }
1248
1249    if settings_has_hook(&root, event, command) {
1250        return Ok(());
1251    }
1252
1253    // Validate the shapes we are about to write into before touching
1254    // anything: a wrong-shaped `hooks` or `hooks.<event>` value belongs to
1255    // the user (or another tool) and must never be silently overwritten.
1256    match root.get("hooks") {
1257        None => root["hooks"] = serde_json::json!({}),
1258        Some(v) if v.is_object() => {}
1259        Some(_) => {
1260            return Err(CliError(format!(
1261                "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
1262                settings_file.display()
1263            )));
1264        }
1265    }
1266    match root["hooks"].get(event) {
1267        None => root["hooks"][event] = serde_json::json!([]),
1268        Some(v) if v.is_array() => {}
1269        Some(_) => {
1270            return Err(CliError(format!(
1271                "{}: \"hooks.{event}\" is not a JSON array — refusing to overwrite it",
1272                settings_file.display()
1273            )));
1274        }
1275    }
1276    root["hooks"][event].as_array_mut().unwrap().push(entry);
1277
1278    let parent = settings_file.parent().unwrap_or(Path::new("."));
1279    fs::create_dir_all(parent)
1280        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
1281    let json = serde_json::to_string_pretty(&root)
1282        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
1283    fs::write(settings_file, json)
1284        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
1285
1286    manifest.hooks.push(ManagedHook {
1287        file: settings_file.to_path_buf(),
1288        event: event.into(),
1289        command: command.into(),
1290    });
1291    Ok(())
1292}
1293
1294/// Remove exactly the hook groups whose only command is `command`; drop the
1295/// command from mixed groups; leave everything else semantically unchanged.
1296/// Returns whether the file was rewritten — that is, whether the hook was
1297/// there to remove at all.
1298fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<bool, CliError> {
1299    drop_hooks(settings_file, event, |c| c == command)
1300}
1301
1302/// Whether `command` is one of our hook bodies for `store`, whatever binary it
1303/// names.
1304///
1305/// The command prefix is exactly what changes between versions — 0.5.x wrote
1306/// the absolute path of a copied binary, 0.6.0 an `npx` pin, 0.6.1 a resolved
1307/// `node <launcher>`, a developer's `--command` a build path — so identity is
1308/// the tail: the subcommand and the store this install is wiring. Both
1309/// spellings of that store count, since `--auto` replaced a written path in
1310/// 0.6.1 and a hook left behind in the other spelling would run alongside the
1311/// new one. A hook naming a *different* store belongs to a different install
1312/// and is not ours to touch.
1313pub(crate) fn is_our_hook_command(command: &str, sub: &str, store: &StoreRef) -> bool {
1314    store
1315        .hook_tails(sub)
1316        .iter()
1317        .any(|tail| command.ends_with(tail))
1318}
1319
1320/// The same identity test for a line that does not *end* with the invocation:
1321/// a git hook block backgrounds it and redirects its output, so the store
1322/// argument sits in the middle of the line rather than at the end of it.
1323pub(crate) fn line_runs_for_store(line: &str, sub: &str, store: &StoreRef) -> bool {
1324    store.hook_tails(sub).iter().any(|tail| line.contains(tail))
1325}
1326
1327/// Take out every hook of ours for `event` that is not the one we are about
1328/// to write. Returns whether anything was removed.
1329///
1330/// Without this an upgrade appends: `merge_hook_entry` matches on the exact
1331/// command string, so a 0.5.x entry naming `~/.mushroomdb/bin/mushroomdb` is
1332/// not recognised, survives, and keeps running alongside the new one — two
1333/// recall digests injected on every prompt.
1334fn remove_stale_hooks(
1335    settings_file: &Path,
1336    event: &str,
1337    sub: &str,
1338    store: &StoreRef,
1339    desired: &str,
1340) -> Result<bool, CliError> {
1341    drop_hooks(settings_file, event, |c| {
1342        c != desired && is_our_hook_command(c, sub, store)
1343    })
1344}
1345
1346/// Drop every hook under `event` whose command satisfies `drop_it`, pruning
1347/// groups that end up empty. Returns whether the file was rewritten.
1348///
1349/// Reads `hooks.<event>` through immutable accessors first, so a settings
1350/// file where the user removed the `hooks` key (or `<event>`, or shaped
1351/// either as something other than an object/array) is left byte-for-byte
1352/// untouched rather than having a stray `null` written back in. Every other
1353/// key is preserved, though the file is re-serialized (comments are not
1354/// supported since `serde_json` is strict JSON).
1355fn drop_hooks(
1356    settings_file: &Path,
1357    event: &str,
1358    drop_it: impl Fn(&str) -> bool,
1359) -> Result<bool, CliError> {
1360    if !settings_file.exists() {
1361        return Ok(false);
1362    }
1363    let raw = fs::read_to_string(settings_file)
1364        .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
1365    let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
1366        CliError(format!(
1367            "corrupt settings json at {}: {e}",
1368            settings_file.display()
1369        ))
1370    })?;
1371
1372    let Some(mut groups) = root
1373        .get("hooks")
1374        .and_then(|h| h.get(event))
1375        .and_then(|g| g.as_array())
1376        .cloned()
1377    else {
1378        // No matching (or well-shaped) event array — nothing of ours to
1379        // remove; leave the file exactly as it is, no write at all.
1380        return Ok(false);
1381    };
1382
1383    for g in groups.iter_mut() {
1384        if let Some(hs) = g["hooks"].as_array_mut() {
1385            hs.retain(|h| !h["command"].as_str().is_some_and(&drop_it));
1386        }
1387    }
1388    groups.retain(|g| {
1389        g["hooks"]
1390            .as_array()
1391            .map(|hs| !hs.is_empty())
1392            .unwrap_or(true)
1393    });
1394
1395    let before = root.clone();
1396    if groups.is_empty() {
1397        root["hooks"].as_object_mut().unwrap().remove(event);
1398    } else {
1399        root["hooks"][event] = serde_json::Value::Array(groups);
1400    }
1401    if root == before {
1402        // The event array held none of our commands, so there is nothing to
1403        // remove. Writing anyway would re-serialize a file we do not own —
1404        // `serde_json` is built without `preserve_order`, so the user's key
1405        // order and indentation would be rewritten for no reason.
1406        return Ok(false);
1407    }
1408
1409    let json = serde_json::to_string_pretty(&root)
1410        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
1411    fs::write(settings_file, json)
1412        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
1413    Ok(true)
1414}
1415
1416// ---------------------------------------------------------------------------
1417// Public entry points
1418// ---------------------------------------------------------------------------
1419
1420/// Everything the write phase needs, gathered once so the per-step functions
1421/// stay readable.
1422struct Ctx<'a> {
1423    project_root: &'a Path,
1424    home: &'a Path,
1425    scope: Scope,
1426    /// The store the repository wiring names — the `.gitignore` line and the
1427    /// git hook blocks. Each platform's own config gets its own [`StoreRef`],
1428    /// passed to the per-platform writers, because only Claude Code can
1429    /// resolve `--auto`; see [`repo_store_ref`] and [`resolves_at_runtime`].
1430    repo_store: &'a StoreRef,
1431    cmd: &'a McpCommand,
1432    ext: &'a Externals,
1433    git_hooks: bool,
1434    prewarm: bool,
1435    /// Which door to open — see [`Delivery`]. Read by [`install_claude_code`],
1436    /// which is the only writer it changes.
1437    delivery: Delivery,
1438    /// Whether to write the experimental grep redirect. Claude Code only —
1439    /// it is a Claude Code hook.
1440    intercept_grep: bool,
1441}
1442
1443/// Anchor a user-supplied path to `base` when it is relative, and drop any
1444/// `./` segments.
1445///
1446/// A relative `--command` or `--db` is convenient to type and wrong to store:
1447/// the assistant spawns the MCP server, and the hooks and git hooks run, from
1448/// whatever directory those processes happen to be in, not the one the install
1449/// was typed in. Nothing is canonicalized — resolving symlinks would rewrite
1450/// a path the user chose deliberately.
1451fn absolutise(path: &Path, base: &Path) -> PathBuf {
1452    let joined = if path.is_absolute() {
1453        path.to_path_buf()
1454    } else {
1455        base.join(path)
1456    };
1457    let mut out = PathBuf::new();
1458    for c in joined.components() {
1459        match c {
1460            std::path::Component::CurDir => {}
1461            other => out.push(other),
1462        }
1463    }
1464    out
1465}
1466
1467/// Whether `p` is a program name to be looked up on PATH rather than a file to
1468/// be anchored: one plain component, no separator, no `.` or `..`.
1469///
1470/// `--command mushroomdb` means "whatever `mushroomdb` PATH resolves to" and
1471/// stays that way in both forms we write — an MCP host resolves a bare
1472/// `command` on PATH, and quoting a bare name in a shell does not defeat the
1473/// lookup either. Anchoring it would invent `<cwd>/mushroomdb`, a file that
1474/// need not exist, and the install would report success over a server that
1475/// cannot spawn.
1476fn is_bare_program_name(p: &Path) -> bool {
1477    let mut components = p.components();
1478    matches!(
1479        (components.next(), components.next()),
1480        (Some(std::path::Component::Normal(_)), None)
1481    )
1482}
1483
1484/// Anchor a `--command` unless it is a bare program name.
1485fn absolutise_command(path: &Path, base: &Path) -> PathBuf {
1486    if is_bare_program_name(path) {
1487        path.to_path_buf()
1488    } else {
1489        absolutise(path, base)
1490    }
1491}
1492
1493/// Install the /mushroom skill and MCP server entry for the resolved platforms.
1494///
1495/// `project_root` is the directory where project-scope config files live
1496/// (`.mcp.json`, `.claude/`, `.cursor/`). `home` is the user HOME directory.
1497/// Tests pass temp directories for both; main.rs passes real values.
1498pub fn run_install(
1499    project_root: &Path,
1500    home: &Path,
1501    opts: &InstallOpts,
1502) -> Result<String, CliError> {
1503    run_install_with(
1504        project_root,
1505        home,
1506        opts,
1507        &detect_mcp_command(opts.command.as_deref()),
1508        &Externals::from_env(),
1509    )
1510}
1511
1512/// Like [`run_install`], but with the server command and the external
1513/// environment supplied by the caller instead of detected. Tests use this to
1514/// stay deterministic and offline; `run_install` is the real-environment
1515/// wrapper.
1516pub fn run_install_with(
1517    project_root: &Path,
1518    home: &Path,
1519    opts: &InstallOpts,
1520    cmd: &McpCommand,
1521    ext: &Externals,
1522) -> Result<String, CliError> {
1523    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1524    // Whatever the caller handed us, what gets written resolves from anywhere:
1525    // an absolute path, or a name PATH answers for.
1526    let cmd = match cmd {
1527        McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
1528        other => other.clone(),
1529    };
1530    // What was actually asked for, before resolution turns an `Npx` request
1531    // into a concrete path — `enable` reads this back later to tell an
1532    // explicit `--command` pin apart from a resolved `npx` path, which the
1533    // written JSON entry alone cannot distinguish (both are absolute paths).
1534    let requested_cmd = StoredCommand::from_mcp(&cmd);
1535    // Resolve the published package to a concrete launcher once, here, so no
1536    // hook has to. Skipped by `--no-prewarm`, which is the flag for "do not
1537    // reach the network during this install"; the `npx` form still works, it
1538    // is just slower on every invocation.
1539    let (cmd, launcher_note, package_fetched) = if opts.prewarm {
1540        resolve_fast_command(&cmd, ext)
1541    } else {
1542        (cmd, None, false)
1543    };
1544    let cmd = &cmd;
1545
1546    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1547    let platforms = expand_platform(&resolved);
1548
1549    // Each platform names the store in its own terms: only Claude Code can be
1550    // relied on to resolve `--auto`. The repository wiring gets its own, since
1551    // git resolves it without any assistant.
1552    let stores = platform_stores(project_root, home, scope, opts.db.as_deref(), &platforms);
1553    let repo_store = repo_store_ref(project_root, home, scope, opts.db.as_deref(), &platforms);
1554
1555    // Check for anything that would make this install fail halfway before
1556    // writing a single byte.
1557    for (plat, store) in &stores {
1558        preflight_check(project_root, home, plat, scope, store, ext)?;
1559    }
1560
1561    let ctx = Ctx {
1562        project_root,
1563        home,
1564        scope,
1565        repo_store: &repo_store,
1566        cmd,
1567        ext,
1568        git_hooks: opts.git_hooks,
1569        prewarm: opts.prewarm && !package_fetched,
1570        delivery: opts.delivery,
1571        intercept_grep: opts.intercept_grep,
1572    };
1573
1574    let manifest_path = manifest_path(project_root, home, scope, &platforms);
1575
1576    // Load the existing manifest so we can union it with what this run writes.
1577    // This covers partial-drift re-installs: if SKILL.md was edited but the MCP
1578    // entry is still intact, only the file is re-written this run; unioning
1579    // preserves the MCP key in the saved manifest so uninstall cleans it up too.
1580    let existing = load_manifest(&manifest_path);
1581    // `install` doubles as `enable` for a disabled install: it rewrites
1582    // everything `disable` took off disk the same as it would repair any
1583    // other drift, so the only extra step is clearing the flag once that
1584    // write lands, and saying so in the summary.
1585    let was_disabled = existing.disabled;
1586    // Turning the redirect off writes nothing new, so the manifest would
1587    // otherwise go on claiming a hook this run just removed.
1588    let intercept_changed = existing.intercept_grep != opts.intercept_grep;
1589
1590    let mut manifest = Manifest {
1591        requested_cmd,
1592        delivery: opts.delivery,
1593        intercept_grep: opts.intercept_grep,
1594        ..Manifest::default()
1595    };
1596    let mut notes: Vec<String> = Vec::new();
1597    notes.extend(launcher_note);
1598    if was_disabled {
1599        notes.push("this install was disabled — install re-enabled it".to_string());
1600    }
1601
1602    let outcome = write_everything(&ctx, &stores, &mut manifest, &mut notes);
1603    if let Err(e) = outcome {
1604        // Persist whatever was already written (an earlier platform's files,
1605        // a git hook) so uninstall can still clean up after a partial
1606        // failure. Best effort: the original error wins.
1607        if !manifest.is_empty() {
1608            let merged = union_manifests(load_manifest(&manifest_path), &manifest);
1609            let _ = write_manifest(&manifest_path, &merged);
1610        }
1611        return Err(e);
1612    }
1613
1614    let anything_written = !manifest.is_empty();
1615    if anything_written || was_disabled || intercept_changed {
1616        // Union this-run entries with the existing manifest (dedup by path/key).
1617        let mut merged = if anything_written {
1618            union_manifests(existing, &manifest)
1619        } else {
1620            existing
1621        };
1622        if was_disabled {
1623            merged.disabled = false;
1624            merged.stashed_mcp.clear();
1625        }
1626        // Re-installing as `cli` over an earlier install just took that
1627        // install's server entry off disk; the manifest must not go on
1628        // claiming a key that is no longer there.
1629        if !opts.delivery.wires_mcp() {
1630            merged.mcp_keys.retain(|k| has_our_server(&k.file));
1631        }
1632        // Same for the redirect: an install without the flag has just taken
1633        // the hook off disk, so the manifest must stop owning it.
1634        merged.intercept_grep = opts.intercept_grep;
1635        if !opts.intercept_grep {
1636            merged.hooks.retain(|h| h.event != INTERCEPT_EVENT);
1637        }
1638        write_manifest(&manifest_path, &merged)?;
1639    }
1640
1641    let labels: Vec<&str> = platforms.iter().map(Platform::label).collect();
1642    let mut out = format!("mushroomdb installed ({})\n", labels.join(", "));
1643    out.push_str(&format!(
1644        "  scope  {}{}\n",
1645        scope.label(),
1646        if auto_scope { " (auto-detected)" } else { "" }
1647    ));
1648    for f in &manifest.files {
1649        out.push_str(&format!("  wrote  {}\n", f.display()));
1650    }
1651    for k in &manifest.mcp_keys {
1652        out.push_str(&format!(
1653            "  added  mcpServers.{} in {}\n",
1654            k.server,
1655            k.file.display()
1656        ));
1657    }
1658    for h in &manifest.hooks {
1659        out.push_str(&format!(
1660            "  added  {} hook in {}\n",
1661            h.event,
1662            h.file.display()
1663        ));
1664    }
1665    for g in &manifest.gitignore {
1666        out.push_str(&format!("  added  {} to {}\n", g.line, g.file.display()));
1667    }
1668    for h in &manifest.git_hooks {
1669        out.push_str(&format!("  added  git hook {}\n", h.display()));
1670    }
1671    if manifest.codex {
1672        out.push_str(&format!("  added  codex mcp server {SERVER_NAME}\n"));
1673    }
1674    if anything_written {
1675        out.push_str(&format!("  manifest  {}\n", manifest_path.display()));
1676        // The hooks and the skill both run this command; only an MCP install
1677        // calls it a server, so a `cli` install says what it actually wrote.
1678        let label = if opts.delivery.wires_mcp() {
1679            "mcp command"
1680        } else {
1681            "command"
1682        };
1683        out.push_str(&format!("  {label}  {}\n", cmd.shell()));
1684        out.push_str(&describe_stores(&stores));
1685    } else {
1686        out.push_str("  (already installed — no changes)\n");
1687    }
1688    for n in &notes {
1689        out.push_str(&format!("  {n}\n"));
1690    }
1691    out.push_str(&format!(
1692        "next: restart Claude Code in {}, then type /mushroom\n",
1693        project_root.display()
1694    ));
1695    Ok(out)
1696}
1697
1698/// The whole write phase, so a failure anywhere in it still leaves the caller
1699/// holding the partial manifest.
1700fn write_everything(
1701    ctx: &Ctx<'_>,
1702    stores: &[(Platform, StoreRef)],
1703    manifest: &mut Manifest,
1704    notes: &mut Vec<String>,
1705) -> Result<(), CliError> {
1706    let platforms: Vec<Platform> = stores.iter().map(|(p, _)| p.clone()).collect();
1707    if let Some(w) = scope_conflict_note(ctx, &platforms) {
1708        notes.push(w);
1709    }
1710
1711    for (plat, store) in stores {
1712        install_platform(ctx, plat, store, manifest, notes)?;
1713    }
1714
1715    // The repository-level wiring is shared by the platforms whose config
1716    // lives in the repository: the store is ignored by git and the graph is
1717    // re-synced after commits whichever of them reads it.
1718    //
1719    // A Codex-only install is excluded. It writes nothing else project-local
1720    // (Codex keeps its own config, and the manifest for it lives under the
1721    // home directory), so an ignore line and three git hooks recorded there
1722    // would be removed by a `uninstall --platform codex` out from under a
1723    // Claude Code install that shares the repository and never recorded them.
1724    let repo_wiring = platforms
1725        .iter()
1726        .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
1727    if ctx.scope == Scope::Project && repo_wiring {
1728        ensure_gitignore_line(ctx, manifest)?;
1729        if ctx.git_hooks {
1730            install_git_hooks(ctx, manifest)?;
1731        }
1732    }
1733
1734    if let Some(w) = prewarm(ctx) {
1735        notes.push(w);
1736    }
1737    Ok(())
1738}
1739
1740/// Find the manifest for an existing install, the way `uninstall`, `disable`
1741/// and `enable` all need to: an inferred scope that turns up nothing falls
1742/// back to the other one before giving up, and giving up is an error naming
1743/// `verb`.
1744///
1745/// An inferred scope is a guess, and guessing wrong here means telling
1746/// someone with a perfectly good user-scope install that they have nothing to
1747/// act on — 0.5.x had no scope detection, so every install made by it inside a
1748/// checkout is exactly that case. A scope the user stated is not
1749/// second-guessed. Returns the scope actually used, since a fallback changes it.
1750fn locate_manifest(
1751    project_root: &Path,
1752    home: &Path,
1753    scope: Scope,
1754    auto_scope: bool,
1755    platforms: &[Platform],
1756    verb: &str,
1757) -> Result<(Scope, PathBuf), CliError> {
1758    let mut scope = scope;
1759    let mut path = manifest_path(project_root, home, scope, platforms);
1760    if auto_scope && !path.exists() {
1761        let other = match scope {
1762            Scope::Project => Scope::User,
1763            Scope::User => Scope::Project,
1764        };
1765        let alt = manifest_path(project_root, home, other, platforms);
1766        if alt.exists() {
1767            scope = other;
1768            path = alt;
1769        }
1770    }
1771    if !path.exists() {
1772        return Err(CliError(format!(
1773            "no install manifest found at {} — nothing to {verb}",
1774            path.display()
1775        )));
1776    }
1777    Ok((scope, path))
1778}
1779
1780/// Uninstall: remove exactly what install wrote. Reads the manifest.
1781pub fn run_uninstall(
1782    project_root: &Path,
1783    home: &Path,
1784    opts: &InstallOpts,
1785) -> Result<String, CliError> {
1786    run_uninstall_with(project_root, home, opts, &Externals::from_env())
1787}
1788
1789/// Like [`run_uninstall`], with the external environment supplied by the
1790/// caller (Codex removal shells out to the `codex` CLI).
1791pub fn run_uninstall_with(
1792    project_root: &Path,
1793    home: &Path,
1794    opts: &InstallOpts,
1795    ext: &Externals,
1796) -> Result<String, CliError> {
1797    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1798    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1799    let platforms = expand_platform(&resolved);
1800
1801    let (scope, manifest_path) = locate_manifest(
1802        project_root,
1803        home,
1804        scope,
1805        auto_scope,
1806        &platforms,
1807        "uninstall",
1808    )?;
1809    let manifest = load_manifest(&manifest_path);
1810
1811    let mut removed = Vec::new();
1812
1813    // Remove MCP keys first (before files, in case files include .mcp.json).
1814    // Each line is printed only for a removal that happened: after an upgrade
1815    // the manifest also lists the commands the upgrade already replaced, and
1816    // claiming to have removed those would be a report of work not done.
1817    for key in &manifest.mcp_keys {
1818        if remove_mcp_key(&key.file, &key.server)? {
1819            removed.push(format!(
1820                "removed  mcpServers.{} from {}",
1821                key.server,
1822                key.file.display()
1823            ));
1824        }
1825    }
1826
1827    // Remove hooks (before files, same reasoning as MCP keys).
1828    for h in &manifest.hooks {
1829        if remove_hook_entry(&h.file, &h.event, &h.command)? {
1830            removed.push(format!(
1831                "removed  {} hook from {}",
1832                h.event,
1833                h.file.display()
1834            ));
1835        }
1836    }
1837
1838    // Git hooks: the marked region only, never the user's own lines.
1839    for h in &manifest.git_hooks {
1840        if remove_git_hook(h)? {
1841            removed.push(format!("removed  git hook block from {}", h.display()));
1842        }
1843    }
1844
1845    // The ignore line, exactly as it was written. A file that exists only
1846    // because install created it goes too — but only when our line was all it
1847    // ever held; anything the user added to it since is theirs to keep.
1848    for g in &manifest.gitignore {
1849        if remove_line(&g.file, &g.line)? {
1850            removed.push(format!("removed  {} from {}", g.line, g.file.display()));
1851        }
1852        if g.created && g.file.exists() && file_is_blank(&g.file) {
1853            fs::remove_file(&g.file)
1854                .map_err(|e| CliError(format!("cannot remove {}: {e}", g.file.display())))?;
1855            removed.push(format!("removed  {}", g.file.display()));
1856        }
1857    }
1858
1859    // Codex holds its own config; hand the removal back to its CLI. Not being
1860    // able to reach `codex` must not strand every other thing the manifest
1861    // lists.
1862    if manifest.codex {
1863        remove_codex(ext, &mut removed, "removed")?;
1864    }
1865
1866    // Remove files.
1867    for f in &manifest.files {
1868        if f.exists() {
1869            fs::remove_file(f)
1870                .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
1871            removed.push(format!("removed  {}", f.display()));
1872        }
1873    }
1874
1875    // Remove the manifest itself.
1876    if manifest_path.exists() {
1877        fs::remove_file(&manifest_path)
1878            .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
1879    }
1880
1881    let mut out = "mushroomdb uninstalled\n".to_string();
1882    out.push_str(&format!(
1883        "  scope  {}{}\n",
1884        scope.label(),
1885        if auto_scope { " (auto-detected)" } else { "" }
1886    ));
1887    for line in &removed {
1888        out.push_str(&format!("  {line}\n"));
1889    }
1890    Ok(out)
1891}
1892
1893// ---------------------------------------------------------------------------
1894// Enable / disable — turn an install off without removing it
1895// ---------------------------------------------------------------------------
1896//
1897// `disable` takes the dynamic, per-assistant config off disk — the MCP entry,
1898// the three Claude Code hooks, the three git hook blocks, the Codex
1899// registration — and leaves everything a person might have customised or that
1900// the store depends on: the skill/rules file, the store itself, the
1901// `.gitignore` line. `enable` puts the config back, re-derived from whatever
1902// `install` would choose right now rather than replayed byte-for-byte, so an
1903// upgrade of the published package between the two calls is picked up instead
1904// of pinned to a path that may no longer resolve.
1905//
1906// Neither command clears `mcp_keys` / `hooks` / `git_hooks` / `codex` on the
1907// manifest — those keep describing what the install *owns*, disabled or not —
1908// which is what lets `uninstall` work unmodified from a disabled install: it
1909// already treats every removal as a no-op when there is nothing left to
1910// remove.
1911
1912fn scope_dir(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
1913    match scope {
1914        Scope::Project => project_root.to_path_buf(),
1915        Scope::User => home.to_path_buf(),
1916    }
1917}
1918
1919/// The `mcpServers.<server>` entry in `mcp_file`, if the file and the entry
1920/// both exist.
1921fn read_mcp_entry(mcp_file: &Path, server: &str) -> Result<Option<serde_json::Value>, CliError> {
1922    if !mcp_file.exists() {
1923        return Ok(None);
1924    }
1925    let raw = fs::read_to_string(mcp_file)
1926        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1927    let root: serde_json::Value = serde_json::from_str(&raw)
1928        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
1929    let entry = &root["mcpServers"][server];
1930    Ok(if entry.is_null() {
1931        None
1932    } else {
1933        Some(entry.clone())
1934    })
1935}
1936
1937/// Reconstruct the [`StoreRef`] an existing config argument names — the same
1938/// argument [`StoreRef::arg`] would have written. `enable` uses this to read
1939/// which store a stashed MCP entry (and the hooks wired alongside it) was for.
1940fn store_from_arg(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
1941    if arg == AUTO_ARG {
1942        return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
1943    }
1944    let path = PathBuf::from(arg);
1945    if path == default_db(Scope::Project, project_root, home) {
1946        return StoreRef::pinned(path).also_auto();
1947    }
1948    StoreRef::pinned(path)
1949}
1950
1951/// The store argument one of our hook commands names.
1952///
1953/// A hook command is `<binary…> <sub> <store>` and the store is its last word,
1954/// written by [`StoreRef::shell_arg`]: `--auto`, or the path in the single
1955/// quotes [`sh_quote`] puts round it. This reads that word back.
1956fn hook_command_store_arg(command: &str, sub: &str) -> Option<String> {
1957    let needle = format!(" {sub} ");
1958    let at = command.rfind(&needle)?;
1959    let arg = command[at + needle.len()..].trim();
1960    if arg.is_empty() {
1961        return None;
1962    }
1963    Some(
1964        match arg.strip_prefix('\'').and_then(|a| a.strip_suffix('\'')) {
1965            Some(inner) => inner.replace(r"'\''", "'"),
1966            None => arg.to_string(),
1967        },
1968    )
1969}
1970
1971/// The store an install recorded, read back out of its `SessionStart` hook.
1972///
1973/// A `cli` install registers no MCP server, so the entry every other recovery
1974/// path reads the store out of does not exist; the hooks are what it wrote,
1975/// and they name the store the same way.
1976fn store_from_hooks(manifest: &Manifest, project_root: &Path, home: &Path) -> Option<StoreRef> {
1977    manifest
1978        .hooks
1979        .iter()
1980        .find(|h| h.event == BRIEF_EVENT)
1981        .and_then(|h| hook_command_store_arg(&h.command, "brief"))
1982        .map(|arg| store_from_arg(&arg, project_root, home))
1983}
1984
1985/// What an install at this scope recorded: the door it opened, and the store
1986/// its hooks name. Both are `None`/`Both` when there is no manifest at all.
1987pub(crate) fn installed_shape(
1988    project_root: &Path,
1989    home: &Path,
1990    scope: Scope,
1991    platforms: &[Platform],
1992) -> (Delivery, Option<StoreRef>) {
1993    let manifest = load_manifest(&manifest_path(project_root, home, scope, platforms));
1994    let store = store_from_hooks(&manifest, project_root, home);
1995    (manifest.delivery, store)
1996}
1997
1998/// The config file `disable` would have stashed `platform`'s MCP entry from —
1999/// the same file [`platform_stores`]/[`install_platform`] write to. `None` for
2000/// Codex, whose registration is not a file this program reads.
2001fn platform_mcp_file(
2002    platform: &Platform,
2003    project_root: &Path,
2004    home: &Path,
2005    scope: Scope,
2006) -> Option<PathBuf> {
2007    match platform {
2008        Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
2009        Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
2010        Platform::Codex | Platform::All => None,
2011    }
2012}
2013
2014/// The store `enable` should write for `platform`: whichever one that
2015/// platform's own stashed MCP entry named, matched by which file it was
2016/// stashed from — Claude Code and Cursor can disagree (only Claude Code
2017/// resolves `--auto`; see [`resolves_at_runtime`]), so a single stash entry
2018/// must never be applied to every platform. Falls back to the same default
2019/// `install` would pick with no `--db` when nothing was stashed for this
2020/// platform — always true for Codex, whose registration is not a file this
2021/// program reads, so a Codex install made with an explicit `--db` cannot be
2022/// recovered exactly and is re-registered at the default store instead.
2023fn recover_store_for(
2024    manifest: &Manifest,
2025    platform: &Platform,
2026    project_root: &Path,
2027    home: &Path,
2028    scope: Scope,
2029) -> StoreRef {
2030    platform_mcp_file(platform, project_root, home, scope)
2031        .and_then(|file| manifest.stashed_mcp.iter().find(|s| s.file == file))
2032        .and_then(|s| entry_db(&s.entry))
2033        .map(|arg| store_from_arg(arg, project_root, home))
2034        // A `cli` install stashed no entry, because it registered no server.
2035        // Its hooks name the store, and they are recorded too.
2036        .or_else(|| match platform {
2037            Platform::ClaudeCode => store_from_hooks(manifest, project_root, home),
2038            _ => None,
2039        })
2040        .unwrap_or_else(|| {
2041            store_ref(
2042                project_root,
2043                home,
2044                scope,
2045                None,
2046                resolves_at_runtime(platform),
2047            )
2048        })
2049}
2050
2051/// The store the repository wiring (`.gitignore`, the git hook blocks) should
2052/// name for `enable`: whichever recovered per-platform store resolves at
2053/// runtime (Claude Code's, when present — same preference [`repo_store_ref`]
2054/// gives a fresh install), else the first platform's. Mirrors
2055/// [`repo_store_ref`], sourced from what was actually recovered rather than
2056/// recomputed independently, so it can never disagree with what
2057/// `install_claude_code`/`install_cursor` just wrote.
2058fn repo_store_for_enable(stores: &[(Platform, StoreRef)]) -> StoreRef {
2059    stores
2060        .iter()
2061        .find(|(p, _)| resolves_at_runtime(p))
2062        .or_else(|| stores.first())
2063        .map(|(_, s)| s.clone())
2064        .expect("enable always resolves at least one platform")
2065}
2066
2067/// Turn an install off: remove the MCP entry, the three Claude Code hooks, the
2068/// git hook blocks and the Codex registration; leave the skill/rules file, the
2069/// store, and the `.gitignore` line untouched. Idempotent.
2070pub fn run_disable(
2071    project_root: &Path,
2072    home: &Path,
2073    opts: &ToggleOpts,
2074) -> Result<String, CliError> {
2075    run_disable_with(project_root, home, opts, &Externals::from_env())
2076}
2077
2078/// Like [`run_disable`], with the external environment supplied by the caller
2079/// (Codex removal shells out to the `codex` CLI).
2080pub fn run_disable_with(
2081    project_root: &Path,
2082    home: &Path,
2083    opts: &ToggleOpts,
2084    ext: &Externals,
2085) -> Result<String, CliError> {
2086    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
2087    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
2088    let platforms = expand_platform(&resolved);
2089    let (scope, manifest_path) =
2090        locate_manifest(project_root, home, scope, auto_scope, &platforms, "disable")?;
2091    let mut manifest = load_manifest(&manifest_path);
2092
2093    let dir = scope_dir(project_root, home, scope);
2094    if manifest.disabled {
2095        return Ok(format!(
2096            "mushroomdb is already disabled in {}\n",
2097            dir.display()
2098        ));
2099    }
2100
2101    let mut changed = Vec::new();
2102    let mut stashed = Vec::new();
2103    for key in &manifest.mcp_keys {
2104        let Some(entry) = read_mcp_entry(&key.file, &key.server)? else {
2105            continue;
2106        };
2107        stashed.push(StashedMcpEntry {
2108            file: key.file.clone(),
2109            server: key.server.clone(),
2110            entry,
2111        });
2112        if remove_mcp_key(&key.file, &key.server)? {
2113            changed.push(format!(
2114                "disabled  mcpServers.{} in {}",
2115                key.server,
2116                key.file.display()
2117            ));
2118        }
2119    }
2120
2121    for h in &manifest.hooks {
2122        if remove_hook_entry(&h.file, &h.event, &h.command)? {
2123            changed.push(format!(
2124                "disabled  {} hook in {}",
2125                h.event,
2126                h.file.display()
2127            ));
2128        }
2129    }
2130
2131    for h in &manifest.git_hooks {
2132        if remove_git_hook(h)? {
2133            changed.push(format!("disabled  git hook block in {}", h.display()));
2134        }
2135    }
2136
2137    if manifest.codex {
2138        remove_codex(ext, &mut changed, "disabled")?;
2139    }
2140
2141    manifest.disabled = true;
2142    manifest.stashed_mcp = stashed;
2143    write_manifest(&manifest_path, &manifest)?;
2144
2145    let mut out = String::new();
2146    for line in &changed {
2147        out.push_str(line);
2148        out.push('\n');
2149    }
2150    out.push_str(&format!(
2151        "mushroomdb is disabled in {}; enable with: mushroomdb enable\n",
2152        dir.display()
2153    ));
2154    Ok(out)
2155}
2156
2157/// Turn a disabled install back on. Re-adds the MCP entry, the three Claude Code
2158/// hooks and the git hook blocks using the store a stashed entry named and the
2159/// command `install` would resolve right now — not a replay of what
2160/// `disable` took out, which may no longer be the fastest path to the
2161/// published package. Idempotent, and a no-op (not an error) when the install
2162/// is not disabled.
2163pub fn run_enable(project_root: &Path, home: &Path, opts: &ToggleOpts) -> Result<String, CliError> {
2164    run_enable_with(
2165        project_root,
2166        home,
2167        opts,
2168        &detect_mcp_command(None),
2169        &Externals::from_env(),
2170    )
2171}
2172
2173/// Like [`run_enable`], with the server command and the external environment
2174/// supplied by the caller. Tests use this to stay deterministic and offline.
2175pub fn run_enable_with(
2176    project_root: &Path,
2177    home: &Path,
2178    opts: &ToggleOpts,
2179    cmd: &McpCommand,
2180    ext: &Externals,
2181) -> Result<String, CliError> {
2182    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
2183    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
2184    let platforms = expand_platform(&resolved);
2185    let (scope, manifest_path) =
2186        locate_manifest(project_root, home, scope, auto_scope, &platforms, "enable")?;
2187    let mut manifest = load_manifest(&manifest_path);
2188
2189    let dir = scope_dir(project_root, home, scope);
2190    if !manifest.disabled {
2191        return Ok(format!(
2192            "mushroomdb is already enabled in {}\n",
2193            dir.display()
2194        ));
2195    }
2196
2197    // Restore what was actually requested before, not the caller's
2198    // auto-detected `cmd` — that would silently drop an explicit `--command`
2199    // pin the moment it was disabled. `Npx` is re-resolved below exactly like
2200    // `install` would (the "current shapes" part); `Explicit` is used
2201    // verbatim unless the binary it names is gone, in which case this falls
2202    // back to the caller's `cmd` and says so. A manifest with no stash at all
2203    // (written before this field existed) also falls back, silently — there
2204    // is nothing to have dropped.
2205    let mut notes: Vec<String> = Vec::new();
2206    let base_cmd = match manifest.requested_cmd.clone() {
2207        Some(StoredCommand::Explicit(p)) if is_bare_program_name(&p) || p.is_file() => {
2208            McpCommand::Explicit(p)
2209        }
2210        Some(StoredCommand::Explicit(p)) => {
2211            notes.push(format!(
2212                "warning: the pinned command {} no longer exists — re-detected the command instead",
2213                p.display()
2214            ));
2215            cmd.clone()
2216        }
2217        Some(other) => other.into_mcp(),
2218        None => cmd.clone(),
2219    };
2220    let base_cmd = match &base_cmd {
2221        McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
2222        other => other.clone(),
2223    };
2224    let (cmd, launcher_note, _) = resolve_fast_command(&base_cmd, ext);
2225    let cmd = &cmd;
2226    notes.extend(launcher_note);
2227
2228    // Each platform's own stashed entry says which store *that* platform was
2229    // installed with — Claude Code and Cursor can disagree, since only Claude
2230    // Code resolves `--auto`. A Codex-only install stashes nothing and falls
2231    // back to the same default a fresh install would pick.
2232    let stores: Vec<(Platform, StoreRef)> = platforms
2233        .iter()
2234        .map(|p| {
2235            (
2236                p.clone(),
2237                recover_store_for(&manifest, p, project_root, home, scope),
2238            )
2239        })
2240        .collect();
2241    let repo_store = repo_store_for_enable(&stores);
2242    let had_git_hooks = !manifest.git_hooks.is_empty();
2243
2244    let ctx = Ctx {
2245        project_root,
2246        home,
2247        scope,
2248        repo_store: &repo_store,
2249        cmd,
2250        ext,
2251        git_hooks: true,
2252        prewarm: false,
2253        // Re-enable the install that was disabled, not a different one: a
2254        // `cli` install has no server to put back, and its skill is the one
2255        // that teaches the binary.
2256        delivery: manifest.delivery,
2257        // Likewise the redirect: `enable` never adds an experiment the
2258        // install it is restoring never had.
2259        intercept_grep: manifest.intercept_grep,
2260    };
2261
2262    let mut fresh = Manifest::default();
2263    for (plat, store) in &stores {
2264        match plat {
2265            Platform::ClaudeCode => install_claude_code(&ctx, store, &mut fresh, &mut notes)?,
2266            Platform::Cursor => install_cursor(&ctx, store, &mut fresh, &mut notes)?,
2267            Platform::Codex => install_codex(&ctx, store, &mut fresh)?,
2268            Platform::All => unreachable!("expand_platform never produces All"),
2269        }
2270    }
2271
2272    let repo_wiring = platforms
2273        .iter()
2274        .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
2275    if had_git_hooks && scope == Scope::Project && repo_wiring {
2276        install_git_hooks(&ctx, &mut fresh)?;
2277    }
2278
2279    // Fold this run's writes into the manifest: entries for files this run
2280    // touched replace what was there before (the command may have re-resolved
2281    // to a different path since `disable`); anything this run did not touch
2282    // — a platform this call was not asked to enable — survives untouched.
2283    let touched_mcp: Vec<&PathBuf> = fresh.mcp_keys.iter().map(|k| &k.file).collect();
2284    manifest
2285        .mcp_keys
2286        .retain(|k| !touched_mcp.contains(&&k.file));
2287    manifest.mcp_keys.extend(fresh.mcp_keys.iter().cloned());
2288
2289    let touched_hooks: Vec<(&PathBuf, &str)> = fresh
2290        .hooks
2291        .iter()
2292        .map(|h| (&h.file, h.event.as_str()))
2293        .collect();
2294    manifest
2295        .hooks
2296        .retain(|h| !touched_hooks.contains(&(&h.file, h.event.as_str())));
2297    manifest.hooks.extend(fresh.hooks.iter().cloned());
2298
2299    if !fresh.git_hooks.is_empty() {
2300        manifest.git_hooks = fresh.git_hooks.clone();
2301    }
2302    manifest.codex |= fresh.codex;
2303    for f in &fresh.files {
2304        if !manifest.files.contains(f) {
2305            manifest.files.push(f.clone());
2306        }
2307    }
2308
2309    manifest.disabled = false;
2310    manifest.stashed_mcp.clear();
2311    // Remember what was actually used this round — the restored pin, the
2312    // fallback it took because that pin was gone, or the unresolved `Npx`
2313    // request (never the resolved native-binary/launcher path resolution
2314    // turned it into) — so the *next* `disable`/`enable` round trip starts
2315    // from what is actually true now rather than a permanently stale pin.
2316    manifest.requested_cmd = StoredCommand::from_mcp(&base_cmd);
2317    write_manifest(&manifest_path, &manifest)?;
2318
2319    let mut out = String::new();
2320    for k in &fresh.mcp_keys {
2321        out.push_str(&format!(
2322            "enabled  mcpServers.{} in {}\n",
2323            k.server,
2324            k.file.display()
2325        ));
2326    }
2327    for h in &fresh.hooks {
2328        out.push_str(&format!(
2329            "enabled  {} hook in {}\n",
2330            h.event,
2331            h.file.display()
2332        ));
2333    }
2334    for g in &fresh.git_hooks {
2335        out.push_str(&format!("enabled  git hook {}\n", g.display()));
2336    }
2337    if fresh.codex {
2338        out.push_str(&format!("enabled  codex mcp server {SERVER_NAME}\n"));
2339    }
2340    for n in &notes {
2341        out.push_str(&format!("  {n}\n"));
2342    }
2343    out.push_str(&format!("mushroomdb is enabled in {}\n", dir.display()));
2344    Ok(out)
2345}
2346
2347// ---------------------------------------------------------------------------
2348// Platform resolution
2349// ---------------------------------------------------------------------------
2350
2351pub(crate) fn resolve_platform(
2352    project_root: &Path,
2353    home: &Path,
2354    requested: Option<&Platform>,
2355) -> Result<Platform, CliError> {
2356    if let Some(p) = requested {
2357        return Ok(p.clone());
2358    }
2359
2360    // Auto-detect. Codex is never inferred: registering with it runs another
2361    // program, which is not something to do because a directory exists.
2362    let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
2363    let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
2364
2365    match (has_claude, has_cursor) {
2366        (true, true) => Ok(Platform::All),
2367        (true, false) => Ok(Platform::ClaudeCode),
2368        (false, true) => Ok(Platform::Cursor),
2369        (false, false) => Err(CliError(
2370            "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
2371             Pass --platform claude-code, --platform cursor, --platform codex, or --platform all."
2372                .to_string(),
2373        )),
2374    }
2375}
2376
2377/// `All` is the two platforms whose config this program writes itself. Codex
2378/// is deliberately not in it: it is wired by running the `codex` CLI, which
2379/// may not exist, and `--platform all` must not fail on a machine that simply
2380/// does not have it.
2381pub(crate) fn expand_platform(p: &Platform) -> Vec<Platform> {
2382    match p {
2383        Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
2384        other => vec![other.clone()],
2385    }
2386}
2387
2388// ---------------------------------------------------------------------------
2389// Pre-flight conflict check (no writes)
2390// ---------------------------------------------------------------------------
2391
2392fn preflight_check(
2393    project_root: &Path,
2394    home: &Path,
2395    platform: &Platform,
2396    scope: Scope,
2397    store: &StoreRef,
2398    ext: &Externals,
2399) -> Result<(), CliError> {
2400    match platform {
2401        Platform::ClaudeCode => {
2402            check_mcp_conflict(&claude_mcp_file(project_root, home, scope), store)
2403        }
2404        Platform::Cursor => check_mcp_conflict(&cursor_mcp_file(project_root, home, scope), store),
2405        // Nothing of Codex's is a file we read; what can fail early is the CLI
2406        // being absent, and that is worth saying before anything is written.
2407        Platform::Codex => codex_bin(ext).map(|_| ()),
2408        Platform::All => unreachable!("expand_platform never produces All"),
2409    }
2410}
2411
2412pub(crate) fn claude_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
2413    match scope {
2414        Scope::Project => project_root.join(".mcp.json"),
2415        // User-scope: verified empirically on a live Claude Code install.
2416        // ~/.claude.json holds top-level mcpServers; ~/.claude/settings.json
2417        // holds env/permissions/hooks but no mcpServers key.
2418        Scope::User => home.join(".claude.json"),
2419    }
2420}
2421
2422pub(crate) fn cursor_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
2423    match scope {
2424        Scope::Project => project_root.join(".cursor").join("mcp.json"),
2425        Scope::User => home.join(".cursor").join("mcp.json"),
2426    }
2427}
2428
2429/// The store an existing entry serves: the argument straight after `mcp`,
2430/// which is either a path or `--auto`.
2431///
2432/// Its position moved between versions — 0.5.x wrote `["mcp", db]`, the npx
2433/// form writes `["-y", "mushroomdb@x.y.z", "mcp", db]`, a resolved launcher
2434/// writes `["<launcher>", "mcp", "--auto"]` — so the subcommand is what
2435/// locates it, not an index.
2436pub(crate) fn entry_db(entry: &serde_json::Value) -> Option<&str> {
2437    let args = entry["args"].as_array()?;
2438    let at = args.iter().position(|a| a == "mcp")?;
2439    args.get(at + 1)?.as_str()
2440}
2441
2442/// Check if a MCP JSON file has a conflicting `mushroomdb` entry.
2443///
2444/// A conflict is: the file exists, has `mcpServers.mushroomdb`, and the store
2445/// it names differs from the one we'd write. An entry for the SAME store with
2446/// a different `command` — or the same store spelled the other way, which is
2447/// every 0.6.0 entry now that a project install writes `--auto` — is ours to
2448/// repair, so it is not a conflict.
2449fn check_mcp_conflict(mcp_file: &Path, store: &StoreRef) -> Result<(), CliError> {
2450    if !mcp_file.exists() {
2451        return Ok(());
2452    }
2453    let raw = fs::read_to_string(mcp_file)
2454        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
2455    let v: serde_json::Value = serde_json::from_str(&raw)
2456        .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
2457
2458    let existing = &v["mcpServers"][SERVER_NAME];
2459    if existing.is_null() {
2460        return Ok(()); // Key absent — no conflict.
2461    }
2462
2463    let existing_db = entry_db(existing).unwrap_or("");
2464    if store.names_same_store(existing_db) {
2465        return Ok(()); // Same store — idempotent or repairable, no conflict.
2466    }
2467
2468    Err(CliError(format!(
2469        "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
2470         To update it, run `mushroomdb uninstall` first, then re-install.\n\
2471         Or manually edit {} and remove the existing mushroomdb entry.",
2472        mcp_file.display(),
2473        existing_db,
2474        mcp_file.display()
2475    )))
2476}
2477
2478/// A server registered in the *other* scope still shows up in the assistant,
2479/// and two of them pointed at two stores is a confusing place to be. Say so;
2480/// never touch the other scope's file.
2481fn scope_conflict_note(ctx: &Ctx<'_>, platforms: &[Platform]) -> Option<String> {
2482    if !platforms.contains(&Platform::ClaudeCode) {
2483        return None;
2484    }
2485    let (other, label, flag) = match ctx.scope {
2486        Scope::Project => (
2487            claude_mcp_file(ctx.project_root, ctx.home, Scope::User),
2488            "user",
2489            "--user",
2490        ),
2491        Scope::User => (
2492            claude_mcp_file(ctx.project_root, ctx.home, Scope::Project),
2493            "project",
2494            "--project",
2495        ),
2496    };
2497    if !has_our_server(&other) {
2498        return None;
2499    }
2500    Some(format!(
2501        "warning: a {label}-scope mushroomdb server also exists ({}) — \
2502         both will load; to remove that one run: mushroomdb uninstall {flag}",
2503        other.display()
2504    ))
2505}
2506
2507pub(crate) fn has_our_server(mcp_file: &Path) -> bool {
2508    let Ok(raw) = fs::read_to_string(mcp_file) else {
2509        return false;
2510    };
2511    serde_json::from_str::<serde_json::Value>(&raw)
2512        .map(|v| !v["mcpServers"][SERVER_NAME].is_null())
2513        .unwrap_or(false)
2514}
2515
2516// ---------------------------------------------------------------------------
2517// Per-platform installation
2518// ---------------------------------------------------------------------------
2519
2520fn install_platform(
2521    ctx: &Ctx<'_>,
2522    platform: &Platform,
2523    store: &StoreRef,
2524    manifest: &mut Manifest,
2525    notes: &mut Vec<String>,
2526) -> Result<(), CliError> {
2527    // `--delivery` only has a skill to switch on Claude Code; the other two
2528    // are registered as MCP servers whatever was asked for. Say so — a flag
2529    // that does nothing is worse when it does it quietly.
2530    if !ctx.delivery.wires_mcp() && !matches!(platform, Platform::ClaudeCode) {
2531        notes.push(format!(
2532            "note: --delivery {} applies to claude-code only — {} was registered as an MCP server",
2533            ctx.delivery.label(),
2534            platform.label()
2535        ));
2536    }
2537    match platform {
2538        Platform::ClaudeCode => install_claude_code(ctx, store, manifest, notes),
2539        Platform::Cursor => install_cursor(ctx, store, manifest, notes),
2540        Platform::Codex => install_codex(ctx, store, manifest),
2541        Platform::All => unreachable!("expand_platform never produces All"),
2542    }
2543}
2544
2545/// Substitute both template placeholders and keep only the delivery regions
2546/// this install wants. `bin_cmd` is the pre-quoted shell form, so the
2547/// templates carry `{{BIN}}` unquoted.
2548///
2549/// One source file describes every delivery, so the variants cannot drift: a
2550/// `<!-- cli -->` / `<!-- /cli -->` (or `mcp`) pair marks a block only that
2551/// door's reader should see, and `Both` keeps them all. The marker lines
2552/// themselves are never written. By convention a region opens immediately
2553/// after the paragraph before it and its content starts with the blank line,
2554/// so dropping a whole region leaves exactly one blank line behind rather than
2555/// a hole in the prose.
2556///
2557/// The regions are checked for well-formedness rather than trusted: an
2558/// unterminated `<!-- mcp -->` would silently swallow the rest of the file on
2559/// the `cli` variant, and a nested pair would leave the inner close re-opening
2560/// the outer region, so both are errors and neither can ship as a short skill
2561/// nobody looked at. The two conditions are the same ones the awk twin in
2562/// `scripts/render-plugin.sh` fails on.
2563///
2564/// Public so the skill's per-turn budget can be measured on every variant
2565/// without an install: what this returns is exactly what `install` writes, and
2566/// `scripts/render-plugin.sh` mirrors it for the plugin copy.
2567pub fn render_template(
2568    template: &str,
2569    db_str: &str,
2570    bin_cmd: &str,
2571    delivery: Delivery,
2572) -> Result<String, CliError> {
2573    /// `("cli", true)` for `<!-- cli -->`, `("cli", false)` for `<!-- /cli -->`.
2574    fn marker(line: &str) -> Option<(&'static str, bool)> {
2575        match line {
2576            "<!-- cli -->" => Some(("cli", true)),
2577            "<!-- mcp -->" => Some(("mcp", true)),
2578            "<!-- /cli -->" => Some(("cli", false)),
2579            "<!-- /mcp -->" => Some(("mcp", false)),
2580            _ => None,
2581        }
2582    }
2583
2584    let mut out = String::with_capacity(template.len());
2585    let mut open: Option<(&str, usize)> = None;
2586    let mut dropping = false;
2587    for (i, line) in template.lines().enumerate() {
2588        let at = i + 1;
2589        match marker(line) {
2590            Some((name, true)) => {
2591                if let Some((outer, opened)) = open {
2592                    return Err(CliError(format!(
2593                        "skill template line {at}: <!-- {name} --> opens inside the \
2594                         <!-- {outer} --> region opened on line {opened} — delivery \
2595                         regions must not nest"
2596                    )));
2597                }
2598                open = Some((name, at));
2599                dropping = match name {
2600                    "cli" => matches!(delivery, Delivery::Mcp),
2601                    _ => matches!(delivery, Delivery::Cli),
2602                };
2603            }
2604            Some((name, false)) => {
2605                match open {
2606                    None => {
2607                        return Err(CliError(format!(
2608                            "skill template line {at}: <!-- /{name} --> closes a region \
2609                             that was never opened"
2610                        )))
2611                    }
2612                    Some((outer, opened)) if outer != name => {
2613                        return Err(CliError(format!(
2614                            "skill template line {at}: <!-- /{name} --> closes the \
2615                             <!-- {outer} --> region opened on line {opened}"
2616                        )))
2617                    }
2618                    Some(_) => {}
2619                }
2620                open = None;
2621                dropping = false;
2622            }
2623            None if dropping => {}
2624            None => {
2625                out.push_str(line);
2626                out.push('\n');
2627            }
2628        }
2629    }
2630    if let Some((name, opened)) = open {
2631        return Err(CliError(format!(
2632            "skill template: the <!-- {name} --> region opened on line {opened} is \
2633             never closed"
2634        )));
2635    }
2636    Ok(out
2637        .replace(DB_PATH_PLACEHOLDER, db_str)
2638        .replace(BIN_PLACEHOLDER, bin_cmd))
2639}
2640
2641fn install_claude_code(
2642    ctx: &Ctx<'_>,
2643    store: &StoreRef,
2644    manifest: &mut Manifest,
2645    notes: &mut Vec<String>,
2646) -> Result<(), CliError> {
2647    let shell = ctx.cmd.shell();
2648    // The skill is prose a reader follows by hand, so it names the directory
2649    // the store is in rather than the `--auto` the machine-read config uses.
2650    let db_str = store.path().to_string_lossy();
2651    let skill_content = render_template(SKILL_TEMPLATE, &db_str, &shell, ctx.delivery)?;
2652
2653    let skill_dir = match ctx.scope {
2654        Scope::Project => ctx
2655            .project_root
2656            .join(".claude")
2657            .join("skills")
2658            .join("mushroom"),
2659        Scope::User => ctx.home.join(".claude").join("skills").join("mushroom"),
2660    };
2661    let skill_file = skill_dir.join("SKILL.md");
2662
2663    // Idempotent: skip if the file already has the same content.
2664    if !file_matches(&skill_file, &skill_content) {
2665        fs::create_dir_all(&skill_dir)
2666            .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
2667        fs::write(&skill_file, &skill_content)
2668            .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
2669        manifest.files.push(skill_file);
2670    }
2671
2672    // `cli` delivery is defined by what it does *not* write: no server entry,
2673    // so nothing in the session pays a tool-discovery round trip before its
2674    // first question. The hooks below are written either way — they are the
2675    // binary talking to the session, not the session talking to a server.
2676    let mcp_file = claude_mcp_file(ctx.project_root, ctx.home, ctx.scope);
2677    if ctx.delivery.wires_mcp() {
2678        merge_mcp_entry(&mcp_file, ctx, store, manifest, notes)?;
2679    } else if remove_mcp_key(&mcp_file, SERVER_NAME)? {
2680        // Switching an existing install to `cli` has to take the server it
2681        // already registered back out, or the door this delivery exists to
2682        // close would stay open.
2683        notes.push(format!(
2684            "removed mcpServers.{SERVER_NAME} from {} — delivery: {}",
2685            mcp_file.display(),
2686            ctx.delivery.label()
2687        ));
2688    }
2689
2690    // All three hooks: settings.json in the same scope as the skill. The
2691    // prompt hook first, so a manifest lists them in the order they were
2692    // written.
2693    let settings_file = match ctx.scope {
2694        Scope::Project => ctx.project_root.join(".claude").join("settings.json"),
2695        Scope::User => ctx.home.join(".claude").join("settings.json"),
2696    };
2697    // An earlier install of ours for this same store is replaced, not joined:
2698    // its command names a binary this version no longer writes, and leaving it
2699    // would run both on every prompt.
2700    let recall = recall_hook_command(&shell, store);
2701    if remove_stale_hooks(&settings_file, HOOK_EVENT, "recall", store, &recall)? {
2702        notes.push(format!("replaced stale {HOOK_EVENT} hook"));
2703    }
2704    merge_hook_entry(
2705        &settings_file,
2706        HOOK_EVENT,
2707        &recall,
2708        hook_entry(&recall),
2709        manifest,
2710    )?;
2711    let touch = touch_hook_command(&shell, store);
2712    if remove_stale_hooks(&settings_file, TOUCH_EVENT, "touch", store, &touch)? {
2713        notes.push(format!("replaced stale {TOUCH_EVENT} hook"));
2714    }
2715    merge_hook_entry(
2716        &settings_file,
2717        TOUCH_EVENT,
2718        &touch,
2719        touch_hook_entry(&touch),
2720        manifest,
2721    )?;
2722    let brief = brief_hook_command(&shell, store);
2723    if remove_stale_hooks(&settings_file, BRIEF_EVENT, "brief", store, &brief)? {
2724        notes.push(format!("replaced stale {BRIEF_EVENT} hook"));
2725    }
2726    merge_hook_entry(
2727        &settings_file,
2728        BRIEF_EVENT,
2729        &brief,
2730        hook_entry(&brief),
2731        manifest,
2732    )?;
2733
2734    // The fourth hook is opt-in, and an install that does not ask for it takes
2735    // back any earlier one of ours for this store — otherwise the experiment
2736    // could only ever be turned on.
2737    let intercept = intercept_hook_command(&shell, store);
2738    if ctx.intercept_grep {
2739        if remove_stale_hooks(
2740            &settings_file,
2741            INTERCEPT_EVENT,
2742            "intercept",
2743            store,
2744            &intercept,
2745        )? {
2746            notes.push(format!("replaced stale {INTERCEPT_EVENT} hook"));
2747        }
2748        merge_hook_entry(
2749            &settings_file,
2750            INTERCEPT_EVENT,
2751            &intercept,
2752            intercept_hook_entry(&intercept),
2753            manifest,
2754        )?;
2755    } else if drop_hooks(&settings_file, INTERCEPT_EVENT, |c| {
2756        is_our_hook_command(c, "intercept", store)
2757    })? {
2758        notes.push(format!(
2759            "removed {INTERCEPT_EVENT} hook — no --intercept-grep"
2760        ));
2761    }
2762
2763    Ok(())
2764}
2765
2766fn install_cursor(
2767    ctx: &Ctx<'_>,
2768    store: &StoreRef,
2769    manifest: &mut Manifest,
2770    notes: &mut Vec<String>,
2771) -> Result<(), CliError> {
2772    let db_str = store.path().to_string_lossy();
2773    // Cursor is always an MCP install (see [`Delivery`]), so its rules file is
2774    // rendered for that door whatever `--delivery` asked for.
2775    let rules_content = render_template(
2776        CURSOR_RULES_TEMPLATE,
2777        &db_str,
2778        &ctx.cmd.shell(),
2779        Delivery::Mcp,
2780    )?;
2781
2782    let rules_dir = match ctx.scope {
2783        Scope::Project => ctx.project_root.join(".cursor").join("rules"),
2784        Scope::User => ctx.home.join(".cursor").join("rules"),
2785    };
2786    let rules_file = rules_dir.join("mushroom.mdc");
2787
2788    if !file_matches(&rules_file, &rules_content) {
2789        fs::create_dir_all(&rules_dir)
2790            .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
2791        fs::write(&rules_file, &rules_content)
2792            .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
2793        manifest.files.push(rules_file);
2794    }
2795
2796    let mcp_file = cursor_mcp_file(ctx.project_root, ctx.home, ctx.scope);
2797    merge_mcp_entry(&mcp_file, ctx, store, manifest, notes)?;
2798
2799    Ok(())
2800}
2801
2802/// The `codex` executable, or an error that says what to do about it.
2803fn codex_bin(ext: &Externals) -> Result<PathBuf, CliError> {
2804    ext.which("codex").ok_or_else(|| {
2805        CliError(
2806            "codex was not found on PATH — install the Codex CLI, or drop \
2807             `--platform codex`"
2808                .to_string(),
2809        )
2810    })
2811}
2812
2813/// Take the Codex registration back out, through `codex mcp remove`. Appends
2814/// one line to `out` on success (`"{verb}  codex mcp server mushroomdb"`), or
2815/// a warning naming the manual fallback when `codex` cannot be reached — not
2816/// being able to reach it must not strand every other thing the caller is
2817/// removing. Shared by `uninstall` and `disable`, which take the registration
2818/// off disk the same way and differ only in whether they still own it after.
2819fn remove_codex(ext: &Externals, out: &mut Vec<String>, verb: &str) -> Result<(), CliError> {
2820    match ext.which("codex") {
2821        Some(bin) => {
2822            run_and_capture(&bin, &["mcp".into(), "remove".into(), SERVER_NAME.into()])
2823                .map_err(|e| CliError(format!("codex mcp remove failed: {e}")))?;
2824            out.push(format!("{verb}  codex mcp server {SERVER_NAME}"));
2825        }
2826        None => out.push(
2827            "warning: codex is not on PATH — run `codex mcp remove mushroomdb` yourself"
2828                .to_string(),
2829        ),
2830    }
2831    Ok(())
2832}
2833
2834/// Register the server with Codex through its own CLI.
2835///
2836/// Codex owns its configuration file and its format is its business, so this
2837/// writes nothing: it runs `codex mcp add mushroomdb -- <command> <args…>` and
2838/// lets Codex record it. 0.6.0 ships no Codex skill — the MCP tools carry
2839/// their own descriptions, which is what Codex reads.
2840fn install_codex(ctx: &Ctx<'_>, store: &StoreRef, manifest: &mut Manifest) -> Result<(), CliError> {
2841    let bin = codex_bin(ctx.ext)?;
2842    let mut args = vec![
2843        "mcp".to_string(),
2844        "add".to_string(),
2845        SERVER_NAME.to_string(),
2846        "--".to_string(),
2847    ];
2848    args.extend(ctx.cmd.argv("mcp", &store.arg()));
2849    run_and_capture(&bin, &args).map_err(|e| CliError(format!("codex mcp add failed: {e}")))?;
2850    manifest.codex = true;
2851    Ok(())
2852}
2853
2854// ---------------------------------------------------------------------------
2855// Repository wiring: the ignore line and the sync hooks
2856// ---------------------------------------------------------------------------
2857
2858/// The git hooks a sync belongs in: after a commit lands, after a branch
2859/// changes the working tree, and after a merge brings other people's commits
2860/// in. All three leave the graph a commit behind if they are skipped.
2861pub(crate) const GIT_HOOKS: &[&str] = &["post-commit", "post-checkout", "post-merge"];
2862
2863/// The `.gitignore` line for a store kept inside the repository, or `None`
2864/// when it is kept outside — a repository has no business ignoring a path it
2865/// does not contain.
2866fn gitignore_line(project_root: &Path, db: &Path) -> Option<String> {
2867    let rel = db.strip_prefix(project_root).ok()?;
2868    if rel.as_os_str().is_empty() {
2869        return None;
2870    }
2871    Some(format!("{}/", rel.to_string_lossy().replace('\\', "/")))
2872}
2873
2874/// Append the store directory to the repository's `.gitignore` unless some
2875/// spelling of it is already listed. Creates the file if it is absent.
2876fn ensure_gitignore_line(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
2877    let Some(line) = gitignore_line(ctx.project_root, ctx.repo_store.path()) else {
2878        return Ok(());
2879    };
2880    let path = ctx.project_root.join(".gitignore");
2881    let existed = path.exists();
2882    let current = match fs::read_to_string(&path) {
2883        Ok(s) => s,
2884        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
2885        Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
2886    };
2887    let bare = line.trim_end_matches('/');
2888    if current
2889        .lines()
2890        .map(str::trim)
2891        .any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
2892    {
2893        return Ok(());
2894    }
2895    let mut next = current;
2896    if !next.is_empty() && !next.ends_with('\n') {
2897        next.push('\n');
2898    }
2899    next.push_str(&line);
2900    next.push('\n');
2901    fs::write(&path, next)
2902        .map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
2903    // `created` is what lets uninstall leave a repository that had no
2904    // `.gitignore` with none again — but only if our line is still all that is
2905    // in it. Anything the user has added since is theirs, and the file stays.
2906    manifest.gitignore.push(ManagedLine {
2907        file: path,
2908        line,
2909        created: !existed,
2910    });
2911    Ok(())
2912}
2913
2914/// Whether the file is gone or holds nothing but whitespace.
2915fn file_is_blank(path: &Path) -> bool {
2916    match fs::read_to_string(path) {
2917        Ok(s) => s.trim().is_empty(),
2918        Err(_) => true,
2919    }
2920}
2921
2922/// Remove one exact line from a text file. Returns whether anything changed;
2923/// a file that does not hold the line is not rewritten at all.
2924fn remove_line(path: &Path, line: &str) -> Result<bool, CliError> {
2925    let Ok(current) = fs::read_to_string(path) else {
2926        return Ok(false);
2927    };
2928    if !current.lines().any(|l| l == line) {
2929        return Ok(false);
2930    }
2931    let kept: Vec<&str> = current.lines().filter(|l| *l != line).collect();
2932    let mut next = kept.join("\n");
2933    if !next.is_empty() {
2934        next.push('\n');
2935    }
2936    fs::write(path, next).map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
2937    Ok(true)
2938}
2939
2940/// The directory git will actually run this checkout's hooks from, following
2941/// the `gitdir:` link a worktree or submodule leaves in place of a `.git`
2942/// directory.
2943///
2944/// The subtlety is the last step. A linked worktree's gitdir is
2945/// `<main>/.git/worktrees/<name>`, but git resolves hooks through the
2946/// **common** dir — `git rev-parse --git-path hooks` inside a worktree answers
2947/// `<main>/.git/hooks`, not the worktree's own. Writing a hook into the
2948/// worktree's gitdir puts it somewhere git never looks: the file is there, it
2949/// is executable, and nothing ever runs it. A linked worktree records the way
2950/// back in a `commondir` file next to its gitdir (contents `../..`), so this
2951/// follows it whenever it is there.
2952///
2953/// A submodule has no `commondir` and its own gitdir *is* its hooks dir
2954/// (`.git/modules/<path>/hooks`), which is what the plain resolution already
2955/// computes — so the absence of the file is the signal to stop.
2956pub(crate) fn git_hooks_dir(project_root: &Path) -> Option<PathBuf> {
2957    let dot_git = project_root.join(".git");
2958    if dot_git.is_dir() {
2959        return Some(dot_git.join("hooks"));
2960    }
2961    let text = fs::read_to_string(&dot_git).ok()?;
2962    let target = text.strip_prefix("gitdir:")?.trim();
2963    let target = Path::new(target);
2964    let resolved = if target.is_absolute() {
2965        target.to_path_buf()
2966    } else {
2967        project_root.join(target)
2968    };
2969    // A linked worktree defers its hooks to the common dir; a submodule keeps
2970    // its own. The `commondir` file is what tells the two apart.
2971    let base = match fs::read_to_string(resolved.join("commondir")) {
2972        Ok(rel) => {
2973            let rel_path = PathBuf::from(rel.trim());
2974            if rel_path.is_absolute() {
2975                rel_path
2976            } else {
2977                lexically_normalize(&resolved.join(rel_path))
2978            }
2979        }
2980        Err(_) => resolved,
2981    };
2982    Some(base.join("hooks"))
2983}
2984
2985/// Resolve `.` and `..` in a path textually, without touching the filesystem.
2986///
2987/// `commondir` is written relative (`../..`), so joining it leaves a path that
2988/// works but reads badly in `doctor`'s output and in the manifest. This is
2989/// purely cosmetic and deliberately does not canonicalize: resolving symlinks
2990/// would rewrite a path the user gave us into one they do not recognise. A
2991/// leading `..` with nothing to pop is kept, since dropping it would change
2992/// where the path points.
2993fn lexically_normalize(path: &Path) -> PathBuf {
2994    let mut out = PathBuf::new();
2995    for part in path.components() {
2996        match part {
2997            std::path::Component::CurDir => {}
2998            std::path::Component::ParentDir => {
2999                let can_pop = out
3000                    .components()
3001                    .next_back()
3002                    .is_some_and(|c| matches!(c, std::path::Component::Normal(_)));
3003                if !can_pop || !out.pop() {
3004                    out.push("..");
3005                }
3006            }
3007            other => out.push(other.as_os_str()),
3008        }
3009    }
3010    out
3011}
3012
3013fn install_git_hooks(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
3014    // Not a checkout: there is nothing to hook into, and that is not an error.
3015    let Some(dir) = git_hooks_dir(ctx.project_root) else {
3016        return Ok(());
3017    };
3018    let shell = ctx.cmd.shell();
3019    for name in GIT_HOOKS {
3020        let file = dir.join(name);
3021        if merge_git_hook(&file, &shell, ctx.repo_store)? {
3022            manifest.git_hooks.push(file);
3023        }
3024    }
3025    Ok(())
3026}
3027
3028// ---------------------------------------------------------------------------
3029// Pre-warm
3030// ---------------------------------------------------------------------------
3031
3032/// Fetch the pinned package once, so the assistant's first spawn of the MCP
3033/// server is not a cold `npx` download inside a startup timeout.
3034///
3035/// Best effort in every direction: it only applies to the `npx` form, it is
3036/// skipped when asked to be, and a failure is a line in the summary rather
3037/// than a failed install — the entry that was written is correct either way.
3038///
3039/// Whenever [`resolve_fast_command`] got as far as asking `npx` anything, it
3040/// already ran this fetch — asking the package a question downloads it first —
3041/// so the caller clears `Ctx::prewarm` and this does not run a second time.
3042/// The match below is the remaining guard: a resolved binary or launcher is
3043/// not the `npx` form and needs no warming either way.
3044fn prewarm(ctx: &Ctx<'_>) -> Option<String> {
3045    if !ctx.prewarm {
3046        return None;
3047    }
3048    let McpCommand::Npx { version } = ctx.cmd else {
3049        return None;
3050    };
3051    let args = vec![
3052        "-y".to_string(),
3053        format!("{NPM_PACKAGE}@{version}"),
3054        "--version".to_string(),
3055    ];
3056    let Some(npx) = ctx.ext.which("npx") else {
3057        return Some(
3058            "warning: pre-warm skipped — npx is not on PATH; the first MCP \
3059             spawn will download the package"
3060                .to_string(),
3061        );
3062    };
3063    match run_with_timeout(&npx, &args, ctx.ext.prewarm_timeout) {
3064        Ok(()) => None,
3065        Err(e) => Some(format!(
3066            "warning: pre-warm of {NPM_PACKAGE}@{version} failed ({e}) — \
3067             the first MCP spawn will download the package"
3068        )),
3069    }
3070}
3071
3072// ---------------------------------------------------------------------------
3073// MCP JSON merge helpers
3074// ---------------------------------------------------------------------------
3075
3076/// Add `mcpServers.mushroomdb` to a JSON config file. Creates the file if
3077/// absent. No-op if the entry already matches (idempotent). An entry that is
3078/// present but different is an upgrade: it is rewritten, and the summary says
3079/// so, because a stale command is exactly the failure this replaces.
3080fn merge_mcp_entry(
3081    mcp_file: &Path,
3082    ctx: &Ctx<'_>,
3083    store: &StoreRef,
3084    manifest: &mut Manifest,
3085    notes: &mut Vec<String>,
3086) -> Result<(), CliError> {
3087    let mut root: serde_json::Value = if mcp_file.exists() {
3088        let raw = fs::read_to_string(mcp_file)
3089            .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
3090        serde_json::from_str(&raw)
3091            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
3092    } else {
3093        serde_json::json!({})
3094    };
3095
3096    // Ensure `mcpServers` object exists.
3097    if !root["mcpServers"].is_object() {
3098        root["mcpServers"] = serde_json::json!({});
3099    }
3100
3101    let desired = ctx.cmd.json_entry("mcp", &store.arg());
3102    let existing = &root["mcpServers"][SERVER_NAME];
3103
3104    if existing == &desired {
3105        return Ok(()); // Exact match — idempotent.
3106    }
3107    let replaced = !existing.is_null();
3108
3109    // Write the entry.
3110    root["mcpServers"][SERVER_NAME] = desired;
3111
3112    let parent = mcp_file.parent().unwrap_or(Path::new("."));
3113    fs::create_dir_all(parent)
3114        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
3115
3116    let json = serde_json::to_string_pretty(&root)
3117        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
3118    fs::write(mcp_file, json)
3119        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
3120
3121    manifest.mcp_keys.push(ManagedMcpKey {
3122        file: mcp_file.to_path_buf(),
3123        server: SERVER_NAME.to_string(),
3124    });
3125    if replaced {
3126        notes.push(format!(
3127            "updated mcp command in {} → {} mcp {}",
3128            mcp_file.display(),
3129            ctx.cmd.shell(),
3130            store.arg()
3131        ));
3132    }
3133
3134    Ok(())
3135}
3136
3137/// Remove `mcpServers.<server>` from a JSON config file. Leaves the file in
3138/// place (with the key removed) unless `mcpServers` becomes empty, in which
3139/// case we still leave the file (the user may have other keys).
3140///
3141/// Returns whether the key was there. A file that does not hold it is not
3142/// rewritten at all, for the same reason `drop_hooks` does not: re-serializing
3143/// a file we take nothing out of would reorder and re-indent the user's keys
3144/// for no reason.
3145fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<bool, CliError> {
3146    if !mcp_file.exists() {
3147        return Ok(false);
3148    }
3149    let raw = fs::read_to_string(mcp_file)
3150        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
3151    let mut root: serde_json::Value = serde_json::from_str(&raw)
3152        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
3153
3154    let removed = root["mcpServers"]
3155        .as_object_mut()
3156        .is_some_and(|servers| servers.remove(server).is_some());
3157    if !removed {
3158        return Ok(false);
3159    }
3160
3161    let json = serde_json::to_string_pretty(&root)
3162        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
3163    fs::write(mcp_file, json)
3164        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
3165    Ok(true)
3166}
3167
3168// ---------------------------------------------------------------------------
3169// Manifest helpers
3170// ---------------------------------------------------------------------------
3171
3172pub(crate) fn manifest_path(
3173    project_root: &Path,
3174    home: &Path,
3175    scope: Scope,
3176    platforms: &[Platform],
3177) -> PathBuf {
3178    // Codex writes nothing project-local — its registration lives wherever the
3179    // Codex CLI keeps it — so a Codex-only install records itself under the
3180    // home directory whatever the scope, in its own file so it cannot collide
3181    // with a user-scope Claude Code manifest.
3182    if platforms == [Platform::Codex] {
3183        return home.join(".mushroomdb").join("install-manifest-codex.json");
3184    }
3185    if scope == Scope::User {
3186        return home.join(".mushroomdb").join("install-manifest.json");
3187    }
3188    // Project scope: prefer the Claude Code location; fall back to Cursor.
3189    if platforms.contains(&Platform::ClaudeCode) {
3190        project_root
3191            .join(".claude")
3192            .join("skills")
3193            .join("mushroom")
3194            .join(".install-manifest.json")
3195    } else {
3196        project_root.join(".cursor").join(".install-manifest.json")
3197    }
3198}
3199
3200/// Load an existing manifest from `path`. Returns an empty manifest if absent or unparseable.
3201///
3202/// A `.gitignore` is never a file this install may delete outright, whatever a
3203/// manifest says. An earlier 0.6.0 build recorded a `.gitignore` it created in
3204/// `files`, which the uninstall file loop removes unconditionally — taking any
3205/// line the user had added to it since. The `gitignore` entry in the same
3206/// manifest already carries the one line that is ours, and that is the only
3207/// route by which the file may be touched, so the stale `files` entry is
3208/// dropped on the way in.
3209fn load_manifest(path: &Path) -> Manifest {
3210    let raw = match fs::read_to_string(path) {
3211        Ok(s) => s,
3212        Err(_) => return Manifest::default(),
3213    };
3214    serde_json::from_str::<Manifest>(&raw)
3215        .unwrap_or_default()
3216        .sanitised()
3217}
3218
3219/// The door an install opened for the store at `db_dir`.
3220///
3221/// The `brief` hook is handed a store, not an install, and its last line says
3222/// how to reach the graph — so it has to know whether there is a server to
3223/// name. A project install keeps its manifest beside the skill it wrote, one
3224/// level up from a default store; a user install keeps it beside the store
3225/// itself. No manifest there means [`Delivery::Both`]: the reach line names
3226/// both doors, which is what every install before this flag wired, and naming
3227/// a door too many costs a reader a moment where naming too few would cost
3228/// them the graph.
3229pub fn delivery_for_store(db_dir: &Path) -> Delivery {
3230    let Some(parent) = db_dir.parent() else {
3231        return Delivery::default();
3232    };
3233    let candidates = [
3234        parent
3235            .join(".claude")
3236            .join("skills")
3237            .join("mushroom")
3238            .join(".install-manifest.json"),
3239        parent.join("install-manifest.json"),
3240    ];
3241    for candidate in candidates {
3242        if candidate.is_file() {
3243            return load_manifest(&candidate).delivery;
3244        }
3245    }
3246    Delivery::default()
3247}
3248
3249/// Whether an install at this scope has been turned off by `disable`. `false`
3250/// for a scope with no manifest at all — `doctor` falls through to its normal
3251/// "no config entry" checks in that case rather than reporting a disabled
3252/// state that was never installed.
3253pub(crate) fn is_disabled(
3254    project_root: &Path,
3255    home: &Path,
3256    scope: Scope,
3257    platforms: &[Platform],
3258) -> bool {
3259    load_manifest(&manifest_path(project_root, home, scope, platforms)).disabled
3260}
3261
3262/// Whether an install at this scope asked for the experimental grep redirect.
3263/// `false` for a scope with no manifest, and for every manifest written before
3264/// the flag existed — `doctor` reports the hook only where one was asked for.
3265pub(crate) fn intercept_installed(
3266    project_root: &Path,
3267    home: &Path,
3268    scope: Scope,
3269    platforms: &[Platform],
3270) -> bool {
3271    load_manifest(&manifest_path(project_root, home, scope, platforms)).intercept_grep
3272}
3273
3274/// Union `existing` with `this_run`, deduplicating by path (files, git hooks),
3275/// by (file, server) pair (mcp_keys), and by full equality (hooks, lines).
3276/// Entries from `this_run` win on collision so the manifest always reflects
3277/// the latest state.
3278fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
3279    for f in &this_run.files {
3280        if !existing.files.contains(f) {
3281            existing.files.push(f.clone());
3282        }
3283    }
3284    for k in &this_run.mcp_keys {
3285        let already = existing
3286            .mcp_keys
3287            .iter()
3288            .any(|e| e.file == k.file && e.server == k.server);
3289        if !already {
3290            existing.mcp_keys.push(k.clone());
3291        }
3292    }
3293    for h in &this_run.hooks {
3294        if !existing.hooks.contains(h) {
3295            existing.hooks.push(h.clone());
3296        }
3297    }
3298    for h in &this_run.git_hooks {
3299        if !existing.git_hooks.contains(h) {
3300            existing.git_hooks.push(h.clone());
3301        }
3302    }
3303    for l in &this_run.gitignore {
3304        if !existing.gitignore.contains(l) {
3305            existing.gitignore.push(l.clone());
3306        }
3307    }
3308    existing.codex |= this_run.codex;
3309    if let Some(c) = &this_run.requested_cmd {
3310        existing.requested_cmd = Some(c.clone());
3311    }
3312    // The latest run's door wins: re-installing with a different `--delivery`
3313    // is how a user changes it, and the manifest has to describe what is on
3314    // disk now, not what an earlier run put there.
3315    existing.delivery = this_run.delivery;
3316    // Same rule for the redirect (and `run_install_with` prunes the hook entry
3317    // when the latest run turned it off).
3318    existing.intercept_grep = this_run.intercept_grep;
3319    existing
3320}
3321
3322fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
3323    let parent = path.parent().unwrap_or(Path::new("."));
3324    fs::create_dir_all(parent).map_err(|e| {
3325        CliError(format!(
3326            "cannot create manifest dir {}: {e}",
3327            parent.display()
3328        ))
3329    })?;
3330    let json = serde_json::to_string_pretty(manifest)
3331        .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
3332    fs::write(path, json)
3333        .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
3334    Ok(())
3335}
3336
3337// ---------------------------------------------------------------------------
3338// Git hook block — `mushroomdb sync` after every commit
3339// ---------------------------------------------------------------------------
3340//
3341// A git hook file belongs to the repository owner, not to us. Everything below
3342// therefore edits one marked region and nothing else: the region is rewritten
3343// in place when it changes, and removing it restores the user's lines exactly.
3344// The pure text transforms are split out from the filesystem wrappers so the
3345// merge and removal rules can be reasoned about — and tested — without a disk.
3346
3347/// Opening marker of the region this module owns inside a git hook.
3348pub const HOOK_BEGIN: &str = "# >>> mushroomdb >>>";
3349/// Closing marker of that region.
3350pub const HOOK_END: &str = "# <<< mushroomdb <<<";
3351/// Written as the first line when we create a hook file ourselves.
3352const HOOK_SHEBANG: &str = "#!/bin/sh";
3353
3354/// The block a git hook runs: one backgrounded, silenced `sync`.
3355///
3356/// Backgrounded (`( … & )` in a subshell, so no job-control notice reaches the
3357/// terminal) because a hook must not make `git commit` wait on a graph
3358/// refresh, and silenced because a hook that prints — or fails — on a store
3359/// that is momentarily busy would be noise on every commit. `sync` exits 3 when
3360/// another process holds the write lock, and the next commit picks the work up.
3361///
3362/// `shell` is the already-quoted command prefix from [`McpCommand::shell`];
3363/// the store contributes either `--auto` or its own quoted path, since a path
3364/// with a space in it would otherwise be word-split into two arguments.
3365///
3366/// `--auto` is what makes the block correct in a `git worktree`. Git runs a
3367/// hook with the working tree it acted on as the working directory, so `sync`
3368/// walks up from there to that tree's own root and updates that tree's own
3369/// store — the same block, committed once, doing the right thing in every
3370/// checkout of the repository.
3371#[must_use]
3372pub fn git_hook_block(shell: &str, store: &StoreRef) -> String {
3373    format!(
3374        "{HOOK_BEGIN}\n( {shell} sync {} >/dev/null 2>&1 & )\n{HOOK_END}\n",
3375        store.shell_arg()
3376    )
3377}
3378
3379/// What [`strip_hook_block`] found in a hook file.
3380enum Stripped {
3381    /// No opening marker: every line belongs to whoever wrote the file.
3382    Absent,
3383    /// A complete region was removed; this is what is left.
3384    Removed(String),
3385    /// An opening marker with no closing marker. Where our region ends is
3386    /// unknowable, so nothing may be removed.
3387    Unterminated,
3388}
3389
3390/// `text` with our marked region removed.
3391///
3392/// Blank lines left dangling at the end are dropped, so a merge followed by a
3393/// removal returns the original bytes rather than the original plus the blank
3394/// separator the merge inserted.
3395///
3396/// An opening marker with no closing marker is [`Stripped::Unterminated`]
3397/// rather than "ours to the end of the file". Someone hand-edited the region,
3398/// and the lines below the opening marker are now as likely to be theirs as
3399/// ours — a `make lint` they added under it would be deleted by the guess.
3400/// Both public helpers turn this into an error and write nothing, which is the
3401/// same rule the rest of this module follows for a config file whose shape it
3402/// does not recognise.
3403fn strip_hook_block(text: &str) -> Stripped {
3404    let mut kept: Vec<&str> = Vec::new();
3405    let mut inside = false;
3406    let mut found = false;
3407    for line in text.lines() {
3408        if !inside && line.trim_end() == HOOK_BEGIN {
3409            inside = true;
3410            found = true;
3411            continue;
3412        }
3413        if inside {
3414            if line.trim_end() == HOOK_END {
3415                inside = false;
3416            }
3417            continue;
3418        }
3419        kept.push(line);
3420    }
3421    if !found {
3422        return Stripped::Absent;
3423    }
3424    if inside {
3425        return Stripped::Unterminated;
3426    }
3427    while kept.last().is_some_and(|l| l.trim().is_empty()) {
3428        kept.pop();
3429    }
3430    let mut out = kept.join("\n");
3431    if !out.is_empty() {
3432        out.push('\n');
3433    }
3434    Stripped::Removed(out)
3435}
3436
3437/// The error both helpers return for [`Stripped::Unterminated`].
3438fn unterminated(hook_file: &Path) -> CliError {
3439    CliError(format!(
3440        "{}: a mushroomdb block opens with `{HOOK_BEGIN}` but never closes \
3441         — refusing to edit it; delete the block by hand and re-run",
3442        hook_file.display()
3443    ))
3444}
3445
3446/// What the hook file should contain once `block` is in it.
3447///
3448/// Idempotent by construction: any existing region is stripped first and the
3449/// fresh one appended, so a re-merge of the same block reproduces the same
3450/// bytes and a merge of a *different* block rewrites in place instead of
3451/// stacking a second region.
3452fn merged_hook_text(existing: Option<&str>, block: &str) -> Result<String, ()> {
3453    let base = match existing {
3454        None => String::new(),
3455        Some(text) => match strip_hook_block(text) {
3456            Stripped::Absent => text.to_string(),
3457            Stripped::Removed(rest) => rest,
3458            Stripped::Unterminated => return Err(()),
3459        },
3460    };
3461    let mut lines: Vec<&str> = base.lines().collect();
3462    while lines.last().is_some_and(|l| l.trim().is_empty()) {
3463        lines.pop();
3464    }
3465    // A file we are creating needs an interpreter line; one the user wrote
3466    // already has whichever they chose, and we must not add a second.
3467    if lines.is_empty() {
3468        lines.push(HOOK_SHEBANG);
3469    }
3470    let mut out = lines.join("\n");
3471    out.push_str("\n\n");
3472    out.push_str(block);
3473    Ok(out)
3474}
3475
3476/// Whether `text` is nothing but an interpreter line — the shape a hook file we
3477/// created is left in once our region is stripped out of it.
3478fn only_a_shebang(text: &str) -> bool {
3479    text.lines()
3480        .filter(|l| !l.trim().is_empty())
3481        .all(|l| l.starts_with("#!"))
3482}
3483
3484/// Put the sync block in `hook_file`, creating the file (mode 755, with a
3485/// `#!/bin/sh` line) if it is not there. Returns whether anything changed.
3486///
3487/// Every line the user has in the file is preserved, and running this twice
3488/// with the same arguments writes nothing the second time. A file whose
3489/// mushroomdb block was hand-edited so its closing marker is gone is an error
3490/// and is left byte-for-byte alone; see [`strip_hook_block`].
3491pub fn merge_git_hook(hook_file: &Path, shell: &str, store: &StoreRef) -> Result<bool, CliError> {
3492    let existing = if hook_file.exists() {
3493        Some(
3494            fs::read_to_string(hook_file)
3495                .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?,
3496        )
3497    } else {
3498        None
3499    };
3500    let next = merged_hook_text(existing.as_deref(), &git_hook_block(shell, store))
3501        .map_err(|()| unterminated(hook_file))?;
3502    if existing.as_deref() == Some(next.as_str()) {
3503        return Ok(false);
3504    }
3505    let parent = hook_file.parent().unwrap_or(Path::new("."));
3506    fs::create_dir_all(parent)
3507        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
3508    fs::write(hook_file, &next)
3509        .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
3510    #[cfg(unix)]
3511    {
3512        use std::os::unix::fs::PermissionsExt;
3513        // git ignores a hook that is not executable, so this is not cosmetic.
3514        fs::set_permissions(hook_file, fs::Permissions::from_mode(0o755)).map_err(|e| {
3515            CliError(format!(
3516                "cannot make {} executable: {e}",
3517                hook_file.display()
3518            ))
3519        })?;
3520    }
3521    Ok(true)
3522}
3523
3524/// Take the sync block back out of `hook_file`. Returns whether anything
3525/// changed.
3526///
3527/// The file itself is deleted only when nothing but an interpreter line is
3528/// left, which is exactly the state a hook *we* created is in — a hook the user
3529/// wrote has their lines in it and is rewritten rather than removed. An empty
3530/// stub of theirs would be deleted too, which git cannot tell apart from the
3531/// stub never having existed.
3532///
3533/// An unterminated block is an error and the file is left alone; see
3534/// [`strip_hook_block`].
3535pub fn remove_git_hook(hook_file: &Path) -> Result<bool, CliError> {
3536    if !hook_file.exists() {
3537        return Ok(false);
3538    }
3539    let existing = fs::read_to_string(hook_file)
3540        .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?;
3541    let next = match strip_hook_block(&existing) {
3542        // None of it is ours; leave the file untouched.
3543        Stripped::Absent => return Ok(false),
3544        Stripped::Removed(rest) => rest,
3545        Stripped::Unterminated => return Err(unterminated(hook_file)),
3546    };
3547    if only_a_shebang(&next) {
3548        fs::remove_file(hook_file)
3549            .map_err(|e| CliError(format!("cannot remove {}: {e}", hook_file.display())))?;
3550        return Ok(true);
3551    }
3552    fs::write(hook_file, next)
3553        .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
3554    Ok(true)
3555}
3556
3557// ---------------------------------------------------------------------------
3558// Utilities
3559// ---------------------------------------------------------------------------
3560
3561/// True if the file exists and its content equals `expected`.
3562fn file_matches(path: &Path, expected: &str) -> bool {
3563    fs::read_to_string(path)
3564        .map(|s| s == expected)
3565        .unwrap_or(false)
3566}