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 two
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/// Options parsed from `mushroomdb install [flags]` or `mushroomdb uninstall [flags]`.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct InstallOpts {
454    /// Which platform to wire up. `None` = auto-detect.
455    pub platform: Option<Platform>,
456    /// Project or user scope. `None` = auto: project inside a git checkout.
457    pub scope: Option<Scope>,
458    /// Database directory. `None` = use the scope default.
459    pub db: Option<PathBuf>,
460    /// `--command <path>`: invoke this binary instead of `npx`/the bare name.
461    pub command: Option<PathBuf>,
462    /// Write the `post-commit` / `post-checkout` / `post-merge` sync hooks.
463    pub git_hooks: bool,
464    /// Run `npx -y mushroomdb@<v> --version` once so the first real spawn is
465    /// not a cold download.
466    pub prewarm: bool,
467}
468
469/// Options parsed from `mushroomdb enable [flags]` or `mushroomdb disable [flags]`.
470///
471/// Deliberately narrower than [`InstallOpts`]: neither command takes `--db`,
472/// `--command` or `--no-git-hooks` — they act on whatever an existing install
473/// already recorded, not on a fresh choice of store or binary.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct ToggleOpts {
476    /// Which platform to act on. `None` = auto-detect, same as `install`.
477    pub platform: Option<Platform>,
478    /// Project or user scope. `None` = auto: project inside a git checkout.
479    pub scope: Option<Scope>,
480}
481
482/// The store directory an install with no `--db` uses.
483#[must_use]
484pub fn default_db(scope: Scope, project_root: &Path, home: &Path) -> PathBuf {
485    match scope {
486        Scope::Project => project_root.join("mushroom-memory"),
487        Scope::User => home.join(".mushroomdb").join("memory"),
488    }
489}
490
491/// Whether this platform's host guarantees `--auto` resolves to this project.
492///
493/// Only Claude Code does. It sets `$CLAUDE_PROJECT_DIR` for both MCP servers
494/// and hook processes, so the first resolution step always answers, whatever
495/// working directory the process happens to have.
496///
497/// Cursor and Codex set no such variable. `--auto` there would rest entirely
498/// on the host spawning the server inside the checkout, and if it did not,
499/// resolution would fall through to `~/.mushroomdb/memory`: an empty store,
500/// with the `.gitignore` line and the rules file both naming a different
501/// directory, and nothing anywhere reporting an error. The assistant would
502/// simply see a graph with nothing in it. So those two get the path.
503///
504/// The worktree argument is weaker for them in any case. `.mcp.json` and the
505/// two settings hooks are Claude Code's, and they are what a `git worktree`
506/// carries across; a Cursor install's committed artifact is one rules file
507/// that names the store in prose.
508fn resolves_at_runtime(platform: &Platform) -> bool {
509    match platform {
510        Platform::ClaudeCode => true,
511        Platform::Cursor | Platform::Codex => false,
512        // `expand_platform` never produces it; false is the safe reading.
513        Platform::All => false,
514    }
515}
516
517/// How each requested platform will name the store, in the order they were
518/// asked for.
519fn platform_stores(
520    project_root: &Path,
521    home: &Path,
522    scope: Scope,
523    db: Option<&Path>,
524    platforms: &[Platform],
525) -> Vec<(Platform, StoreRef)> {
526    platforms
527        .iter()
528        .map(|p| {
529            (
530                p.clone(),
531                store_ref(project_root, home, scope, db, resolves_at_runtime(p)),
532            )
533        })
534        .collect()
535}
536
537/// The summary's `store` line(s).
538///
539/// One line when every platform names the store the same way, which is every
540/// single-platform install and most `--platform all` ones. When they differ —
541/// Claude Code resolving `--auto` beside a Cursor entry that cannot — each is
542/// labelled, because "which one is pinned" is exactly what a reader needs.
543fn describe_stores(stores: &[(Platform, StoreRef)]) -> String {
544    let all_same = stores.windows(2).all(|w| w[0].1 == w[1].1);
545    match stores.first() {
546        None => String::new(),
547        Some((_, first)) if all_same => format!("  store  {}\n", first.describe()),
548        _ => stores
549            .iter()
550            .map(|(p, s)| format!("  store  {}: {}\n", p.label(), s.describe()))
551            .collect(),
552    }
553}
554
555/// The store the *repository* wiring names: the `.gitignore` line and the
556/// three git hook blocks.
557///
558/// `--auto` is safe here on its own terms, whatever platform asked for the
559/// install: git runs a hook with the working tree it acted on as the working
560/// directory, so the store resolves from that tree with no assistant, and no
561/// `$CLAUDE_PROJECT_DIR`, involved. It is written when any installed platform
562/// writes it, so the git hooks and the assistant's own config agree — and
563/// pinned otherwise, so a Cursor-only install is one store spelled one way.
564fn repo_store_ref(
565    project_root: &Path,
566    home: &Path,
567    scope: Scope,
568    db: Option<&Path>,
569    platforms: &[Platform],
570) -> StoreRef {
571    let runtime_ok = platforms.iter().any(resolves_at_runtime);
572    store_ref(project_root, home, scope, db, runtime_ok)
573}
574
575/// How one platform will name the store in everything written for it.
576///
577/// `--auto` is written only where it provably resolves to the same directory:
578/// the default store, in project scope, inside a git checkout, for a host that
579/// resolves it (`runtime_ok`, from [`resolves_at_runtime`]). The checkout
580/// condition is what makes the fallback safe — a hook that never receives
581/// `$CLAUDE_PROJECT_DIR` still finds the store by walking up to the working
582/// tree root, and there is no working tree root to find without it. Everywhere
583/// else the path is pinned, because a wrong `--auto` would silently build a
584/// second store under the home directory and report nothing.
585fn store_ref(
586    project_root: &Path,
587    home: &Path,
588    scope: Scope,
589    db: Option<&Path>,
590    runtime_ok: bool,
591) -> StoreRef {
592    let default = default_db(scope, project_root, home);
593    let Some(pinned) = db.map(|d| absolutise(d, project_root)) else {
594        if runtime_ok && scope == Scope::Project && project_root.join(".git").exists() {
595            return StoreRef::auto(default);
596        }
597        // Pinned, but `--auto` would still name this same directory in project
598        // scope — so a `--auto` entry an earlier build wrote here is this
599        // install's to rewrite rather than a conflicting one to refuse.
600        let pinned = StoreRef::pinned(default);
601        return if scope == Scope::Project {
602            pinned.also_auto()
603        } else {
604            pinned
605        };
606    };
607    // A `--db` naming the very store `--auto` resolves to is still pinned —
608    // the user asked for a path — but an `--auto` hook from an earlier install
609    // points at the same place and is this install's to replace.
610    let auto_here = default_db(Scope::Project, project_root, home);
611    if pinned == auto_here {
612        return StoreRef::pinned(pinned).also_auto();
613    }
614    StoreRef::pinned(pinned)
615}
616
617/// Resolve the scope, and say whether it was inferred.
618///
619/// A git checkout is a project: its store belongs beside it, is ignored by the
620/// repository, and its hooks fire on its commits. Anywhere else there is no
621/// project to scope to, so the install is the user's.
622pub(crate) fn resolve_scope(project_root: &Path, requested: Option<Scope>) -> (Scope, bool) {
623    match requested {
624        Some(s) => (s, false),
625        None if project_root.join(".git").exists() => (Scope::Project, true),
626        None => (Scope::User, true),
627    }
628}
629
630// ---------------------------------------------------------------------------
631// External programs
632// ---------------------------------------------------------------------------
633
634/// The world outside the two directories install is given: the programs it
635/// shells out to (`codex`, `npx`) and how long it will wait for them.
636///
637/// Carried explicitly rather than read from the process environment at the
638/// point of use, so a test can point PATH at a directory of stand-ins without
639/// mutating global state that its neighbours share.
640#[derive(Debug, Clone)]
641pub struct Externals {
642    /// PATH used to resolve external programs. `None` resolves nothing.
643    pub path: Option<OsString>,
644    /// Budget for the pre-warm.
645    pub prewarm_timeout: Duration,
646}
647
648impl Externals {
649    /// The real process environment.
650    #[must_use]
651    pub fn from_env() -> Self {
652        Self::with_path(std::env::var_os("PATH"))
653    }
654
655    /// The same, with an explicit PATH.
656    #[must_use]
657    pub fn with_path(path: Option<OsString>) -> Self {
658        Self {
659            path,
660            prewarm_timeout: Duration::from_secs(PREWARM_TIMEOUT_SECS),
661        }
662    }
663
664    /// The first executable named `program` on this PATH.
665    pub(crate) fn which(&self, program: &str) -> Option<PathBuf> {
666        let path = self.path.as_ref()?;
667        std::env::split_paths(path)
668            .map(|dir| dir.join(program))
669            .find(|c| is_executable(c))
670    }
671}
672
673fn is_executable(path: &Path) -> bool {
674    let Ok(meta) = fs::metadata(path) else {
675        return false;
676    };
677    if !meta.is_file() {
678        return false;
679    }
680    #[cfg(unix)]
681    {
682        use std::os::unix::fs::PermissionsExt;
683        meta.permissions().mode() & 0o111 != 0
684    }
685    #[cfg(not(unix))]
686    {
687        true
688    }
689}
690
691/// Run `bin` to completion, returning its stderr (trimmed) on a non-zero exit.
692fn run_and_capture(bin: &Path, args: &[String]) -> Result<(), String> {
693    let out = std::process::Command::new(bin)
694        .args(args)
695        .output()
696        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
697    if out.status.success() {
698        return Ok(());
699    }
700    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
701    let detail = if stderr.is_empty() {
702        String::new()
703    } else {
704        format!(": {stderr}")
705    };
706    Err(format!(
707        "{} {} exited with {}{detail}",
708        bin.display(),
709        args.join(" "),
710        out.status
711    ))
712}
713
714/// Run `bin`, giving up after `timeout`. Output is discarded — only the exit
715/// status matters — so the child cannot block on a pipe nobody drains.
716fn run_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<(), String> {
717    let mut child = std::process::Command::new(bin)
718        .args(args)
719        .stdin(std::process::Stdio::null())
720        .stdout(std::process::Stdio::null())
721        .stderr(std::process::Stdio::null())
722        .spawn()
723        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
724    let deadline = Instant::now() + timeout;
725    loop {
726        match child.try_wait() {
727            Ok(Some(status)) if status.success() => return Ok(()),
728            Ok(Some(status)) => return Err(format!("exited with {status}")),
729            Ok(None) => {}
730            Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
731        }
732        if Instant::now() >= deadline {
733            let _ = child.kill();
734            let _ = child.wait();
735            return Err(format!("timed out after {}s", timeout.as_secs()));
736        }
737        std::thread::sleep(Duration::from_millis(25));
738    }
739}
740
741/// Run `bin`, capturing stdout and giving up after `timeout`.
742///
743/// The pipe is drained on another thread so a child that writes more than a
744/// pipe buffer cannot deadlock against the timeout loop watching it.
745fn capture_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<String, String> {
746    let mut child = std::process::Command::new(bin)
747        .args(args)
748        .stdin(std::process::Stdio::null())
749        .stdout(std::process::Stdio::piped())
750        .stderr(std::process::Stdio::null())
751        .spawn()
752        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
753    let mut stdout = child.stdout.take().expect("stdout is piped");
754    let (tx, rx) = std::sync::mpsc::channel::<String>();
755    std::thread::spawn(move || {
756        use std::io::Read as _;
757        let mut out = String::new();
758        let _ = stdout.read_to_string(&mut out);
759        let _ = tx.send(out);
760    });
761    let deadline = Instant::now() + timeout;
762    loop {
763        match child.try_wait() {
764            Ok(Some(status)) if status.success() => {
765                return Ok(rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default());
766            }
767            Ok(Some(status)) => return Err(format!("exited with {status}")),
768            Ok(None) if Instant::now() >= deadline => {
769                let _ = child.kill();
770                let _ = child.wait();
771                return Err(format!("timed out after {}s", timeout.as_secs()));
772            }
773            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
774            Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
775        }
776    }
777}
778
779/// Ask the published package where something of its own is, once.
780///
781/// `flag` is [`PRINT_BINARY_FLAG`] or [`PRINT_LAUNCHER_FLAG`]; the package
782/// answers with an absolute path and exits. Writing that path into the hooks
783/// takes the whole npx resolution — cache check, version resolve, an extra Node
784/// process — off the per-prompt and per-edit path.
785///
786/// Chosen over `npm root -g` (only finds a *global* install, which the npx
787/// route never makes) and over `npm exec --offline` (still pays npm's own
788/// startup on every call). Asking the package itself is the one answer that is
789/// correct for however it was installed, and it is the same fetch the pre-warm
790/// already ran, so it costs an install nothing extra.
791///
792/// Every failure is recoverable: the caller falls back a step.
793fn ask_package(version: &str, flag: &str, ext: &Externals) -> Result<PathBuf, String> {
794    let npx = ext
795        .which("npx")
796        .ok_or_else(|| "npx is not on PATH".to_string())?;
797    let args = vec![
798        "-y".to_string(),
799        format!("{NPM_PACKAGE}@{version}"),
800        flag.to_string(),
801    ];
802    let out = capture_with_timeout(&npx, &args, ext.prewarm_timeout)?;
803    // The last non-blank line: npm is entitled to print notices before it.
804    let path = out
805        .lines()
806        .map(str::trim)
807        .rfind(|l| !l.is_empty())
808        .ok_or_else(|| format!("{NPM_PACKAGE}@{version} {flag} printed nothing"))?;
809    let path = PathBuf::from(path);
810    if !path.is_absolute() {
811        return Err(format!("{} is not an absolute path", path.display()));
812    }
813    if !path.is_file() {
814        return Err(format!("{} does not exist", path.display()));
815    }
816    Ok(path)
817}
818
819/// Turn an `npx` command into a resolved, directly-runnable one, so the hooks
820/// it writes do not spawn `npx` on every invocation.
821///
822/// Three rungs, best first:
823///
824/// 1. **The native binary** (`--print-binary`). What every other form ends up
825///    running anyway, reached without a Node startup in front of it.
826/// 2. **`node <launcher>`** (`--print-launcher`). For a package whose vendored
827///    binary was never fetched, and only when `node` is on PATH to run it.
828/// 3. **`npx`**, unchanged, with a warning saying the hooks will be slow.
829///
830/// Returns the command to write; the one-line warning for the summary when
831/// every rung failed; and whether the package was fetched on the way, which
832/// tells the caller the separate pre-warm has nothing left to do. Anything
833/// other than the `npx` form is already a direct path and is handed back
834/// untouched.
835fn resolve_fast_command(cmd: &McpCommand, ext: &Externals) -> (McpCommand, Option<String>, bool) {
836    let McpCommand::Npx { version } = cmd else {
837        return (cmd.clone(), None, false);
838    };
839    // Asking the package anything downloads it first, so one question warms
840    // the cache exactly as the pre-warm's `--version` would. If `npx` is not
841    // there to ask, nothing was fetched and the pre-warm's own report of that
842    // is worth having.
843    let fetched = ext.which("npx").is_some();
844    let binary_err = match ask_package(version, PRINT_BINARY_FLAG, ext) {
845        Ok(binary) => {
846            return (
847                McpCommand::NativeBinary {
848                    binary,
849                    version: version.clone(),
850                },
851                None,
852                fetched,
853            )
854        }
855        Err(e) => e,
856    };
857    // No binary. The launcher is the same package one Node startup away, and
858    // it is only worth writing if `node` is there to run it.
859    if ext.which(NODE_BIN).is_some() {
860        if let Ok(launcher) = ask_package(version, PRINT_LAUNCHER_FLAG, ext) {
861            return (
862                McpCommand::NodeLauncher {
863                    launcher,
864                    version: version.clone(),
865                },
866                None,
867                fetched,
868            );
869        }
870    }
871    (
872        cmd.clone(),
873        Some(format!(
874            "warning: could not resolve {NPM_PACKAGE}@{version} to a path ({binary_err}) — \
875             the hooks will spawn npx on every prompt and every edit"
876        )),
877        fetched,
878    )
879}
880
881// ---------------------------------------------------------------------------
882// Manifest — tracks everything install wrote so uninstall can undo it.
883// ---------------------------------------------------------------------------
884
885#[derive(Serialize, Deserialize, Default, Debug)]
886struct Manifest {
887    /// Files created by this install (absolute paths).
888    files: Vec<PathBuf>,
889    /// MCP JSON keys added by this install.
890    mcp_keys: Vec<ManagedMcpKey>,
891    /// Hook entries added to a settings.json by this install.
892    #[serde(default)]
893    hooks: Vec<ManagedHook>,
894    /// Git hook files this install put its block into.
895    #[serde(default)]
896    git_hooks: Vec<PathBuf>,
897    /// Single lines added to a file the user owns (the `.gitignore` entry).
898    #[serde(default)]
899    gitignore: Vec<ManagedLine>,
900    /// Whether a Codex MCP server was registered through the `codex` CLI.
901    #[serde(default)]
902    codex: bool,
903    /// Whether `disable` has turned this install off. The tracking fields
904    /// above (`mcp_keys`, `hooks`, `git_hooks`, `codex`) still describe what
905    /// the install owns even while disabled — `disable` does not clear them,
906    /// it only takes the config off disk and sets this flag — so `uninstall`
907    /// needs no disabled-aware branch of its own: every removal it attempts
908    /// is already a no-op for whatever `disable` already removed.
909    #[serde(default)]
910    disabled: bool,
911    /// The exact `mcpServers.mushroomdb` entry `disable` removed from each
912    /// file, captured byte-for-byte before the removal. `enable` reads the
913    /// store argument back out of these (see [`store_from_arg`]) rather than
914    /// replaying the entry itself — the command it writes is re-resolved
915    /// fresh, since the published package may have moved since `disable` ran.
916    #[serde(default)]
917    stashed_mcp: Vec<StashedMcpEntry>,
918    /// The command `install` (or the last successful `enable`) was asked to
919    /// write, *before* [`resolve_fast_command`] turned an `Npx` request into a
920    /// concrete native-binary or launcher path. `enable` reads this back so it
921    /// can tell an explicit `--command` pin apart from an `npx` resolution
922    /// that happened to land on the same shape of value (an absolute path) —
923    /// something the resolved JSON entry alone cannot distinguish. `None` only
924    /// for a manifest written before this field existed.
925    #[serde(default)]
926    requested_cmd: Option<StoredCommand>,
927}
928
929impl Manifest {
930    /// Drop entries no version of this install may act on as written. See
931    /// [`load_manifest`] for why a `.gitignore` in `files` is one of them.
932    fn sanitised(mut self) -> Self {
933        self.files
934            .retain(|f| f.file_name() != Some(OsStr::new(".gitignore")));
935        self
936    }
937
938    fn is_empty(&self) -> bool {
939        self.files.is_empty()
940            && self.mcp_keys.is_empty()
941            && self.hooks.is_empty()
942            && self.git_hooks.is_empty()
943            && self.gitignore.is_empty()
944            && !self.codex
945    }
946}
947
948/// One `mcpServers.<server>` entry [`run_disable_with`] took out of a config
949/// file, kept whole so [`entry_db`] can still read the store argument back out
950/// of it later.
951#[derive(Serialize, Deserialize, Debug, Clone)]
952struct StashedMcpEntry {
953    /// The JSON file the entry was removed from (absolute path).
954    file: PathBuf,
955    /// The key inside `mcpServers`.
956    server: String,
957    /// The entry itself, exactly as it read before removal.
958    entry: serde_json::Value,
959}
960
961/// The three requestable shapes of [`McpCommand`] — the ones a caller can ask
962/// for, as opposed to [`McpCommand::NativeBinary`]/[`McpCommand::NodeLauncher`],
963/// which only [`resolve_fast_command`] ever produces, by resolving an `Npx`
964/// request. Serializable so a manifest can carry it across a `disable`/`enable`
965/// round trip.
966#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
967enum StoredCommand {
968    Npx { version: String },
969    Explicit(PathBuf),
970    OnPath,
971}
972
973impl StoredCommand {
974    /// The request behind `cmd`, or `None` for a value only resolution
975    /// produces — there is nothing to remember about those beyond the `Npx`
976    /// request that led to them, which is captured before resolution runs.
977    fn from_mcp(cmd: &McpCommand) -> Option<Self> {
978        match cmd {
979            McpCommand::Npx { version } => Some(StoredCommand::Npx {
980                version: version.clone(),
981            }),
982            McpCommand::Explicit(p) => Some(StoredCommand::Explicit(p.clone())),
983            McpCommand::OnPath => Some(StoredCommand::OnPath),
984            McpCommand::NativeBinary { .. } | McpCommand::NodeLauncher { .. } => None,
985        }
986    }
987
988    fn into_mcp(self) -> McpCommand {
989        match self {
990            StoredCommand::Npx { version } => McpCommand::Npx { version },
991            StoredCommand::Explicit(p) => McpCommand::Explicit(p),
992            StoredCommand::OnPath => McpCommand::OnPath,
993        }
994    }
995}
996
997#[derive(Serialize, Deserialize, Debug, Clone)]
998struct ManagedMcpKey {
999    /// The JSON file the key was added to (absolute path).
1000    file: PathBuf,
1001    /// The key inside `mcpServers`.
1002    server: String,
1003}
1004
1005#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1006struct ManagedHook {
1007    /// The settings.json file the hook was added to (absolute path).
1008    file: PathBuf,
1009    /// The hook event name (e.g. `UserPromptSubmit`).
1010    event: String,
1011    /// The exact command string that was added.
1012    command: String,
1013}
1014
1015/// One line this install appended to a text file the user owns.
1016#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1017struct ManagedLine {
1018    /// The file the line was added to (absolute path).
1019    file: PathBuf,
1020    /// The exact line, without its newline.
1021    line: String,
1022    /// Whether the file itself did not exist before this install. Only such a
1023    /// file may be deleted on uninstall, and only if nothing is left in it.
1024    #[serde(default)]
1025    created: bool,
1026}
1027
1028/// Claude Code hook event this install wires: fires before each prompt is
1029/// sent, so the recall digest lands as context ahead of the user's turn.
1030pub(crate) const HOOK_EVENT: &str = "UserPromptSubmit";
1031/// Kept short: the hook must never noticeably slow a prompt.
1032const HOOK_TIMEOUT_SECS: u64 = 5;
1033
1034/// The second hook event: fires after a tool call, so an edit reaches the
1035/// graph while the assistant is still working rather than at the next commit.
1036pub(crate) const TOUCH_EVENT: &str = "PostToolUse";
1037/// The tools that change a file on disk. Anything else — a read, a search, a
1038/// shell command — leaves the working tree as the graph already has it.
1039const TOUCH_MATCHER: &str = "Edit|Write|MultiEdit";
1040/// Longer than the prompt hook's: re-extracting a file costs more than reading
1041/// a digest, and nothing is waiting on the answer. The run is `async`, so this
1042/// bounds a background process rather than the assistant's turn.
1043const TOUCH_TIMEOUT_SECS: u64 = 30;
1044
1045/// Single-quote `s` for embedding in a POSIX shell command line, escaping
1046/// embedded single quotes as `'\''`. Claude Code runs a `type: "command"`
1047/// hook through a shell, so an unquoted path containing whitespace or shell
1048/// metacharacters is word-split and the hook silently receives the wrong
1049/// arguments — quoting keeps the command exact.
1050fn sh_quote(s: &str) -> String {
1051    format!("'{}'", s.replace('\'', r"'\''"))
1052}
1053
1054/// The exact command string written into the hook entry. Both halves arrive
1055/// already quoted where quoting is needed.
1056fn recall_hook_command(shell: &str, store: &StoreRef) -> String {
1057    format!("{shell} recall {}", store.shell_arg())
1058}
1059
1060/// The exact command string written into the post-edit hook entry. `touch` in
1061/// hook mode prints nothing and exits 0 whatever it is handed.
1062fn touch_hook_command(shell: &str, store: &StoreRef) -> String {
1063    format!("{shell} touch {}", store.shell_arg())
1064}
1065
1066/// One `hooks.<event>` array entry in Claude Code's settings.json shape.
1067fn hook_entry(command: &str) -> serde_json::Value {
1068    serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
1069}
1070
1071/// The `PostToolUse` entry: matched to the file-editing tools, and `async` so
1072/// the assistant's tool call returns without waiting for the re-extraction.
1073fn touch_hook_entry(command: &str) -> serde_json::Value {
1074    serde_json::json!({
1075        "matcher": TOUCH_MATCHER,
1076        "hooks": [ {
1077            "type": "command",
1078            "command": command,
1079            "timeout": TOUCH_TIMEOUT_SECS,
1080            "async": true
1081        } ]
1082    })
1083}
1084
1085/// True if any hook group under `event` contains a command hook equal to `command`.
1086pub(crate) fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
1087    root["hooks"][event]
1088        .as_array()
1089        .map(|groups| {
1090            groups.iter().any(|g| {
1091                g["hooks"]
1092                    .as_array()
1093                    .map(|hs| hs.iter().any(|h| h["command"] == command))
1094                    .unwrap_or(false)
1095            })
1096        })
1097        .unwrap_or(false)
1098}
1099
1100/// Add one hook to `settings_file` (created if absent). Idempotent: no-op if
1101/// `command` is already present under `event`. Every other key in the file —
1102/// including other hook events and groups — is preserved. Errors out (no
1103/// write) rather than overwriting if `hooks` or `hooks.<event>` already exists
1104/// with an unexpected JSON type, or if the file's top level is not a JSON
1105/// object.
1106///
1107/// `entry` is the group to append, built by the caller: the two events this
1108/// install wires want different shapes, and only the caller knows which.
1109fn merge_hook_entry(
1110    settings_file: &Path,
1111    event: &str,
1112    command: &str,
1113    entry: serde_json::Value,
1114    manifest: &mut Manifest,
1115) -> Result<(), CliError> {
1116    let mut root: serde_json::Value = if settings_file.exists() {
1117        let raw = fs::read_to_string(settings_file)
1118            .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
1119        serde_json::from_str(&raw)
1120            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
1121    } else {
1122        serde_json::json!({})
1123    };
1124
1125    if !root.is_object() {
1126        return Err(CliError(format!(
1127            "{} is not a JSON object at its top level — refusing to add a hook",
1128            settings_file.display()
1129        )));
1130    }
1131
1132    if settings_has_hook(&root, event, command) {
1133        return Ok(());
1134    }
1135
1136    // Validate the shapes we are about to write into before touching
1137    // anything: a wrong-shaped `hooks` or `hooks.<event>` value belongs to
1138    // the user (or another tool) and must never be silently overwritten.
1139    match root.get("hooks") {
1140        None => root["hooks"] = serde_json::json!({}),
1141        Some(v) if v.is_object() => {}
1142        Some(_) => {
1143            return Err(CliError(format!(
1144                "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
1145                settings_file.display()
1146            )));
1147        }
1148    }
1149    match root["hooks"].get(event) {
1150        None => root["hooks"][event] = serde_json::json!([]),
1151        Some(v) if v.is_array() => {}
1152        Some(_) => {
1153            return Err(CliError(format!(
1154                "{}: \"hooks.{event}\" is not a JSON array — refusing to overwrite it",
1155                settings_file.display()
1156            )));
1157        }
1158    }
1159    root["hooks"][event].as_array_mut().unwrap().push(entry);
1160
1161    let parent = settings_file.parent().unwrap_or(Path::new("."));
1162    fs::create_dir_all(parent)
1163        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
1164    let json = serde_json::to_string_pretty(&root)
1165        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
1166    fs::write(settings_file, json)
1167        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
1168
1169    manifest.hooks.push(ManagedHook {
1170        file: settings_file.to_path_buf(),
1171        event: event.into(),
1172        command: command.into(),
1173    });
1174    Ok(())
1175}
1176
1177/// Remove exactly the hook groups whose only command is `command`; drop the
1178/// command from mixed groups; leave everything else semantically unchanged.
1179/// Returns whether the file was rewritten — that is, whether the hook was
1180/// there to remove at all.
1181fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<bool, CliError> {
1182    drop_hooks(settings_file, event, |c| c == command)
1183}
1184
1185/// Whether `command` is one of our hook bodies for `store`, whatever binary it
1186/// names.
1187///
1188/// The command prefix is exactly what changes between versions — 0.5.x wrote
1189/// the absolute path of a copied binary, 0.6.0 an `npx` pin, 0.6.1 a resolved
1190/// `node <launcher>`, a developer's `--command` a build path — so identity is
1191/// the tail: the subcommand and the store this install is wiring. Both
1192/// spellings of that store count, since `--auto` replaced a written path in
1193/// 0.6.1 and a hook left behind in the other spelling would run alongside the
1194/// new one. A hook naming a *different* store belongs to a different install
1195/// and is not ours to touch.
1196pub(crate) fn is_our_hook_command(command: &str, sub: &str, store: &StoreRef) -> bool {
1197    store
1198        .hook_tails(sub)
1199        .iter()
1200        .any(|tail| command.ends_with(tail))
1201}
1202
1203/// The same identity test for a line that does not *end* with the invocation:
1204/// a git hook block backgrounds it and redirects its output, so the store
1205/// argument sits in the middle of the line rather than at the end of it.
1206pub(crate) fn line_runs_for_store(line: &str, sub: &str, store: &StoreRef) -> bool {
1207    store.hook_tails(sub).iter().any(|tail| line.contains(tail))
1208}
1209
1210/// Take out every hook of ours for `event` that is not the one we are about
1211/// to write. Returns whether anything was removed.
1212///
1213/// Without this an upgrade appends: `merge_hook_entry` matches on the exact
1214/// command string, so a 0.5.x entry naming `~/.mushroomdb/bin/mushroomdb` is
1215/// not recognised, survives, and keeps running alongside the new one — two
1216/// recall digests injected on every prompt.
1217fn remove_stale_hooks(
1218    settings_file: &Path,
1219    event: &str,
1220    sub: &str,
1221    store: &StoreRef,
1222    desired: &str,
1223) -> Result<bool, CliError> {
1224    drop_hooks(settings_file, event, |c| {
1225        c != desired && is_our_hook_command(c, sub, store)
1226    })
1227}
1228
1229/// Drop every hook under `event` whose command satisfies `drop_it`, pruning
1230/// groups that end up empty. Returns whether the file was rewritten.
1231///
1232/// Reads `hooks.<event>` through immutable accessors first, so a settings
1233/// file where the user removed the `hooks` key (or `<event>`, or shaped
1234/// either as something other than an object/array) is left byte-for-byte
1235/// untouched rather than having a stray `null` written back in. Every other
1236/// key is preserved, though the file is re-serialized (comments are not
1237/// supported since `serde_json` is strict JSON).
1238fn drop_hooks(
1239    settings_file: &Path,
1240    event: &str,
1241    drop_it: impl Fn(&str) -> bool,
1242) -> Result<bool, CliError> {
1243    if !settings_file.exists() {
1244        return Ok(false);
1245    }
1246    let raw = fs::read_to_string(settings_file)
1247        .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
1248    let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
1249        CliError(format!(
1250            "corrupt settings json at {}: {e}",
1251            settings_file.display()
1252        ))
1253    })?;
1254
1255    let Some(mut groups) = root
1256        .get("hooks")
1257        .and_then(|h| h.get(event))
1258        .and_then(|g| g.as_array())
1259        .cloned()
1260    else {
1261        // No matching (or well-shaped) event array — nothing of ours to
1262        // remove; leave the file exactly as it is, no write at all.
1263        return Ok(false);
1264    };
1265
1266    for g in groups.iter_mut() {
1267        if let Some(hs) = g["hooks"].as_array_mut() {
1268            hs.retain(|h| !h["command"].as_str().is_some_and(&drop_it));
1269        }
1270    }
1271    groups.retain(|g| {
1272        g["hooks"]
1273            .as_array()
1274            .map(|hs| !hs.is_empty())
1275            .unwrap_or(true)
1276    });
1277
1278    let before = root.clone();
1279    if groups.is_empty() {
1280        root["hooks"].as_object_mut().unwrap().remove(event);
1281    } else {
1282        root["hooks"][event] = serde_json::Value::Array(groups);
1283    }
1284    if root == before {
1285        // The event array held none of our commands, so there is nothing to
1286        // remove. Writing anyway would re-serialize a file we do not own —
1287        // `serde_json` is built without `preserve_order`, so the user's key
1288        // order and indentation would be rewritten for no reason.
1289        return Ok(false);
1290    }
1291
1292    let json = serde_json::to_string_pretty(&root)
1293        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
1294    fs::write(settings_file, json)
1295        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
1296    Ok(true)
1297}
1298
1299// ---------------------------------------------------------------------------
1300// Public entry points
1301// ---------------------------------------------------------------------------
1302
1303/// Everything the write phase needs, gathered once so the per-step functions
1304/// stay readable.
1305struct Ctx<'a> {
1306    project_root: &'a Path,
1307    home: &'a Path,
1308    scope: Scope,
1309    /// The store the repository wiring names — the `.gitignore` line and the
1310    /// git hook blocks. Each platform's own config gets its own [`StoreRef`],
1311    /// passed to the per-platform writers, because only Claude Code can
1312    /// resolve `--auto`; see [`repo_store_ref`] and [`resolves_at_runtime`].
1313    repo_store: &'a StoreRef,
1314    cmd: &'a McpCommand,
1315    ext: &'a Externals,
1316    git_hooks: bool,
1317    prewarm: bool,
1318}
1319
1320/// Anchor a user-supplied path to `base` when it is relative, and drop any
1321/// `./` segments.
1322///
1323/// A relative `--command` or `--db` is convenient to type and wrong to store:
1324/// the assistant spawns the MCP server, and the hooks and git hooks run, from
1325/// whatever directory those processes happen to be in, not the one the install
1326/// was typed in. Nothing is canonicalized — resolving symlinks would rewrite
1327/// a path the user chose deliberately.
1328fn absolutise(path: &Path, base: &Path) -> PathBuf {
1329    let joined = if path.is_absolute() {
1330        path.to_path_buf()
1331    } else {
1332        base.join(path)
1333    };
1334    let mut out = PathBuf::new();
1335    for c in joined.components() {
1336        match c {
1337            std::path::Component::CurDir => {}
1338            other => out.push(other),
1339        }
1340    }
1341    out
1342}
1343
1344/// Whether `p` is a program name to be looked up on PATH rather than a file to
1345/// be anchored: one plain component, no separator, no `.` or `..`.
1346///
1347/// `--command mushroomdb` means "whatever `mushroomdb` PATH resolves to" and
1348/// stays that way in both forms we write — an MCP host resolves a bare
1349/// `command` on PATH, and quoting a bare name in a shell does not defeat the
1350/// lookup either. Anchoring it would invent `<cwd>/mushroomdb`, a file that
1351/// need not exist, and the install would report success over a server that
1352/// cannot spawn.
1353fn is_bare_program_name(p: &Path) -> bool {
1354    let mut components = p.components();
1355    matches!(
1356        (components.next(), components.next()),
1357        (Some(std::path::Component::Normal(_)), None)
1358    )
1359}
1360
1361/// Anchor a `--command` unless it is a bare program name.
1362fn absolutise_command(path: &Path, base: &Path) -> PathBuf {
1363    if is_bare_program_name(path) {
1364        path.to_path_buf()
1365    } else {
1366        absolutise(path, base)
1367    }
1368}
1369
1370/// Install the /mushroom skill and MCP server entry for the resolved platforms.
1371///
1372/// `project_root` is the directory where project-scope config files live
1373/// (`.mcp.json`, `.claude/`, `.cursor/`). `home` is the user HOME directory.
1374/// Tests pass temp directories for both; main.rs passes real values.
1375pub fn run_install(
1376    project_root: &Path,
1377    home: &Path,
1378    opts: &InstallOpts,
1379) -> Result<String, CliError> {
1380    run_install_with(
1381        project_root,
1382        home,
1383        opts,
1384        &detect_mcp_command(opts.command.as_deref()),
1385        &Externals::from_env(),
1386    )
1387}
1388
1389/// Like [`run_install`], but with the server command and the external
1390/// environment supplied by the caller instead of detected. Tests use this to
1391/// stay deterministic and offline; `run_install` is the real-environment
1392/// wrapper.
1393pub fn run_install_with(
1394    project_root: &Path,
1395    home: &Path,
1396    opts: &InstallOpts,
1397    cmd: &McpCommand,
1398    ext: &Externals,
1399) -> Result<String, CliError> {
1400    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1401    // Whatever the caller handed us, what gets written resolves from anywhere:
1402    // an absolute path, or a name PATH answers for.
1403    let cmd = match cmd {
1404        McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
1405        other => other.clone(),
1406    };
1407    // What was actually asked for, before resolution turns an `Npx` request
1408    // into a concrete path — `enable` reads this back later to tell an
1409    // explicit `--command` pin apart from a resolved `npx` path, which the
1410    // written JSON entry alone cannot distinguish (both are absolute paths).
1411    let requested_cmd = StoredCommand::from_mcp(&cmd);
1412    // Resolve the published package to a concrete launcher once, here, so no
1413    // hook has to. Skipped by `--no-prewarm`, which is the flag for "do not
1414    // reach the network during this install"; the `npx` form still works, it
1415    // is just slower on every invocation.
1416    let (cmd, launcher_note, package_fetched) = if opts.prewarm {
1417        resolve_fast_command(&cmd, ext)
1418    } else {
1419        (cmd, None, false)
1420    };
1421    let cmd = &cmd;
1422
1423    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1424    let platforms = expand_platform(&resolved);
1425
1426    // Each platform names the store in its own terms: only Claude Code can be
1427    // relied on to resolve `--auto`. The repository wiring gets its own, since
1428    // git resolves it without any assistant.
1429    let stores = platform_stores(project_root, home, scope, opts.db.as_deref(), &platforms);
1430    let repo_store = repo_store_ref(project_root, home, scope, opts.db.as_deref(), &platforms);
1431
1432    // Check for anything that would make this install fail halfway before
1433    // writing a single byte.
1434    for (plat, store) in &stores {
1435        preflight_check(project_root, home, plat, scope, store, ext)?;
1436    }
1437
1438    let ctx = Ctx {
1439        project_root,
1440        home,
1441        scope,
1442        repo_store: &repo_store,
1443        cmd,
1444        ext,
1445        git_hooks: opts.git_hooks,
1446        prewarm: opts.prewarm && !package_fetched,
1447    };
1448
1449    let manifest_path = manifest_path(project_root, home, scope, &platforms);
1450
1451    // Load the existing manifest so we can union it with what this run writes.
1452    // This covers partial-drift re-installs: if SKILL.md was edited but the MCP
1453    // entry is still intact, only the file is re-written this run; unioning
1454    // preserves the MCP key in the saved manifest so uninstall cleans it up too.
1455    let existing = load_manifest(&manifest_path);
1456    // `install` doubles as `enable` for a disabled install: it rewrites
1457    // everything `disable` took off disk the same as it would repair any
1458    // other drift, so the only extra step is clearing the flag once that
1459    // write lands, and saying so in the summary.
1460    let was_disabled = existing.disabled;
1461
1462    let mut manifest = Manifest {
1463        requested_cmd,
1464        ..Manifest::default()
1465    };
1466    let mut notes: Vec<String> = Vec::new();
1467    notes.extend(launcher_note);
1468    if was_disabled {
1469        notes.push("this install was disabled — install re-enabled it".to_string());
1470    }
1471
1472    let outcome = write_everything(&ctx, &stores, &mut manifest, &mut notes);
1473    if let Err(e) = outcome {
1474        // Persist whatever was already written (an earlier platform's files,
1475        // a git hook) so uninstall can still clean up after a partial
1476        // failure. Best effort: the original error wins.
1477        if !manifest.is_empty() {
1478            let merged = union_manifests(load_manifest(&manifest_path), &manifest);
1479            let _ = write_manifest(&manifest_path, &merged);
1480        }
1481        return Err(e);
1482    }
1483
1484    let anything_written = !manifest.is_empty();
1485    if anything_written || was_disabled {
1486        // Union this-run entries with the existing manifest (dedup by path/key).
1487        let mut merged = if anything_written {
1488            union_manifests(existing, &manifest)
1489        } else {
1490            existing
1491        };
1492        if was_disabled {
1493            merged.disabled = false;
1494            merged.stashed_mcp.clear();
1495        }
1496        write_manifest(&manifest_path, &merged)?;
1497    }
1498
1499    let labels: Vec<&str> = platforms.iter().map(Platform::label).collect();
1500    let mut out = format!("mushroomdb installed ({})\n", labels.join(", "));
1501    out.push_str(&format!(
1502        "  scope  {}{}\n",
1503        scope.label(),
1504        if auto_scope { " (auto-detected)" } else { "" }
1505    ));
1506    for f in &manifest.files {
1507        out.push_str(&format!("  wrote  {}\n", f.display()));
1508    }
1509    for k in &manifest.mcp_keys {
1510        out.push_str(&format!(
1511            "  added  mcpServers.{} in {}\n",
1512            k.server,
1513            k.file.display()
1514        ));
1515    }
1516    for h in &manifest.hooks {
1517        out.push_str(&format!(
1518            "  added  {} hook in {}\n",
1519            h.event,
1520            h.file.display()
1521        ));
1522    }
1523    for g in &manifest.gitignore {
1524        out.push_str(&format!("  added  {} to {}\n", g.line, g.file.display()));
1525    }
1526    for h in &manifest.git_hooks {
1527        out.push_str(&format!("  added  git hook {}\n", h.display()));
1528    }
1529    if manifest.codex {
1530        out.push_str(&format!("  added  codex mcp server {SERVER_NAME}\n"));
1531    }
1532    if anything_written {
1533        out.push_str(&format!("  manifest  {}\n", manifest_path.display()));
1534        out.push_str(&format!("  mcp command  {}\n", cmd.shell()));
1535        out.push_str(&describe_stores(&stores));
1536    } else {
1537        out.push_str("  (already installed — no changes)\n");
1538    }
1539    for n in &notes {
1540        out.push_str(&format!("  {n}\n"));
1541    }
1542    out.push_str(&format!(
1543        "next: restart Claude Code in {}, then type /mushroom\n",
1544        project_root.display()
1545    ));
1546    Ok(out)
1547}
1548
1549/// The whole write phase, so a failure anywhere in it still leaves the caller
1550/// holding the partial manifest.
1551fn write_everything(
1552    ctx: &Ctx<'_>,
1553    stores: &[(Platform, StoreRef)],
1554    manifest: &mut Manifest,
1555    notes: &mut Vec<String>,
1556) -> Result<(), CliError> {
1557    let platforms: Vec<Platform> = stores.iter().map(|(p, _)| p.clone()).collect();
1558    if let Some(w) = scope_conflict_note(ctx, &platforms) {
1559        notes.push(w);
1560    }
1561
1562    for (plat, store) in stores {
1563        install_platform(ctx, plat, store, manifest, notes)?;
1564    }
1565
1566    // The repository-level wiring is shared by the platforms whose config
1567    // lives in the repository: the store is ignored by git and the graph is
1568    // re-synced after commits whichever of them reads it.
1569    //
1570    // A Codex-only install is excluded. It writes nothing else project-local
1571    // (Codex keeps its own config, and the manifest for it lives under the
1572    // home directory), so an ignore line and three git hooks recorded there
1573    // would be removed by a `uninstall --platform codex` out from under a
1574    // Claude Code install that shares the repository and never recorded them.
1575    let repo_wiring = platforms
1576        .iter()
1577        .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
1578    if ctx.scope == Scope::Project && repo_wiring {
1579        ensure_gitignore_line(ctx, manifest)?;
1580        if ctx.git_hooks {
1581            install_git_hooks(ctx, manifest)?;
1582        }
1583    }
1584
1585    if let Some(w) = prewarm(ctx) {
1586        notes.push(w);
1587    }
1588    Ok(())
1589}
1590
1591/// Find the manifest for an existing install, the way `uninstall`, `disable`
1592/// and `enable` all need to: an inferred scope that turns up nothing falls
1593/// back to the other one before giving up, and giving up is an error naming
1594/// `verb`.
1595///
1596/// An inferred scope is a guess, and guessing wrong here means telling
1597/// someone with a perfectly good user-scope install that they have nothing to
1598/// act on — 0.5.x had no scope detection, so every install made by it inside a
1599/// checkout is exactly that case. A scope the user stated is not
1600/// second-guessed. Returns the scope actually used, since a fallback changes it.
1601fn locate_manifest(
1602    project_root: &Path,
1603    home: &Path,
1604    scope: Scope,
1605    auto_scope: bool,
1606    platforms: &[Platform],
1607    verb: &str,
1608) -> Result<(Scope, PathBuf), CliError> {
1609    let mut scope = scope;
1610    let mut path = manifest_path(project_root, home, scope, platforms);
1611    if auto_scope && !path.exists() {
1612        let other = match scope {
1613            Scope::Project => Scope::User,
1614            Scope::User => Scope::Project,
1615        };
1616        let alt = manifest_path(project_root, home, other, platforms);
1617        if alt.exists() {
1618            scope = other;
1619            path = alt;
1620        }
1621    }
1622    if !path.exists() {
1623        return Err(CliError(format!(
1624            "no install manifest found at {} — nothing to {verb}",
1625            path.display()
1626        )));
1627    }
1628    Ok((scope, path))
1629}
1630
1631/// Uninstall: remove exactly what install wrote. Reads the manifest.
1632pub fn run_uninstall(
1633    project_root: &Path,
1634    home: &Path,
1635    opts: &InstallOpts,
1636) -> Result<String, CliError> {
1637    run_uninstall_with(project_root, home, opts, &Externals::from_env())
1638}
1639
1640/// Like [`run_uninstall`], with the external environment supplied by the
1641/// caller (Codex removal shells out to the `codex` CLI).
1642pub fn run_uninstall_with(
1643    project_root: &Path,
1644    home: &Path,
1645    opts: &InstallOpts,
1646    ext: &Externals,
1647) -> Result<String, CliError> {
1648    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1649    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1650    let platforms = expand_platform(&resolved);
1651
1652    let (scope, manifest_path) = locate_manifest(
1653        project_root,
1654        home,
1655        scope,
1656        auto_scope,
1657        &platforms,
1658        "uninstall",
1659    )?;
1660    let manifest = load_manifest(&manifest_path);
1661
1662    let mut removed = Vec::new();
1663
1664    // Remove MCP keys first (before files, in case files include .mcp.json).
1665    // Each line is printed only for a removal that happened: after an upgrade
1666    // the manifest also lists the commands the upgrade already replaced, and
1667    // claiming to have removed those would be a report of work not done.
1668    for key in &manifest.mcp_keys {
1669        if remove_mcp_key(&key.file, &key.server)? {
1670            removed.push(format!(
1671                "removed  mcpServers.{} from {}",
1672                key.server,
1673                key.file.display()
1674            ));
1675        }
1676    }
1677
1678    // Remove hooks (before files, same reasoning as MCP keys).
1679    for h in &manifest.hooks {
1680        if remove_hook_entry(&h.file, &h.event, &h.command)? {
1681            removed.push(format!(
1682                "removed  {} hook from {}",
1683                h.event,
1684                h.file.display()
1685            ));
1686        }
1687    }
1688
1689    // Git hooks: the marked region only, never the user's own lines.
1690    for h in &manifest.git_hooks {
1691        if remove_git_hook(h)? {
1692            removed.push(format!("removed  git hook block from {}", h.display()));
1693        }
1694    }
1695
1696    // The ignore line, exactly as it was written. A file that exists only
1697    // because install created it goes too — but only when our line was all it
1698    // ever held; anything the user added to it since is theirs to keep.
1699    for g in &manifest.gitignore {
1700        if remove_line(&g.file, &g.line)? {
1701            removed.push(format!("removed  {} from {}", g.line, g.file.display()));
1702        }
1703        if g.created && g.file.exists() && file_is_blank(&g.file) {
1704            fs::remove_file(&g.file)
1705                .map_err(|e| CliError(format!("cannot remove {}: {e}", g.file.display())))?;
1706            removed.push(format!("removed  {}", g.file.display()));
1707        }
1708    }
1709
1710    // Codex holds its own config; hand the removal back to its CLI. Not being
1711    // able to reach `codex` must not strand every other thing the manifest
1712    // lists.
1713    if manifest.codex {
1714        remove_codex(ext, &mut removed, "removed")?;
1715    }
1716
1717    // Remove files.
1718    for f in &manifest.files {
1719        if f.exists() {
1720            fs::remove_file(f)
1721                .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
1722            removed.push(format!("removed  {}", f.display()));
1723        }
1724    }
1725
1726    // Remove the manifest itself.
1727    if manifest_path.exists() {
1728        fs::remove_file(&manifest_path)
1729            .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
1730    }
1731
1732    let mut out = "mushroomdb uninstalled\n".to_string();
1733    out.push_str(&format!(
1734        "  scope  {}{}\n",
1735        scope.label(),
1736        if auto_scope { " (auto-detected)" } else { "" }
1737    ));
1738    for line in &removed {
1739        out.push_str(&format!("  {line}\n"));
1740    }
1741    Ok(out)
1742}
1743
1744// ---------------------------------------------------------------------------
1745// Enable / disable — turn an install off without removing it
1746// ---------------------------------------------------------------------------
1747//
1748// `disable` takes the dynamic, per-assistant config off disk — the MCP entry,
1749// the two Claude Code hooks, the three git hook blocks, the Codex
1750// registration — and leaves everything a person might have customised or that
1751// the store depends on: the skill/rules file, the store itself, the
1752// `.gitignore` line. `enable` puts the config back, re-derived from whatever
1753// `install` would choose right now rather than replayed byte-for-byte, so an
1754// upgrade of the published package between the two calls is picked up instead
1755// of pinned to a path that may no longer resolve.
1756//
1757// Neither command clears `mcp_keys` / `hooks` / `git_hooks` / `codex` on the
1758// manifest — those keep describing what the install *owns*, disabled or not —
1759// which is what lets `uninstall` work unmodified from a disabled install: it
1760// already treats every removal as a no-op when there is nothing left to
1761// remove.
1762
1763fn scope_dir(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
1764    match scope {
1765        Scope::Project => project_root.to_path_buf(),
1766        Scope::User => home.to_path_buf(),
1767    }
1768}
1769
1770/// The `mcpServers.<server>` entry in `mcp_file`, if the file and the entry
1771/// both exist.
1772fn read_mcp_entry(mcp_file: &Path, server: &str) -> Result<Option<serde_json::Value>, CliError> {
1773    if !mcp_file.exists() {
1774        return Ok(None);
1775    }
1776    let raw = fs::read_to_string(mcp_file)
1777        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1778    let root: serde_json::Value = serde_json::from_str(&raw)
1779        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
1780    let entry = &root["mcpServers"][server];
1781    Ok(if entry.is_null() {
1782        None
1783    } else {
1784        Some(entry.clone())
1785    })
1786}
1787
1788/// Reconstruct the [`StoreRef`] an existing config argument names — the same
1789/// argument [`StoreRef::arg`] would have written. `enable` uses this to read
1790/// which store a stashed MCP entry (and the hooks wired alongside it) was for.
1791fn store_from_arg(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
1792    if arg == AUTO_ARG {
1793        return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
1794    }
1795    let path = PathBuf::from(arg);
1796    if path == default_db(Scope::Project, project_root, home) {
1797        return StoreRef::pinned(path).also_auto();
1798    }
1799    StoreRef::pinned(path)
1800}
1801
1802/// The config file `disable` would have stashed `platform`'s MCP entry from —
1803/// the same file [`platform_stores`]/[`install_platform`] write to. `None` for
1804/// Codex, whose registration is not a file this program reads.
1805fn platform_mcp_file(
1806    platform: &Platform,
1807    project_root: &Path,
1808    home: &Path,
1809    scope: Scope,
1810) -> Option<PathBuf> {
1811    match platform {
1812        Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
1813        Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
1814        Platform::Codex | Platform::All => None,
1815    }
1816}
1817
1818/// The store `enable` should write for `platform`: whichever one that
1819/// platform's own stashed MCP entry named, matched by which file it was
1820/// stashed from — Claude Code and Cursor can disagree (only Claude Code
1821/// resolves `--auto`; see [`resolves_at_runtime`]), so a single stash entry
1822/// must never be applied to every platform. Falls back to the same default
1823/// `install` would pick with no `--db` when nothing was stashed for this
1824/// platform — always true for Codex, whose registration is not a file this
1825/// program reads, so a Codex install made with an explicit `--db` cannot be
1826/// recovered exactly and is re-registered at the default store instead.
1827fn recover_store_for(
1828    manifest: &Manifest,
1829    platform: &Platform,
1830    project_root: &Path,
1831    home: &Path,
1832    scope: Scope,
1833) -> StoreRef {
1834    platform_mcp_file(platform, project_root, home, scope)
1835        .and_then(|file| manifest.stashed_mcp.iter().find(|s| s.file == file))
1836        .and_then(|s| entry_db(&s.entry))
1837        .map(|arg| store_from_arg(arg, project_root, home))
1838        .unwrap_or_else(|| {
1839            store_ref(
1840                project_root,
1841                home,
1842                scope,
1843                None,
1844                resolves_at_runtime(platform),
1845            )
1846        })
1847}
1848
1849/// The store the repository wiring (`.gitignore`, the git hook blocks) should
1850/// name for `enable`: whichever recovered per-platform store resolves at
1851/// runtime (Claude Code's, when present — same preference [`repo_store_ref`]
1852/// gives a fresh install), else the first platform's. Mirrors
1853/// [`repo_store_ref`], sourced from what was actually recovered rather than
1854/// recomputed independently, so it can never disagree with what
1855/// `install_claude_code`/`install_cursor` just wrote.
1856fn repo_store_for_enable(stores: &[(Platform, StoreRef)]) -> StoreRef {
1857    stores
1858        .iter()
1859        .find(|(p, _)| resolves_at_runtime(p))
1860        .or_else(|| stores.first())
1861        .map(|(_, s)| s.clone())
1862        .expect("enable always resolves at least one platform")
1863}
1864
1865/// Turn an install off: remove the MCP entry, the two Claude Code hooks, the
1866/// git hook blocks and the Codex registration; leave the skill/rules file, the
1867/// store, and the `.gitignore` line untouched. Idempotent.
1868pub fn run_disable(
1869    project_root: &Path,
1870    home: &Path,
1871    opts: &ToggleOpts,
1872) -> Result<String, CliError> {
1873    run_disable_with(project_root, home, opts, &Externals::from_env())
1874}
1875
1876/// Like [`run_disable`], with the external environment supplied by the caller
1877/// (Codex removal shells out to the `codex` CLI).
1878pub fn run_disable_with(
1879    project_root: &Path,
1880    home: &Path,
1881    opts: &ToggleOpts,
1882    ext: &Externals,
1883) -> Result<String, CliError> {
1884    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1885    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1886    let platforms = expand_platform(&resolved);
1887    let (scope, manifest_path) =
1888        locate_manifest(project_root, home, scope, auto_scope, &platforms, "disable")?;
1889    let mut manifest = load_manifest(&manifest_path);
1890
1891    let dir = scope_dir(project_root, home, scope);
1892    if manifest.disabled {
1893        return Ok(format!(
1894            "mushroomdb is already disabled in {}\n",
1895            dir.display()
1896        ));
1897    }
1898
1899    let mut changed = Vec::new();
1900    let mut stashed = Vec::new();
1901    for key in &manifest.mcp_keys {
1902        let Some(entry) = read_mcp_entry(&key.file, &key.server)? else {
1903            continue;
1904        };
1905        stashed.push(StashedMcpEntry {
1906            file: key.file.clone(),
1907            server: key.server.clone(),
1908            entry,
1909        });
1910        if remove_mcp_key(&key.file, &key.server)? {
1911            changed.push(format!(
1912                "disabled  mcpServers.{} in {}",
1913                key.server,
1914                key.file.display()
1915            ));
1916        }
1917    }
1918
1919    for h in &manifest.hooks {
1920        if remove_hook_entry(&h.file, &h.event, &h.command)? {
1921            changed.push(format!(
1922                "disabled  {} hook in {}",
1923                h.event,
1924                h.file.display()
1925            ));
1926        }
1927    }
1928
1929    for h in &manifest.git_hooks {
1930        if remove_git_hook(h)? {
1931            changed.push(format!("disabled  git hook block in {}", h.display()));
1932        }
1933    }
1934
1935    if manifest.codex {
1936        remove_codex(ext, &mut changed, "disabled")?;
1937    }
1938
1939    manifest.disabled = true;
1940    manifest.stashed_mcp = stashed;
1941    write_manifest(&manifest_path, &manifest)?;
1942
1943    let mut out = String::new();
1944    for line in &changed {
1945        out.push_str(line);
1946        out.push('\n');
1947    }
1948    out.push_str(&format!(
1949        "mushroomdb is disabled in {}; enable with: mushroomdb enable\n",
1950        dir.display()
1951    ));
1952    Ok(out)
1953}
1954
1955/// Turn a disabled install back on. Re-adds the MCP entry, the two Claude Code
1956/// hooks and the git hook blocks using the store a stashed entry named and the
1957/// command `install` would resolve right now — not a replay of what
1958/// `disable` took out, which may no longer be the fastest path to the
1959/// published package. Idempotent, and a no-op (not an error) when the install
1960/// is not disabled.
1961pub fn run_enable(project_root: &Path, home: &Path, opts: &ToggleOpts) -> Result<String, CliError> {
1962    run_enable_with(
1963        project_root,
1964        home,
1965        opts,
1966        &detect_mcp_command(None),
1967        &Externals::from_env(),
1968    )
1969}
1970
1971/// Like [`run_enable`], with the server command and the external environment
1972/// supplied by the caller. Tests use this to stay deterministic and offline.
1973pub fn run_enable_with(
1974    project_root: &Path,
1975    home: &Path,
1976    opts: &ToggleOpts,
1977    cmd: &McpCommand,
1978    ext: &Externals,
1979) -> Result<String, CliError> {
1980    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1981    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1982    let platforms = expand_platform(&resolved);
1983    let (scope, manifest_path) =
1984        locate_manifest(project_root, home, scope, auto_scope, &platforms, "enable")?;
1985    let mut manifest = load_manifest(&manifest_path);
1986
1987    let dir = scope_dir(project_root, home, scope);
1988    if !manifest.disabled {
1989        return Ok(format!(
1990            "mushroomdb is already enabled in {}\n",
1991            dir.display()
1992        ));
1993    }
1994
1995    // Restore what was actually requested before, not the caller's
1996    // auto-detected `cmd` — that would silently drop an explicit `--command`
1997    // pin the moment it was disabled. `Npx` is re-resolved below exactly like
1998    // `install` would (the "current shapes" part); `Explicit` is used
1999    // verbatim unless the binary it names is gone, in which case this falls
2000    // back to the caller's `cmd` and says so. A manifest with no stash at all
2001    // (written before this field existed) also falls back, silently — there
2002    // is nothing to have dropped.
2003    let mut notes: Vec<String> = Vec::new();
2004    let base_cmd = match manifest.requested_cmd.clone() {
2005        Some(StoredCommand::Explicit(p)) if is_bare_program_name(&p) || p.is_file() => {
2006            McpCommand::Explicit(p)
2007        }
2008        Some(StoredCommand::Explicit(p)) => {
2009            notes.push(format!(
2010                "warning: the pinned command {} no longer exists — re-detected the command instead",
2011                p.display()
2012            ));
2013            cmd.clone()
2014        }
2015        Some(other) => other.into_mcp(),
2016        None => cmd.clone(),
2017    };
2018    let base_cmd = match &base_cmd {
2019        McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
2020        other => other.clone(),
2021    };
2022    let (cmd, launcher_note, _) = resolve_fast_command(&base_cmd, ext);
2023    let cmd = &cmd;
2024    notes.extend(launcher_note);
2025
2026    // Each platform's own stashed entry says which store *that* platform was
2027    // installed with — Claude Code and Cursor can disagree, since only Claude
2028    // Code resolves `--auto`. A Codex-only install stashes nothing and falls
2029    // back to the same default a fresh install would pick.
2030    let stores: Vec<(Platform, StoreRef)> = platforms
2031        .iter()
2032        .map(|p| {
2033            (
2034                p.clone(),
2035                recover_store_for(&manifest, p, project_root, home, scope),
2036            )
2037        })
2038        .collect();
2039    let repo_store = repo_store_for_enable(&stores);
2040    let had_git_hooks = !manifest.git_hooks.is_empty();
2041
2042    let ctx = Ctx {
2043        project_root,
2044        home,
2045        scope,
2046        repo_store: &repo_store,
2047        cmd,
2048        ext,
2049        git_hooks: true,
2050        prewarm: false,
2051    };
2052
2053    let mut fresh = Manifest::default();
2054    for (plat, store) in &stores {
2055        match plat {
2056            Platform::ClaudeCode => install_claude_code(&ctx, store, &mut fresh, &mut notes)?,
2057            Platform::Cursor => install_cursor(&ctx, store, &mut fresh, &mut notes)?,
2058            Platform::Codex => install_codex(&ctx, store, &mut fresh)?,
2059            Platform::All => unreachable!("expand_platform never produces All"),
2060        }
2061    }
2062
2063    let repo_wiring = platforms
2064        .iter()
2065        .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
2066    if had_git_hooks && scope == Scope::Project && repo_wiring {
2067        install_git_hooks(&ctx, &mut fresh)?;
2068    }
2069
2070    // Fold this run's writes into the manifest: entries for files this run
2071    // touched replace what was there before (the command may have re-resolved
2072    // to a different path since `disable`); anything this run did not touch
2073    // — a platform this call was not asked to enable — survives untouched.
2074    let touched_mcp: Vec<&PathBuf> = fresh.mcp_keys.iter().map(|k| &k.file).collect();
2075    manifest
2076        .mcp_keys
2077        .retain(|k| !touched_mcp.contains(&&k.file));
2078    manifest.mcp_keys.extend(fresh.mcp_keys.iter().cloned());
2079
2080    let touched_hooks: Vec<(&PathBuf, &str)> = fresh
2081        .hooks
2082        .iter()
2083        .map(|h| (&h.file, h.event.as_str()))
2084        .collect();
2085    manifest
2086        .hooks
2087        .retain(|h| !touched_hooks.contains(&(&h.file, h.event.as_str())));
2088    manifest.hooks.extend(fresh.hooks.iter().cloned());
2089
2090    if !fresh.git_hooks.is_empty() {
2091        manifest.git_hooks = fresh.git_hooks.clone();
2092    }
2093    manifest.codex |= fresh.codex;
2094    for f in &fresh.files {
2095        if !manifest.files.contains(f) {
2096            manifest.files.push(f.clone());
2097        }
2098    }
2099
2100    manifest.disabled = false;
2101    manifest.stashed_mcp.clear();
2102    // Remember what was actually used this round — the restored pin, the
2103    // fallback it took because that pin was gone, or the unresolved `Npx`
2104    // request (never the resolved native-binary/launcher path resolution
2105    // turned it into) — so the *next* `disable`/`enable` round trip starts
2106    // from what is actually true now rather than a permanently stale pin.
2107    manifest.requested_cmd = StoredCommand::from_mcp(&base_cmd);
2108    write_manifest(&manifest_path, &manifest)?;
2109
2110    let mut out = String::new();
2111    for k in &fresh.mcp_keys {
2112        out.push_str(&format!(
2113            "enabled  mcpServers.{} in {}\n",
2114            k.server,
2115            k.file.display()
2116        ));
2117    }
2118    for h in &fresh.hooks {
2119        out.push_str(&format!(
2120            "enabled  {} hook in {}\n",
2121            h.event,
2122            h.file.display()
2123        ));
2124    }
2125    for g in &fresh.git_hooks {
2126        out.push_str(&format!("enabled  git hook {}\n", g.display()));
2127    }
2128    if fresh.codex {
2129        out.push_str(&format!("enabled  codex mcp server {SERVER_NAME}\n"));
2130    }
2131    for n in &notes {
2132        out.push_str(&format!("  {n}\n"));
2133    }
2134    out.push_str(&format!("mushroomdb is enabled in {}\n", dir.display()));
2135    Ok(out)
2136}
2137
2138// ---------------------------------------------------------------------------
2139// Platform resolution
2140// ---------------------------------------------------------------------------
2141
2142pub(crate) fn resolve_platform(
2143    project_root: &Path,
2144    home: &Path,
2145    requested: Option<&Platform>,
2146) -> Result<Platform, CliError> {
2147    if let Some(p) = requested {
2148        return Ok(p.clone());
2149    }
2150
2151    // Auto-detect. Codex is never inferred: registering with it runs another
2152    // program, which is not something to do because a directory exists.
2153    let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
2154    let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
2155
2156    match (has_claude, has_cursor) {
2157        (true, true) => Ok(Platform::All),
2158        (true, false) => Ok(Platform::ClaudeCode),
2159        (false, true) => Ok(Platform::Cursor),
2160        (false, false) => Err(CliError(
2161            "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
2162             Pass --platform claude-code, --platform cursor, --platform codex, or --platform all."
2163                .to_string(),
2164        )),
2165    }
2166}
2167
2168/// `All` is the two platforms whose config this program writes itself. Codex
2169/// is deliberately not in it: it is wired by running the `codex` CLI, which
2170/// may not exist, and `--platform all` must not fail on a machine that simply
2171/// does not have it.
2172pub(crate) fn expand_platform(p: &Platform) -> Vec<Platform> {
2173    match p {
2174        Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
2175        other => vec![other.clone()],
2176    }
2177}
2178
2179// ---------------------------------------------------------------------------
2180// Pre-flight conflict check (no writes)
2181// ---------------------------------------------------------------------------
2182
2183fn preflight_check(
2184    project_root: &Path,
2185    home: &Path,
2186    platform: &Platform,
2187    scope: Scope,
2188    store: &StoreRef,
2189    ext: &Externals,
2190) -> Result<(), CliError> {
2191    match platform {
2192        Platform::ClaudeCode => {
2193            check_mcp_conflict(&claude_mcp_file(project_root, home, scope), store)
2194        }
2195        Platform::Cursor => check_mcp_conflict(&cursor_mcp_file(project_root, home, scope), store),
2196        // Nothing of Codex's is a file we read; what can fail early is the CLI
2197        // being absent, and that is worth saying before anything is written.
2198        Platform::Codex => codex_bin(ext).map(|_| ()),
2199        Platform::All => unreachable!("expand_platform never produces All"),
2200    }
2201}
2202
2203pub(crate) fn claude_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
2204    match scope {
2205        Scope::Project => project_root.join(".mcp.json"),
2206        // User-scope: verified empirically on a live Claude Code install.
2207        // ~/.claude.json holds top-level mcpServers; ~/.claude/settings.json
2208        // holds env/permissions/hooks but no mcpServers key.
2209        Scope::User => home.join(".claude.json"),
2210    }
2211}
2212
2213pub(crate) fn cursor_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
2214    match scope {
2215        Scope::Project => project_root.join(".cursor").join("mcp.json"),
2216        Scope::User => home.join(".cursor").join("mcp.json"),
2217    }
2218}
2219
2220/// The store an existing entry serves: the argument straight after `mcp`,
2221/// which is either a path or `--auto`.
2222///
2223/// Its position moved between versions — 0.5.x wrote `["mcp", db]`, the npx
2224/// form writes `["-y", "mushroomdb@x.y.z", "mcp", db]`, a resolved launcher
2225/// writes `["<launcher>", "mcp", "--auto"]` — so the subcommand is what
2226/// locates it, not an index.
2227pub(crate) fn entry_db(entry: &serde_json::Value) -> Option<&str> {
2228    let args = entry["args"].as_array()?;
2229    let at = args.iter().position(|a| a == "mcp")?;
2230    args.get(at + 1)?.as_str()
2231}
2232
2233/// Check if a MCP JSON file has a conflicting `mushroomdb` entry.
2234///
2235/// A conflict is: the file exists, has `mcpServers.mushroomdb`, and the store
2236/// it names differs from the one we'd write. An entry for the SAME store with
2237/// a different `command` — or the same store spelled the other way, which is
2238/// every 0.6.0 entry now that a project install writes `--auto` — is ours to
2239/// repair, so it is not a conflict.
2240fn check_mcp_conflict(mcp_file: &Path, store: &StoreRef) -> Result<(), CliError> {
2241    if !mcp_file.exists() {
2242        return Ok(());
2243    }
2244    let raw = fs::read_to_string(mcp_file)
2245        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
2246    let v: serde_json::Value = serde_json::from_str(&raw)
2247        .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
2248
2249    let existing = &v["mcpServers"][SERVER_NAME];
2250    if existing.is_null() {
2251        return Ok(()); // Key absent — no conflict.
2252    }
2253
2254    let existing_db = entry_db(existing).unwrap_or("");
2255    if store.names_same_store(existing_db) {
2256        return Ok(()); // Same store — idempotent or repairable, no conflict.
2257    }
2258
2259    Err(CliError(format!(
2260        "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
2261         To update it, run `mushroomdb uninstall` first, then re-install.\n\
2262         Or manually edit {} and remove the existing mushroomdb entry.",
2263        mcp_file.display(),
2264        existing_db,
2265        mcp_file.display()
2266    )))
2267}
2268
2269/// A server registered in the *other* scope still shows up in the assistant,
2270/// and two of them pointed at two stores is a confusing place to be. Say so;
2271/// never touch the other scope's file.
2272fn scope_conflict_note(ctx: &Ctx<'_>, platforms: &[Platform]) -> Option<String> {
2273    if !platforms.contains(&Platform::ClaudeCode) {
2274        return None;
2275    }
2276    let (other, label, flag) = match ctx.scope {
2277        Scope::Project => (
2278            claude_mcp_file(ctx.project_root, ctx.home, Scope::User),
2279            "user",
2280            "--user",
2281        ),
2282        Scope::User => (
2283            claude_mcp_file(ctx.project_root, ctx.home, Scope::Project),
2284            "project",
2285            "--project",
2286        ),
2287    };
2288    if !has_our_server(&other) {
2289        return None;
2290    }
2291    Some(format!(
2292        "warning: a {label}-scope mushroomdb server also exists ({}) — \
2293         both will load; to remove that one run: mushroomdb uninstall {flag}",
2294        other.display()
2295    ))
2296}
2297
2298pub(crate) fn has_our_server(mcp_file: &Path) -> bool {
2299    let Ok(raw) = fs::read_to_string(mcp_file) else {
2300        return false;
2301    };
2302    serde_json::from_str::<serde_json::Value>(&raw)
2303        .map(|v| !v["mcpServers"][SERVER_NAME].is_null())
2304        .unwrap_or(false)
2305}
2306
2307// ---------------------------------------------------------------------------
2308// Per-platform installation
2309// ---------------------------------------------------------------------------
2310
2311fn install_platform(
2312    ctx: &Ctx<'_>,
2313    platform: &Platform,
2314    store: &StoreRef,
2315    manifest: &mut Manifest,
2316    notes: &mut Vec<String>,
2317) -> Result<(), CliError> {
2318    match platform {
2319        Platform::ClaudeCode => install_claude_code(ctx, store, manifest, notes),
2320        Platform::Cursor => install_cursor(ctx, store, manifest, notes),
2321        Platform::Codex => install_codex(ctx, store, manifest),
2322        Platform::All => unreachable!("expand_platform never produces All"),
2323    }
2324}
2325
2326/// Substitute both template placeholders. `bin_cmd` is the pre-quoted shell
2327/// form, so the templates carry `{{BIN}}` unquoted.
2328fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
2329    template
2330        .replace(DB_PATH_PLACEHOLDER, db_str)
2331        .replace(BIN_PLACEHOLDER, bin_cmd)
2332}
2333
2334fn install_claude_code(
2335    ctx: &Ctx<'_>,
2336    store: &StoreRef,
2337    manifest: &mut Manifest,
2338    notes: &mut Vec<String>,
2339) -> Result<(), CliError> {
2340    let shell = ctx.cmd.shell();
2341    // The skill is prose a reader follows by hand, so it names the directory
2342    // the store is in rather than the `--auto` the machine-read config uses.
2343    let db_str = store.path().to_string_lossy();
2344    let skill_content = render_template(SKILL_TEMPLATE, &db_str, &shell);
2345
2346    let skill_dir = match ctx.scope {
2347        Scope::Project => ctx
2348            .project_root
2349            .join(".claude")
2350            .join("skills")
2351            .join("mushroom"),
2352        Scope::User => ctx.home.join(".claude").join("skills").join("mushroom"),
2353    };
2354    let skill_file = skill_dir.join("SKILL.md");
2355
2356    // Idempotent: skip if the file already has the same content.
2357    if !file_matches(&skill_file, &skill_content) {
2358        fs::create_dir_all(&skill_dir)
2359            .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
2360        fs::write(&skill_file, &skill_content)
2361            .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
2362        manifest.files.push(skill_file);
2363    }
2364
2365    let mcp_file = claude_mcp_file(ctx.project_root, ctx.home, ctx.scope);
2366    merge_mcp_entry(&mcp_file, ctx, store, manifest, notes)?;
2367
2368    // Both hooks: settings.json in the same scope as the skill. The prompt
2369    // hook first, so a manifest lists them in the order they were written.
2370    let settings_file = match ctx.scope {
2371        Scope::Project => ctx.project_root.join(".claude").join("settings.json"),
2372        Scope::User => ctx.home.join(".claude").join("settings.json"),
2373    };
2374    // An earlier install of ours for this same store is replaced, not joined:
2375    // its command names a binary this version no longer writes, and leaving it
2376    // would run both on every prompt.
2377    let recall = recall_hook_command(&shell, store);
2378    if remove_stale_hooks(&settings_file, HOOK_EVENT, "recall", store, &recall)? {
2379        notes.push(format!("replaced stale {HOOK_EVENT} hook"));
2380    }
2381    merge_hook_entry(
2382        &settings_file,
2383        HOOK_EVENT,
2384        &recall,
2385        hook_entry(&recall),
2386        manifest,
2387    )?;
2388    let touch = touch_hook_command(&shell, store);
2389    if remove_stale_hooks(&settings_file, TOUCH_EVENT, "touch", store, &touch)? {
2390        notes.push(format!("replaced stale {TOUCH_EVENT} hook"));
2391    }
2392    merge_hook_entry(
2393        &settings_file,
2394        TOUCH_EVENT,
2395        &touch,
2396        touch_hook_entry(&touch),
2397        manifest,
2398    )?;
2399
2400    Ok(())
2401}
2402
2403fn install_cursor(
2404    ctx: &Ctx<'_>,
2405    store: &StoreRef,
2406    manifest: &mut Manifest,
2407    notes: &mut Vec<String>,
2408) -> Result<(), CliError> {
2409    let db_str = store.path().to_string_lossy();
2410    let rules_content = render_template(CURSOR_RULES_TEMPLATE, &db_str, &ctx.cmd.shell());
2411
2412    let rules_dir = match ctx.scope {
2413        Scope::Project => ctx.project_root.join(".cursor").join("rules"),
2414        Scope::User => ctx.home.join(".cursor").join("rules"),
2415    };
2416    let rules_file = rules_dir.join("mushroom.mdc");
2417
2418    if !file_matches(&rules_file, &rules_content) {
2419        fs::create_dir_all(&rules_dir)
2420            .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
2421        fs::write(&rules_file, &rules_content)
2422            .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
2423        manifest.files.push(rules_file);
2424    }
2425
2426    let mcp_file = cursor_mcp_file(ctx.project_root, ctx.home, ctx.scope);
2427    merge_mcp_entry(&mcp_file, ctx, store, manifest, notes)?;
2428
2429    Ok(())
2430}
2431
2432/// The `codex` executable, or an error that says what to do about it.
2433fn codex_bin(ext: &Externals) -> Result<PathBuf, CliError> {
2434    ext.which("codex").ok_or_else(|| {
2435        CliError(
2436            "codex was not found on PATH — install the Codex CLI, or drop \
2437             `--platform codex`"
2438                .to_string(),
2439        )
2440    })
2441}
2442
2443/// Take the Codex registration back out, through `codex mcp remove`. Appends
2444/// one line to `out` on success (`"{verb}  codex mcp server mushroomdb"`), or
2445/// a warning naming the manual fallback when `codex` cannot be reached — not
2446/// being able to reach it must not strand every other thing the caller is
2447/// removing. Shared by `uninstall` and `disable`, which take the registration
2448/// off disk the same way and differ only in whether they still own it after.
2449fn remove_codex(ext: &Externals, out: &mut Vec<String>, verb: &str) -> Result<(), CliError> {
2450    match ext.which("codex") {
2451        Some(bin) => {
2452            run_and_capture(&bin, &["mcp".into(), "remove".into(), SERVER_NAME.into()])
2453                .map_err(|e| CliError(format!("codex mcp remove failed: {e}")))?;
2454            out.push(format!("{verb}  codex mcp server {SERVER_NAME}"));
2455        }
2456        None => out.push(
2457            "warning: codex is not on PATH — run `codex mcp remove mushroomdb` yourself"
2458                .to_string(),
2459        ),
2460    }
2461    Ok(())
2462}
2463
2464/// Register the server with Codex through its own CLI.
2465///
2466/// Codex owns its configuration file and its format is its business, so this
2467/// writes nothing: it runs `codex mcp add mushroomdb -- <command> <args…>` and
2468/// lets Codex record it. 0.6.0 ships no Codex skill — the MCP tools carry
2469/// their own descriptions, which is what Codex reads.
2470fn install_codex(ctx: &Ctx<'_>, store: &StoreRef, manifest: &mut Manifest) -> Result<(), CliError> {
2471    let bin = codex_bin(ctx.ext)?;
2472    let mut args = vec![
2473        "mcp".to_string(),
2474        "add".to_string(),
2475        SERVER_NAME.to_string(),
2476        "--".to_string(),
2477    ];
2478    args.extend(ctx.cmd.argv("mcp", &store.arg()));
2479    run_and_capture(&bin, &args).map_err(|e| CliError(format!("codex mcp add failed: {e}")))?;
2480    manifest.codex = true;
2481    Ok(())
2482}
2483
2484// ---------------------------------------------------------------------------
2485// Repository wiring: the ignore line and the sync hooks
2486// ---------------------------------------------------------------------------
2487
2488/// The git hooks a sync belongs in: after a commit lands, after a branch
2489/// changes the working tree, and after a merge brings other people's commits
2490/// in. All three leave the graph a commit behind if they are skipped.
2491pub(crate) const GIT_HOOKS: &[&str] = &["post-commit", "post-checkout", "post-merge"];
2492
2493/// The `.gitignore` line for a store kept inside the repository, or `None`
2494/// when it is kept outside — a repository has no business ignoring a path it
2495/// does not contain.
2496fn gitignore_line(project_root: &Path, db: &Path) -> Option<String> {
2497    let rel = db.strip_prefix(project_root).ok()?;
2498    if rel.as_os_str().is_empty() {
2499        return None;
2500    }
2501    Some(format!("{}/", rel.to_string_lossy().replace('\\', "/")))
2502}
2503
2504/// Append the store directory to the repository's `.gitignore` unless some
2505/// spelling of it is already listed. Creates the file if it is absent.
2506fn ensure_gitignore_line(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
2507    let Some(line) = gitignore_line(ctx.project_root, ctx.repo_store.path()) else {
2508        return Ok(());
2509    };
2510    let path = ctx.project_root.join(".gitignore");
2511    let existed = path.exists();
2512    let current = match fs::read_to_string(&path) {
2513        Ok(s) => s,
2514        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
2515        Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
2516    };
2517    let bare = line.trim_end_matches('/');
2518    if current
2519        .lines()
2520        .map(str::trim)
2521        .any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
2522    {
2523        return Ok(());
2524    }
2525    let mut next = current;
2526    if !next.is_empty() && !next.ends_with('\n') {
2527        next.push('\n');
2528    }
2529    next.push_str(&line);
2530    next.push('\n');
2531    fs::write(&path, next)
2532        .map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
2533    // `created` is what lets uninstall leave a repository that had no
2534    // `.gitignore` with none again — but only if our line is still all that is
2535    // in it. Anything the user has added since is theirs, and the file stays.
2536    manifest.gitignore.push(ManagedLine {
2537        file: path,
2538        line,
2539        created: !existed,
2540    });
2541    Ok(())
2542}
2543
2544/// Whether the file is gone or holds nothing but whitespace.
2545fn file_is_blank(path: &Path) -> bool {
2546    match fs::read_to_string(path) {
2547        Ok(s) => s.trim().is_empty(),
2548        Err(_) => true,
2549    }
2550}
2551
2552/// Remove one exact line from a text file. Returns whether anything changed;
2553/// a file that does not hold the line is not rewritten at all.
2554fn remove_line(path: &Path, line: &str) -> Result<bool, CliError> {
2555    let Ok(current) = fs::read_to_string(path) else {
2556        return Ok(false);
2557    };
2558    if !current.lines().any(|l| l == line) {
2559        return Ok(false);
2560    }
2561    let kept: Vec<&str> = current.lines().filter(|l| *l != line).collect();
2562    let mut next = kept.join("\n");
2563    if !next.is_empty() {
2564        next.push('\n');
2565    }
2566    fs::write(path, next).map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
2567    Ok(true)
2568}
2569
2570/// The directory git will actually run this checkout's hooks from, following
2571/// the `gitdir:` link a worktree or submodule leaves in place of a `.git`
2572/// directory.
2573///
2574/// The subtlety is the last step. A linked worktree's gitdir is
2575/// `<main>/.git/worktrees/<name>`, but git resolves hooks through the
2576/// **common** dir — `git rev-parse --git-path hooks` inside a worktree answers
2577/// `<main>/.git/hooks`, not the worktree's own. Writing a hook into the
2578/// worktree's gitdir puts it somewhere git never looks: the file is there, it
2579/// is executable, and nothing ever runs it. A linked worktree records the way
2580/// back in a `commondir` file next to its gitdir (contents `../..`), so this
2581/// follows it whenever it is there.
2582///
2583/// A submodule has no `commondir` and its own gitdir *is* its hooks dir
2584/// (`.git/modules/<path>/hooks`), which is what the plain resolution already
2585/// computes — so the absence of the file is the signal to stop.
2586pub(crate) fn git_hooks_dir(project_root: &Path) -> Option<PathBuf> {
2587    let dot_git = project_root.join(".git");
2588    if dot_git.is_dir() {
2589        return Some(dot_git.join("hooks"));
2590    }
2591    let text = fs::read_to_string(&dot_git).ok()?;
2592    let target = text.strip_prefix("gitdir:")?.trim();
2593    let target = Path::new(target);
2594    let resolved = if target.is_absolute() {
2595        target.to_path_buf()
2596    } else {
2597        project_root.join(target)
2598    };
2599    // A linked worktree defers its hooks to the common dir; a submodule keeps
2600    // its own. The `commondir` file is what tells the two apart.
2601    let base = match fs::read_to_string(resolved.join("commondir")) {
2602        Ok(rel) => {
2603            let rel_path = PathBuf::from(rel.trim());
2604            if rel_path.is_absolute() {
2605                rel_path
2606            } else {
2607                lexically_normalize(&resolved.join(rel_path))
2608            }
2609        }
2610        Err(_) => resolved,
2611    };
2612    Some(base.join("hooks"))
2613}
2614
2615/// Resolve `.` and `..` in a path textually, without touching the filesystem.
2616///
2617/// `commondir` is written relative (`../..`), so joining it leaves a path that
2618/// works but reads badly in `doctor`'s output and in the manifest. This is
2619/// purely cosmetic and deliberately does not canonicalize: resolving symlinks
2620/// would rewrite a path the user gave us into one they do not recognise. A
2621/// leading `..` with nothing to pop is kept, since dropping it would change
2622/// where the path points.
2623fn lexically_normalize(path: &Path) -> PathBuf {
2624    let mut out = PathBuf::new();
2625    for part in path.components() {
2626        match part {
2627            std::path::Component::CurDir => {}
2628            std::path::Component::ParentDir => {
2629                let can_pop = out
2630                    .components()
2631                    .next_back()
2632                    .is_some_and(|c| matches!(c, std::path::Component::Normal(_)));
2633                if !can_pop || !out.pop() {
2634                    out.push("..");
2635                }
2636            }
2637            other => out.push(other.as_os_str()),
2638        }
2639    }
2640    out
2641}
2642
2643fn install_git_hooks(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
2644    // Not a checkout: there is nothing to hook into, and that is not an error.
2645    let Some(dir) = git_hooks_dir(ctx.project_root) else {
2646        return Ok(());
2647    };
2648    let shell = ctx.cmd.shell();
2649    for name in GIT_HOOKS {
2650        let file = dir.join(name);
2651        if merge_git_hook(&file, &shell, ctx.repo_store)? {
2652            manifest.git_hooks.push(file);
2653        }
2654    }
2655    Ok(())
2656}
2657
2658// ---------------------------------------------------------------------------
2659// Pre-warm
2660// ---------------------------------------------------------------------------
2661
2662/// Fetch the pinned package once, so the assistant's first spawn of the MCP
2663/// server is not a cold `npx` download inside a startup timeout.
2664///
2665/// Best effort in every direction: it only applies to the `npx` form, it is
2666/// skipped when asked to be, and a failure is a line in the summary rather
2667/// than a failed install — the entry that was written is correct either way.
2668///
2669/// Whenever [`resolve_fast_command`] got as far as asking `npx` anything, it
2670/// already ran this fetch — asking the package a question downloads it first —
2671/// so the caller clears `Ctx::prewarm` and this does not run a second time.
2672/// The match below is the remaining guard: a resolved binary or launcher is
2673/// not the `npx` form and needs no warming either way.
2674fn prewarm(ctx: &Ctx<'_>) -> Option<String> {
2675    if !ctx.prewarm {
2676        return None;
2677    }
2678    let McpCommand::Npx { version } = ctx.cmd else {
2679        return None;
2680    };
2681    let args = vec![
2682        "-y".to_string(),
2683        format!("{NPM_PACKAGE}@{version}"),
2684        "--version".to_string(),
2685    ];
2686    let Some(npx) = ctx.ext.which("npx") else {
2687        return Some(
2688            "warning: pre-warm skipped — npx is not on PATH; the first MCP \
2689             spawn will download the package"
2690                .to_string(),
2691        );
2692    };
2693    match run_with_timeout(&npx, &args, ctx.ext.prewarm_timeout) {
2694        Ok(()) => None,
2695        Err(e) => Some(format!(
2696            "warning: pre-warm of {NPM_PACKAGE}@{version} failed ({e}) — \
2697             the first MCP spawn will download the package"
2698        )),
2699    }
2700}
2701
2702// ---------------------------------------------------------------------------
2703// MCP JSON merge helpers
2704// ---------------------------------------------------------------------------
2705
2706/// Add `mcpServers.mushroomdb` to a JSON config file. Creates the file if
2707/// absent. No-op if the entry already matches (idempotent). An entry that is
2708/// present but different is an upgrade: it is rewritten, and the summary says
2709/// so, because a stale command is exactly the failure this replaces.
2710fn merge_mcp_entry(
2711    mcp_file: &Path,
2712    ctx: &Ctx<'_>,
2713    store: &StoreRef,
2714    manifest: &mut Manifest,
2715    notes: &mut Vec<String>,
2716) -> Result<(), CliError> {
2717    let mut root: serde_json::Value = if mcp_file.exists() {
2718        let raw = fs::read_to_string(mcp_file)
2719            .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
2720        serde_json::from_str(&raw)
2721            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
2722    } else {
2723        serde_json::json!({})
2724    };
2725
2726    // Ensure `mcpServers` object exists.
2727    if !root["mcpServers"].is_object() {
2728        root["mcpServers"] = serde_json::json!({});
2729    }
2730
2731    let desired = ctx.cmd.json_entry("mcp", &store.arg());
2732    let existing = &root["mcpServers"][SERVER_NAME];
2733
2734    if existing == &desired {
2735        return Ok(()); // Exact match — idempotent.
2736    }
2737    let replaced = !existing.is_null();
2738
2739    // Write the entry.
2740    root["mcpServers"][SERVER_NAME] = desired;
2741
2742    let parent = mcp_file.parent().unwrap_or(Path::new("."));
2743    fs::create_dir_all(parent)
2744        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
2745
2746    let json = serde_json::to_string_pretty(&root)
2747        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
2748    fs::write(mcp_file, json)
2749        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
2750
2751    manifest.mcp_keys.push(ManagedMcpKey {
2752        file: mcp_file.to_path_buf(),
2753        server: SERVER_NAME.to_string(),
2754    });
2755    if replaced {
2756        notes.push(format!(
2757            "updated mcp command in {} → {} mcp {}",
2758            mcp_file.display(),
2759            ctx.cmd.shell(),
2760            store.arg()
2761        ));
2762    }
2763
2764    Ok(())
2765}
2766
2767/// Remove `mcpServers.<server>` from a JSON config file. Leaves the file in
2768/// place (with the key removed) unless `mcpServers` becomes empty, in which
2769/// case we still leave the file (the user may have other keys).
2770///
2771/// Returns whether the key was there. A file that does not hold it is not
2772/// rewritten at all, for the same reason `drop_hooks` does not: re-serializing
2773/// a file we take nothing out of would reorder and re-indent the user's keys
2774/// for no reason.
2775fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<bool, CliError> {
2776    if !mcp_file.exists() {
2777        return Ok(false);
2778    }
2779    let raw = fs::read_to_string(mcp_file)
2780        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
2781    let mut root: serde_json::Value = serde_json::from_str(&raw)
2782        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
2783
2784    let removed = root["mcpServers"]
2785        .as_object_mut()
2786        .is_some_and(|servers| servers.remove(server).is_some());
2787    if !removed {
2788        return Ok(false);
2789    }
2790
2791    let json = serde_json::to_string_pretty(&root)
2792        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
2793    fs::write(mcp_file, json)
2794        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
2795    Ok(true)
2796}
2797
2798// ---------------------------------------------------------------------------
2799// Manifest helpers
2800// ---------------------------------------------------------------------------
2801
2802pub(crate) fn manifest_path(
2803    project_root: &Path,
2804    home: &Path,
2805    scope: Scope,
2806    platforms: &[Platform],
2807) -> PathBuf {
2808    // Codex writes nothing project-local — its registration lives wherever the
2809    // Codex CLI keeps it — so a Codex-only install records itself under the
2810    // home directory whatever the scope, in its own file so it cannot collide
2811    // with a user-scope Claude Code manifest.
2812    if platforms == [Platform::Codex] {
2813        return home.join(".mushroomdb").join("install-manifest-codex.json");
2814    }
2815    if scope == Scope::User {
2816        return home.join(".mushroomdb").join("install-manifest.json");
2817    }
2818    // Project scope: prefer the Claude Code location; fall back to Cursor.
2819    if platforms.contains(&Platform::ClaudeCode) {
2820        project_root
2821            .join(".claude")
2822            .join("skills")
2823            .join("mushroom")
2824            .join(".install-manifest.json")
2825    } else {
2826        project_root.join(".cursor").join(".install-manifest.json")
2827    }
2828}
2829
2830/// Load an existing manifest from `path`. Returns an empty manifest if absent or unparseable.
2831///
2832/// A `.gitignore` is never a file this install may delete outright, whatever a
2833/// manifest says. An earlier 0.6.0 build recorded a `.gitignore` it created in
2834/// `files`, which the uninstall file loop removes unconditionally — taking any
2835/// line the user had added to it since. The `gitignore` entry in the same
2836/// manifest already carries the one line that is ours, and that is the only
2837/// route by which the file may be touched, so the stale `files` entry is
2838/// dropped on the way in.
2839fn load_manifest(path: &Path) -> Manifest {
2840    let raw = match fs::read_to_string(path) {
2841        Ok(s) => s,
2842        Err(_) => return Manifest::default(),
2843    };
2844    serde_json::from_str::<Manifest>(&raw)
2845        .unwrap_or_default()
2846        .sanitised()
2847}
2848
2849/// Whether an install at this scope has been turned off by `disable`. `false`
2850/// for a scope with no manifest at all — `doctor` falls through to its normal
2851/// "no config entry" checks in that case rather than reporting a disabled
2852/// state that was never installed.
2853pub(crate) fn is_disabled(
2854    project_root: &Path,
2855    home: &Path,
2856    scope: Scope,
2857    platforms: &[Platform],
2858) -> bool {
2859    load_manifest(&manifest_path(project_root, home, scope, platforms)).disabled
2860}
2861
2862/// Union `existing` with `this_run`, deduplicating by path (files, git hooks),
2863/// by (file, server) pair (mcp_keys), and by full equality (hooks, lines).
2864/// Entries from `this_run` win on collision so the manifest always reflects
2865/// the latest state.
2866fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
2867    for f in &this_run.files {
2868        if !existing.files.contains(f) {
2869            existing.files.push(f.clone());
2870        }
2871    }
2872    for k in &this_run.mcp_keys {
2873        let already = existing
2874            .mcp_keys
2875            .iter()
2876            .any(|e| e.file == k.file && e.server == k.server);
2877        if !already {
2878            existing.mcp_keys.push(k.clone());
2879        }
2880    }
2881    for h in &this_run.hooks {
2882        if !existing.hooks.contains(h) {
2883            existing.hooks.push(h.clone());
2884        }
2885    }
2886    for h in &this_run.git_hooks {
2887        if !existing.git_hooks.contains(h) {
2888            existing.git_hooks.push(h.clone());
2889        }
2890    }
2891    for l in &this_run.gitignore {
2892        if !existing.gitignore.contains(l) {
2893            existing.gitignore.push(l.clone());
2894        }
2895    }
2896    existing.codex |= this_run.codex;
2897    if let Some(c) = &this_run.requested_cmd {
2898        existing.requested_cmd = Some(c.clone());
2899    }
2900    existing
2901}
2902
2903fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
2904    let parent = path.parent().unwrap_or(Path::new("."));
2905    fs::create_dir_all(parent).map_err(|e| {
2906        CliError(format!(
2907            "cannot create manifest dir {}: {e}",
2908            parent.display()
2909        ))
2910    })?;
2911    let json = serde_json::to_string_pretty(manifest)
2912        .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
2913    fs::write(path, json)
2914        .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
2915    Ok(())
2916}
2917
2918// ---------------------------------------------------------------------------
2919// Git hook block — `mushroomdb sync` after every commit
2920// ---------------------------------------------------------------------------
2921//
2922// A git hook file belongs to the repository owner, not to us. Everything below
2923// therefore edits one marked region and nothing else: the region is rewritten
2924// in place when it changes, and removing it restores the user's lines exactly.
2925// The pure text transforms are split out from the filesystem wrappers so the
2926// merge and removal rules can be reasoned about — and tested — without a disk.
2927
2928/// Opening marker of the region this module owns inside a git hook.
2929pub const HOOK_BEGIN: &str = "# >>> mushroomdb >>>";
2930/// Closing marker of that region.
2931pub const HOOK_END: &str = "# <<< mushroomdb <<<";
2932/// Written as the first line when we create a hook file ourselves.
2933const HOOK_SHEBANG: &str = "#!/bin/sh";
2934
2935/// The block a git hook runs: one backgrounded, silenced `sync`.
2936///
2937/// Backgrounded (`( … & )` in a subshell, so no job-control notice reaches the
2938/// terminal) because a hook must not make `git commit` wait on a graph
2939/// refresh, and silenced because a hook that prints — or fails — on a store
2940/// that is momentarily busy would be noise on every commit. `sync` exits 3 when
2941/// another process holds the write lock, and the next commit picks the work up.
2942///
2943/// `shell` is the already-quoted command prefix from [`McpCommand::shell`];
2944/// the store contributes either `--auto` or its own quoted path, since a path
2945/// with a space in it would otherwise be word-split into two arguments.
2946///
2947/// `--auto` is what makes the block correct in a `git worktree`. Git runs a
2948/// hook with the working tree it acted on as the working directory, so `sync`
2949/// walks up from there to that tree's own root and updates that tree's own
2950/// store — the same block, committed once, doing the right thing in every
2951/// checkout of the repository.
2952#[must_use]
2953pub fn git_hook_block(shell: &str, store: &StoreRef) -> String {
2954    format!(
2955        "{HOOK_BEGIN}\n( {shell} sync {} >/dev/null 2>&1 & )\n{HOOK_END}\n",
2956        store.shell_arg()
2957    )
2958}
2959
2960/// What [`strip_hook_block`] found in a hook file.
2961enum Stripped {
2962    /// No opening marker: every line belongs to whoever wrote the file.
2963    Absent,
2964    /// A complete region was removed; this is what is left.
2965    Removed(String),
2966    /// An opening marker with no closing marker. Where our region ends is
2967    /// unknowable, so nothing may be removed.
2968    Unterminated,
2969}
2970
2971/// `text` with our marked region removed.
2972///
2973/// Blank lines left dangling at the end are dropped, so a merge followed by a
2974/// removal returns the original bytes rather than the original plus the blank
2975/// separator the merge inserted.
2976///
2977/// An opening marker with no closing marker is [`Stripped::Unterminated`]
2978/// rather than "ours to the end of the file". Someone hand-edited the region,
2979/// and the lines below the opening marker are now as likely to be theirs as
2980/// ours — a `make lint` they added under it would be deleted by the guess.
2981/// Both public helpers turn this into an error and write nothing, which is the
2982/// same rule the rest of this module follows for a config file whose shape it
2983/// does not recognise.
2984fn strip_hook_block(text: &str) -> Stripped {
2985    let mut kept: Vec<&str> = Vec::new();
2986    let mut inside = false;
2987    let mut found = false;
2988    for line in text.lines() {
2989        if !inside && line.trim_end() == HOOK_BEGIN {
2990            inside = true;
2991            found = true;
2992            continue;
2993        }
2994        if inside {
2995            if line.trim_end() == HOOK_END {
2996                inside = false;
2997            }
2998            continue;
2999        }
3000        kept.push(line);
3001    }
3002    if !found {
3003        return Stripped::Absent;
3004    }
3005    if inside {
3006        return Stripped::Unterminated;
3007    }
3008    while kept.last().is_some_and(|l| l.trim().is_empty()) {
3009        kept.pop();
3010    }
3011    let mut out = kept.join("\n");
3012    if !out.is_empty() {
3013        out.push('\n');
3014    }
3015    Stripped::Removed(out)
3016}
3017
3018/// The error both helpers return for [`Stripped::Unterminated`].
3019fn unterminated(hook_file: &Path) -> CliError {
3020    CliError(format!(
3021        "{}: a mushroomdb block opens with `{HOOK_BEGIN}` but never closes \
3022         — refusing to edit it; delete the block by hand and re-run",
3023        hook_file.display()
3024    ))
3025}
3026
3027/// What the hook file should contain once `block` is in it.
3028///
3029/// Idempotent by construction: any existing region is stripped first and the
3030/// fresh one appended, so a re-merge of the same block reproduces the same
3031/// bytes and a merge of a *different* block rewrites in place instead of
3032/// stacking a second region.
3033fn merged_hook_text(existing: Option<&str>, block: &str) -> Result<String, ()> {
3034    let base = match existing {
3035        None => String::new(),
3036        Some(text) => match strip_hook_block(text) {
3037            Stripped::Absent => text.to_string(),
3038            Stripped::Removed(rest) => rest,
3039            Stripped::Unterminated => return Err(()),
3040        },
3041    };
3042    let mut lines: Vec<&str> = base.lines().collect();
3043    while lines.last().is_some_and(|l| l.trim().is_empty()) {
3044        lines.pop();
3045    }
3046    // A file we are creating needs an interpreter line; one the user wrote
3047    // already has whichever they chose, and we must not add a second.
3048    if lines.is_empty() {
3049        lines.push(HOOK_SHEBANG);
3050    }
3051    let mut out = lines.join("\n");
3052    out.push_str("\n\n");
3053    out.push_str(block);
3054    Ok(out)
3055}
3056
3057/// Whether `text` is nothing but an interpreter line — the shape a hook file we
3058/// created is left in once our region is stripped out of it.
3059fn only_a_shebang(text: &str) -> bool {
3060    text.lines()
3061        .filter(|l| !l.trim().is_empty())
3062        .all(|l| l.starts_with("#!"))
3063}
3064
3065/// Put the sync block in `hook_file`, creating the file (mode 755, with a
3066/// `#!/bin/sh` line) if it is not there. Returns whether anything changed.
3067///
3068/// Every line the user has in the file is preserved, and running this twice
3069/// with the same arguments writes nothing the second time. A file whose
3070/// mushroomdb block was hand-edited so its closing marker is gone is an error
3071/// and is left byte-for-byte alone; see [`strip_hook_block`].
3072pub fn merge_git_hook(hook_file: &Path, shell: &str, store: &StoreRef) -> Result<bool, CliError> {
3073    let existing = if hook_file.exists() {
3074        Some(
3075            fs::read_to_string(hook_file)
3076                .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?,
3077        )
3078    } else {
3079        None
3080    };
3081    let next = merged_hook_text(existing.as_deref(), &git_hook_block(shell, store))
3082        .map_err(|()| unterminated(hook_file))?;
3083    if existing.as_deref() == Some(next.as_str()) {
3084        return Ok(false);
3085    }
3086    let parent = hook_file.parent().unwrap_or(Path::new("."));
3087    fs::create_dir_all(parent)
3088        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
3089    fs::write(hook_file, &next)
3090        .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
3091    #[cfg(unix)]
3092    {
3093        use std::os::unix::fs::PermissionsExt;
3094        // git ignores a hook that is not executable, so this is not cosmetic.
3095        fs::set_permissions(hook_file, fs::Permissions::from_mode(0o755)).map_err(|e| {
3096            CliError(format!(
3097                "cannot make {} executable: {e}",
3098                hook_file.display()
3099            ))
3100        })?;
3101    }
3102    Ok(true)
3103}
3104
3105/// Take the sync block back out of `hook_file`. Returns whether anything
3106/// changed.
3107///
3108/// The file itself is deleted only when nothing but an interpreter line is
3109/// left, which is exactly the state a hook *we* created is in — a hook the user
3110/// wrote has their lines in it and is rewritten rather than removed. An empty
3111/// stub of theirs would be deleted too, which git cannot tell apart from the
3112/// stub never having existed.
3113///
3114/// An unterminated block is an error and the file is left alone; see
3115/// [`strip_hook_block`].
3116pub fn remove_git_hook(hook_file: &Path) -> Result<bool, CliError> {
3117    if !hook_file.exists() {
3118        return Ok(false);
3119    }
3120    let existing = fs::read_to_string(hook_file)
3121        .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?;
3122    let next = match strip_hook_block(&existing) {
3123        // None of it is ours; leave the file untouched.
3124        Stripped::Absent => return Ok(false),
3125        Stripped::Removed(rest) => rest,
3126        Stripped::Unterminated => return Err(unterminated(hook_file)),
3127    };
3128    if only_a_shebang(&next) {
3129        fs::remove_file(hook_file)
3130            .map_err(|e| CliError(format!("cannot remove {}: {e}", hook_file.display())))?;
3131        return Ok(true);
3132    }
3133    fs::write(hook_file, next)
3134        .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
3135    Ok(true)
3136}
3137
3138// ---------------------------------------------------------------------------
3139// Utilities
3140// ---------------------------------------------------------------------------
3141
3142/// True if the file exists and its content equals `expected`.
3143fn file_matches(path: &Path, expected: &str) -> bool {
3144    fs::read_to_string(path)
3145        .map(|s| s == expected)
3146        .unwrap_or(false)
3147}