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// ---------------------------------------------------------------------------
61// How the server is invoked
62// ---------------------------------------------------------------------------
63
64/// How the MCP server entry (and the skill's bootstrap commands) invoke
65/// mushroomdb.
66///
67/// The assistant host spawns the MCP server by `command`, so that command has
68/// to resolve from *its* process, not from the shell install ran in. A bare
69/// name only works when it resolves on the host's PATH, and the one case where
70/// that is provable is when the `mushroomdb` PATH resolves to is this very
71/// executable. Everything else — npm's Node shim, a different build, a local
72/// `target/release` binary, no hit at all — pins the published package and
73/// lets `npx` fetch it.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum McpCommand {
76    /// `npx -y mushroomdb@<version> …` — the default, and the only form that
77    /// works from a machine where nothing is installed globally.
78    Npx { version: String },
79    /// An absolute path the user named with `--command`.
80    Explicit(PathBuf),
81    /// `mushroomdb` resolves on PATH *and* is this executable: the bare name
82    /// is safe and follows upgrades.
83    OnPath,
84}
85
86impl McpCommand {
87    /// The `npx` form pinned to the version of the binary writing it.
88    #[must_use]
89    pub fn npx() -> Self {
90        McpCommand::Npx {
91            version: CRATE_VERSION.to_string(),
92        }
93    }
94
95    /// The program to exec and the arguments that come before the subcommand.
96    fn program(&self) -> (String, Vec<String>) {
97        match self {
98            McpCommand::Npx { version } => (
99                "npx".to_string(),
100                vec!["-y".to_string(), format!("{NPM_PACKAGE}@{version}")],
101            ),
102            McpCommand::Explicit(p) => (p.to_string_lossy().into_owned(), Vec::new()),
103            McpCommand::OnPath => (BIN_NAME.to_string(), Vec::new()),
104        }
105    }
106
107    /// The MCP server entry: `{"command": …, "args": [… , sub, db]}`.
108    ///
109    /// Nothing here is shell-quoted. An MCP host spawns the command with an
110    /// argv, so a path with a space in it is one element and quoting it would
111    /// make the quotes part of the filename.
112    #[must_use]
113    pub fn json_entry(&self, sub: &str, db: &str) -> serde_json::Value {
114        let (command, mut args) = self.program();
115        args.push(sub.to_string());
116        args.push(db.to_string());
117        serde_json::json!({ "command": command, "args": args })
118    }
119
120    /// The same invocation as a command *prefix* for a POSIX shell, already
121    /// quoted where quoting is needed.
122    ///
123    /// Hook entries and the skill's copy-paste lines are read by a shell, so
124    /// an explicit path has to survive a space in it. The bare name and the
125    /// `npx` form contain no metacharacters and are left as they read.
126    #[must_use]
127    pub fn shell(&self) -> String {
128        let (command, args) = self.program();
129        let mut out = match self {
130            McpCommand::Explicit(_) => sh_quote(&command),
131            _ => command,
132        };
133        for a in args {
134            out.push(' ');
135            out.push_str(&a);
136        }
137        out
138    }
139
140    /// The full argv, for handing to another CLI that registers servers.
141    fn argv(&self, sub: &str, db: &str) -> Vec<String> {
142        let (command, mut args) = self.program();
143        args.push(sub.to_string());
144        args.push(db.to_string());
145        let mut out = vec![command];
146        out.extend(args);
147        out
148    }
149}
150
151/// Decide how the MCP entry should invoke mushroomdb, from the options and the
152/// real environment.
153#[must_use]
154pub fn detect_mcp_command(opts: &InstallOpts) -> McpCommand {
155    if let Some(path) = &opts.command {
156        return McpCommand::Explicit(path.clone());
157    }
158    match std::env::current_exe() {
159        Ok(exe) => classify_mcp_command(std::env::var_os("PATH").as_deref(), &exe),
160        // Cannot locate ourselves — the pinned package always resolves.
161        Err(_) => McpCommand::npx(),
162    }
163}
164
165/// Pure classifier behind [`detect_mcp_command`]: decide whether the
166/// `mushroomdb` that PATH resolves to is the executable now running.
167///
168/// A file named `mushroomdb` on PATH is not enough. `npx mushroomdb install`
169/// prepends `~/.npm/_npx/<hash>/node_modules/.bin` to PATH, and the
170/// `mushroomdb` there is npm's Node shim (`#!/usr/bin/env node`), not our
171/// native binary; `npm i -g mushroomdb` installs the same shim. Treating that
172/// as "on PATH" wrote a bare `mushroomdb` command that resolved only inside
173/// the npx-spawned shell, so the MCP server and recall hook died with ENOENT
174/// everywhere else (the v0.5.0 bug).
175///
176/// So: take the first PATH hit — that is what a bare name would resolve to —
177/// and canonicalize both it and `current_exe`. Equal paths mean the bare name
178/// runs this very executable, including via a symlink (how `cargo install` and
179/// Homebrew expose it), which is the one case where the bare name is safe and
180/// survives upgrades. Anything else means pinning the published package.
181#[must_use]
182pub fn classify_mcp_command(path_var: Option<&OsStr>, current_exe: &Path) -> McpCommand {
183    // What a bare `mushroomdb` would resolve to: the first PATH entry holding
184    // a file by that name (`is_file` follows symlinks, so links count).
185    let Some(hit) = path_var.and_then(|p| {
186        std::env::split_paths(p)
187            .map(|dir| dir.join(BIN_NAME))
188            .find(|candidate| candidate.is_file())
189    }) else {
190        return McpCommand::npx();
191    };
192
193    // Identity, not name. Canonicalizing resolves symlinks and `..`, so a link
194    // to us compares equal; if either side cannot be resolved we cannot prove
195    // it is us, and the pinned package is the answer that always works.
196    match (fs::canonicalize(&hit), fs::canonicalize(current_exe)) {
197        (Ok(on_path), Ok(running)) if on_path == running => McpCommand::OnPath,
198        _ => McpCommand::npx(),
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Options
204// ---------------------------------------------------------------------------
205
206/// Which assistant platform(s) to wire up.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Platform {
209    ClaudeCode,
210    Cursor,
211    Codex,
212    All,
213}
214
215impl Platform {
216    pub fn parse(s: &str) -> Result<Self, String> {
217        match s {
218            "claude-code" => Ok(Platform::ClaudeCode),
219            "cursor" => Ok(Platform::Cursor),
220            "codex" => Ok(Platform::Codex),
221            "all" => Ok(Platform::All),
222            other => Err(format!(
223                "--platform must be claude-code | cursor | codex | all, got: {other}"
224            )),
225        }
226    }
227
228    pub(crate) fn label(&self) -> &'static str {
229        match self {
230            Platform::ClaudeCode => "claude-code",
231            Platform::Cursor => "cursor",
232            Platform::Codex => "codex",
233            Platform::All => "all",
234        }
235    }
236}
237
238/// Where the install lives: alongside one repository, or once for the user.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum Scope {
241    Project,
242    User,
243}
244
245impl Scope {
246    pub(crate) fn label(self) -> &'static str {
247        match self {
248            Scope::Project => "project",
249            Scope::User => "user",
250        }
251    }
252}
253
254/// Options parsed from `mushroomdb install [flags]` or `mushroomdb uninstall [flags]`.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct InstallOpts {
257    /// Which platform to wire up. `None` = auto-detect.
258    pub platform: Option<Platform>,
259    /// Project or user scope. `None` = auto: project inside a git checkout.
260    pub scope: Option<Scope>,
261    /// Database directory. `None` = use the scope default.
262    pub db: Option<PathBuf>,
263    /// `--command <path>`: invoke this binary instead of `npx`/the bare name.
264    pub command: Option<PathBuf>,
265    /// Write the `post-commit` / `post-checkout` / `post-merge` sync hooks.
266    pub git_hooks: bool,
267    /// Run `npx -y mushroomdb@<v> --version` once so the first real spawn is
268    /// not a cold download.
269    pub prewarm: bool,
270}
271
272/// The store directory an install with no `--db` uses.
273#[must_use]
274pub fn default_db(scope: Scope, project_root: &Path, home: &Path) -> PathBuf {
275    match scope {
276        Scope::Project => project_root.join("mushroom-memory"),
277        Scope::User => home.join(".mushroomdb").join("memory"),
278    }
279}
280
281/// Resolve the scope, and say whether it was inferred.
282///
283/// A git checkout is a project: its store belongs beside it, is ignored by the
284/// repository, and its hooks fire on its commits. Anywhere else there is no
285/// project to scope to, so the install is the user's.
286pub(crate) fn resolve_scope(project_root: &Path, requested: Option<Scope>) -> (Scope, bool) {
287    match requested {
288        Some(s) => (s, false),
289        None if project_root.join(".git").exists() => (Scope::Project, true),
290        None => (Scope::User, true),
291    }
292}
293
294// ---------------------------------------------------------------------------
295// External programs
296// ---------------------------------------------------------------------------
297
298/// The world outside the two directories install is given: the programs it
299/// shells out to (`codex`, `npx`) and how long it will wait for them.
300///
301/// Carried explicitly rather than read from the process environment at the
302/// point of use, so a test can point PATH at a directory of stand-ins without
303/// mutating global state that its neighbours share.
304#[derive(Debug, Clone)]
305pub struct Externals {
306    /// PATH used to resolve external programs. `None` resolves nothing.
307    pub path: Option<OsString>,
308    /// Budget for the pre-warm.
309    pub prewarm_timeout: Duration,
310}
311
312impl Externals {
313    /// The real process environment.
314    #[must_use]
315    pub fn from_env() -> Self {
316        Self::with_path(std::env::var_os("PATH"))
317    }
318
319    /// The same, with an explicit PATH.
320    #[must_use]
321    pub fn with_path(path: Option<OsString>) -> Self {
322        Self {
323            path,
324            prewarm_timeout: Duration::from_secs(PREWARM_TIMEOUT_SECS),
325        }
326    }
327
328    /// The first executable named `program` on this PATH.
329    pub(crate) fn which(&self, program: &str) -> Option<PathBuf> {
330        let path = self.path.as_ref()?;
331        std::env::split_paths(path)
332            .map(|dir| dir.join(program))
333            .find(|c| is_executable(c))
334    }
335}
336
337fn is_executable(path: &Path) -> bool {
338    let Ok(meta) = fs::metadata(path) else {
339        return false;
340    };
341    if !meta.is_file() {
342        return false;
343    }
344    #[cfg(unix)]
345    {
346        use std::os::unix::fs::PermissionsExt;
347        meta.permissions().mode() & 0o111 != 0
348    }
349    #[cfg(not(unix))]
350    {
351        true
352    }
353}
354
355/// Run `bin` to completion, returning its stderr (trimmed) on a non-zero exit.
356fn run_and_capture(bin: &Path, args: &[String]) -> Result<(), String> {
357    let out = std::process::Command::new(bin)
358        .args(args)
359        .output()
360        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
361    if out.status.success() {
362        return Ok(());
363    }
364    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
365    let detail = if stderr.is_empty() {
366        String::new()
367    } else {
368        format!(": {stderr}")
369    };
370    Err(format!(
371        "{} {} exited with {}{detail}",
372        bin.display(),
373        args.join(" "),
374        out.status
375    ))
376}
377
378/// Run `bin`, giving up after `timeout`. Output is discarded — only the exit
379/// status matters — so the child cannot block on a pipe nobody drains.
380fn run_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<(), String> {
381    let mut child = std::process::Command::new(bin)
382        .args(args)
383        .stdin(std::process::Stdio::null())
384        .stdout(std::process::Stdio::null())
385        .stderr(std::process::Stdio::null())
386        .spawn()
387        .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
388    let deadline = Instant::now() + timeout;
389    loop {
390        match child.try_wait() {
391            Ok(Some(status)) if status.success() => return Ok(()),
392            Ok(Some(status)) => return Err(format!("exited with {status}")),
393            Ok(None) => {}
394            Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
395        }
396        if Instant::now() >= deadline {
397            let _ = child.kill();
398            let _ = child.wait();
399            return Err(format!("timed out after {}s", timeout.as_secs()));
400        }
401        std::thread::sleep(Duration::from_millis(25));
402    }
403}
404
405// ---------------------------------------------------------------------------
406// Manifest — tracks everything install wrote so uninstall can undo it.
407// ---------------------------------------------------------------------------
408
409#[derive(Serialize, Deserialize, Default, Debug)]
410struct Manifest {
411    /// Files created by this install (absolute paths).
412    files: Vec<PathBuf>,
413    /// MCP JSON keys added by this install.
414    mcp_keys: Vec<ManagedMcpKey>,
415    /// Hook entries added to a settings.json by this install.
416    #[serde(default)]
417    hooks: Vec<ManagedHook>,
418    /// Git hook files this install put its block into.
419    #[serde(default)]
420    git_hooks: Vec<PathBuf>,
421    /// Single lines added to a file the user owns (the `.gitignore` entry).
422    #[serde(default)]
423    gitignore: Vec<ManagedLine>,
424    /// Whether a Codex MCP server was registered through the `codex` CLI.
425    #[serde(default)]
426    codex: bool,
427}
428
429impl Manifest {
430    /// Drop entries no version of this install may act on as written. See
431    /// [`load_manifest`] for why a `.gitignore` in `files` is one of them.
432    fn sanitised(mut self) -> Self {
433        self.files
434            .retain(|f| f.file_name() != Some(OsStr::new(".gitignore")));
435        self
436    }
437
438    fn is_empty(&self) -> bool {
439        self.files.is_empty()
440            && self.mcp_keys.is_empty()
441            && self.hooks.is_empty()
442            && self.git_hooks.is_empty()
443            && self.gitignore.is_empty()
444            && !self.codex
445    }
446}
447
448#[derive(Serialize, Deserialize, Debug, Clone)]
449struct ManagedMcpKey {
450    /// The JSON file the key was added to (absolute path).
451    file: PathBuf,
452    /// The key inside `mcpServers`.
453    server: String,
454}
455
456#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
457struct ManagedHook {
458    /// The settings.json file the hook was added to (absolute path).
459    file: PathBuf,
460    /// The hook event name (e.g. `UserPromptSubmit`).
461    event: String,
462    /// The exact command string that was added.
463    command: String,
464}
465
466/// One line this install appended to a text file the user owns.
467#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
468struct ManagedLine {
469    /// The file the line was added to (absolute path).
470    file: PathBuf,
471    /// The exact line, without its newline.
472    line: String,
473    /// Whether the file itself did not exist before this install. Only such a
474    /// file may be deleted on uninstall, and only if nothing is left in it.
475    #[serde(default)]
476    created: bool,
477}
478
479/// Claude Code hook event this install wires: fires before each prompt is
480/// sent, so the recall digest lands as context ahead of the user's turn.
481pub(crate) const HOOK_EVENT: &str = "UserPromptSubmit";
482/// Kept short: the hook must never noticeably slow a prompt.
483const HOOK_TIMEOUT_SECS: u64 = 5;
484
485/// The second hook event: fires after a tool call, so an edit reaches the
486/// graph while the assistant is still working rather than at the next commit.
487pub(crate) const TOUCH_EVENT: &str = "PostToolUse";
488/// The tools that change a file on disk. Anything else — a read, a search, a
489/// shell command — leaves the working tree as the graph already has it.
490const TOUCH_MATCHER: &str = "Edit|Write|MultiEdit";
491/// Longer than the prompt hook's: re-extracting a file costs more than reading
492/// a digest, and nothing is waiting on the answer. The run is `async`, so this
493/// bounds a background process rather than the assistant's turn.
494const TOUCH_TIMEOUT_SECS: u64 = 30;
495
496/// Single-quote `s` for embedding in a POSIX shell command line, escaping
497/// embedded single quotes as `'\''`. Claude Code runs a `type: "command"`
498/// hook through a shell, so an unquoted path containing whitespace or shell
499/// metacharacters is word-split and the hook silently receives the wrong
500/// arguments — quoting keeps the command exact.
501fn sh_quote(s: &str) -> String {
502    format!("'{}'", s.replace('\'', r"'\''"))
503}
504
505/// The exact command string written into the hook entry. `shell` is already
506/// quoted; only the database path still needs it.
507fn recall_hook_command(shell: &str, db_str: &str) -> String {
508    format!("{shell} recall {}", sh_quote(db_str))
509}
510
511/// The exact command string written into the post-edit hook entry. `touch` in
512/// hook mode prints nothing and exits 0 whatever it is handed.
513fn touch_hook_command(shell: &str, db_str: &str) -> String {
514    format!("{shell} touch {}", sh_quote(db_str))
515}
516
517/// One `hooks.<event>` array entry in Claude Code's settings.json shape.
518fn hook_entry(command: &str) -> serde_json::Value {
519    serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
520}
521
522/// The `PostToolUse` entry: matched to the file-editing tools, and `async` so
523/// the assistant's tool call returns without waiting for the re-extraction.
524fn touch_hook_entry(command: &str) -> serde_json::Value {
525    serde_json::json!({
526        "matcher": TOUCH_MATCHER,
527        "hooks": [ {
528            "type": "command",
529            "command": command,
530            "timeout": TOUCH_TIMEOUT_SECS,
531            "async": true
532        } ]
533    })
534}
535
536/// True if any hook group under `event` contains a command hook equal to `command`.
537pub(crate) fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
538    root["hooks"][event]
539        .as_array()
540        .map(|groups| {
541            groups.iter().any(|g| {
542                g["hooks"]
543                    .as_array()
544                    .map(|hs| hs.iter().any(|h| h["command"] == command))
545                    .unwrap_or(false)
546            })
547        })
548        .unwrap_or(false)
549}
550
551/// Add one hook to `settings_file` (created if absent). Idempotent: no-op if
552/// `command` is already present under `event`. Every other key in the file —
553/// including other hook events and groups — is preserved. Errors out (no
554/// write) rather than overwriting if `hooks` or `hooks.<event>` already exists
555/// with an unexpected JSON type, or if the file's top level is not a JSON
556/// object.
557///
558/// `entry` is the group to append, built by the caller: the two events this
559/// install wires want different shapes, and only the caller knows which.
560fn merge_hook_entry(
561    settings_file: &Path,
562    event: &str,
563    command: &str,
564    entry: serde_json::Value,
565    manifest: &mut Manifest,
566) -> Result<(), CliError> {
567    let mut root: serde_json::Value = if settings_file.exists() {
568        let raw = fs::read_to_string(settings_file)
569            .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
570        serde_json::from_str(&raw)
571            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
572    } else {
573        serde_json::json!({})
574    };
575
576    if !root.is_object() {
577        return Err(CliError(format!(
578            "{} is not a JSON object at its top level — refusing to add a hook",
579            settings_file.display()
580        )));
581    }
582
583    if settings_has_hook(&root, event, command) {
584        return Ok(());
585    }
586
587    // Validate the shapes we are about to write into before touching
588    // anything: a wrong-shaped `hooks` or `hooks.<event>` value belongs to
589    // the user (or another tool) and must never be silently overwritten.
590    match root.get("hooks") {
591        None => root["hooks"] = serde_json::json!({}),
592        Some(v) if v.is_object() => {}
593        Some(_) => {
594            return Err(CliError(format!(
595                "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
596                settings_file.display()
597            )));
598        }
599    }
600    match root["hooks"].get(event) {
601        None => root["hooks"][event] = serde_json::json!([]),
602        Some(v) if v.is_array() => {}
603        Some(_) => {
604            return Err(CliError(format!(
605                "{}: \"hooks.{event}\" is not a JSON array — refusing to overwrite it",
606                settings_file.display()
607            )));
608        }
609    }
610    root["hooks"][event].as_array_mut().unwrap().push(entry);
611
612    let parent = settings_file.parent().unwrap_or(Path::new("."));
613    fs::create_dir_all(parent)
614        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
615    let json = serde_json::to_string_pretty(&root)
616        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
617    fs::write(settings_file, json)
618        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
619
620    manifest.hooks.push(ManagedHook {
621        file: settings_file.to_path_buf(),
622        event: event.into(),
623        command: command.into(),
624    });
625    Ok(())
626}
627
628/// Remove exactly the hook groups whose only command is `command`; drop the
629/// command from mixed groups; leave everything else semantically unchanged.
630/// Returns whether the file was rewritten — that is, whether the hook was
631/// there to remove at all.
632fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<bool, CliError> {
633    drop_hooks(settings_file, event, |c| c == command)
634}
635
636/// Whether `command` is one of our hook bodies for `db`, whatever binary it
637/// names.
638///
639/// The command prefix is exactly what changes between versions — 0.5.x wrote
640/// the absolute path of a copied binary, 0.6.0 writes an `npx` pin, a
641/// developer's `--command` writes a build path — so identity is the tail: the
642/// subcommand and the quoted database this install is wiring. A hook naming a
643/// *different* database belongs to a different install and is not ours to
644/// touch.
645pub(crate) fn is_our_hook_command(command: &str, sub: &str, db_str: &str) -> bool {
646    command.ends_with(&format!(" {sub} {}", sh_quote(db_str)))
647}
648
649/// Take out every hook of ours for `event` that is not the one we are about
650/// to write. Returns whether anything was removed.
651///
652/// Without this an upgrade appends: `merge_hook_entry` matches on the exact
653/// command string, so a 0.5.x entry naming `~/.mushroomdb/bin/mushroomdb` is
654/// not recognised, survives, and keeps running alongside the new one — two
655/// recall digests injected on every prompt.
656fn remove_stale_hooks(
657    settings_file: &Path,
658    event: &str,
659    sub: &str,
660    db_str: &str,
661    desired: &str,
662) -> Result<bool, CliError> {
663    drop_hooks(settings_file, event, |c| {
664        c != desired && is_our_hook_command(c, sub, db_str)
665    })
666}
667
668/// Drop every hook under `event` whose command satisfies `drop_it`, pruning
669/// groups that end up empty. Returns whether the file was rewritten.
670///
671/// Reads `hooks.<event>` through immutable accessors first, so a settings
672/// file where the user removed the `hooks` key (or `<event>`, or shaped
673/// either as something other than an object/array) is left byte-for-byte
674/// untouched rather than having a stray `null` written back in. Every other
675/// key is preserved, though the file is re-serialized (comments are not
676/// supported since `serde_json` is strict JSON).
677fn drop_hooks(
678    settings_file: &Path,
679    event: &str,
680    drop_it: impl Fn(&str) -> bool,
681) -> Result<bool, CliError> {
682    if !settings_file.exists() {
683        return Ok(false);
684    }
685    let raw = fs::read_to_string(settings_file)
686        .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
687    let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
688        CliError(format!(
689            "corrupt settings json at {}: {e}",
690            settings_file.display()
691        ))
692    })?;
693
694    let Some(mut groups) = root
695        .get("hooks")
696        .and_then(|h| h.get(event))
697        .and_then(|g| g.as_array())
698        .cloned()
699    else {
700        // No matching (or well-shaped) event array — nothing of ours to
701        // remove; leave the file exactly as it is, no write at all.
702        return Ok(false);
703    };
704
705    for g in groups.iter_mut() {
706        if let Some(hs) = g["hooks"].as_array_mut() {
707            hs.retain(|h| !h["command"].as_str().is_some_and(&drop_it));
708        }
709    }
710    groups.retain(|g| {
711        g["hooks"]
712            .as_array()
713            .map(|hs| !hs.is_empty())
714            .unwrap_or(true)
715    });
716
717    let before = root.clone();
718    if groups.is_empty() {
719        root["hooks"].as_object_mut().unwrap().remove(event);
720    } else {
721        root["hooks"][event] = serde_json::Value::Array(groups);
722    }
723    if root == before {
724        // The event array held none of our commands, so there is nothing to
725        // remove. Writing anyway would re-serialize a file we do not own —
726        // `serde_json` is built without `preserve_order`, so the user's key
727        // order and indentation would be rewritten for no reason.
728        return Ok(false);
729    }
730
731    let json = serde_json::to_string_pretty(&root)
732        .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
733    fs::write(settings_file, json)
734        .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
735    Ok(true)
736}
737
738// ---------------------------------------------------------------------------
739// Public entry points
740// ---------------------------------------------------------------------------
741
742/// Everything the write phase needs, gathered once so the per-step functions
743/// stay readable.
744struct Ctx<'a> {
745    project_root: &'a Path,
746    home: &'a Path,
747    scope: Scope,
748    db: &'a str,
749    cmd: &'a McpCommand,
750    ext: &'a Externals,
751    git_hooks: bool,
752    prewarm: bool,
753}
754
755/// Anchor a user-supplied path to `base` when it is relative, and drop any
756/// `./` segments.
757///
758/// A relative `--command` or `--db` is convenient to type and wrong to store:
759/// the assistant spawns the MCP server, and the hooks and git hooks run, from
760/// whatever directory those processes happen to be in, not the one the install
761/// was typed in. Nothing is canonicalized — resolving symlinks would rewrite
762/// a path the user chose deliberately.
763fn absolutise(path: &Path, base: &Path) -> PathBuf {
764    let joined = if path.is_absolute() {
765        path.to_path_buf()
766    } else {
767        base.join(path)
768    };
769    let mut out = PathBuf::new();
770    for c in joined.components() {
771        match c {
772            std::path::Component::CurDir => {}
773            other => out.push(other),
774        }
775    }
776    out
777}
778
779/// Whether `p` is a program name to be looked up on PATH rather than a file to
780/// be anchored: one plain component, no separator, no `.` or `..`.
781///
782/// `--command mushroomdb` means "whatever `mushroomdb` PATH resolves to" and
783/// stays that way in both forms we write — an MCP host resolves a bare
784/// `command` on PATH, and quoting a bare name in a shell does not defeat the
785/// lookup either. Anchoring it would invent `<cwd>/mushroomdb`, a file that
786/// need not exist, and the install would report success over a server that
787/// cannot spawn.
788fn is_bare_program_name(p: &Path) -> bool {
789    let mut components = p.components();
790    matches!(
791        (components.next(), components.next()),
792        (Some(std::path::Component::Normal(_)), None)
793    )
794}
795
796/// Anchor a `--command` unless it is a bare program name.
797fn absolutise_command(path: &Path, base: &Path) -> PathBuf {
798    if is_bare_program_name(path) {
799        path.to_path_buf()
800    } else {
801        absolutise(path, base)
802    }
803}
804
805/// Install the /mushroom skill and MCP server entry for the resolved platforms.
806///
807/// `project_root` is the directory where project-scope config files live
808/// (`.mcp.json`, `.claude/`, `.cursor/`). `home` is the user HOME directory.
809/// Tests pass temp directories for both; main.rs passes real values.
810pub fn run_install(
811    project_root: &Path,
812    home: &Path,
813    opts: &InstallOpts,
814) -> Result<String, CliError> {
815    run_install_with(
816        project_root,
817        home,
818        opts,
819        &detect_mcp_command(opts),
820        &Externals::from_env(),
821    )
822}
823
824/// Like [`run_install`], but with the server command and the external
825/// environment supplied by the caller instead of detected. Tests use this to
826/// stay deterministic and offline; `run_install` is the real-environment
827/// wrapper.
828pub fn run_install_with(
829    project_root: &Path,
830    home: &Path,
831    opts: &InstallOpts,
832    cmd: &McpCommand,
833    ext: &Externals,
834) -> Result<String, CliError> {
835    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
836    let db = opts
837        .db
838        .as_ref()
839        .map(|d| absolutise(d, project_root))
840        .unwrap_or_else(|| default_db(scope, project_root, home));
841    let db_str = db.to_string_lossy();
842    // Whatever the caller handed us, what gets written resolves from anywhere:
843    // an absolute path, or a name PATH answers for.
844    let cmd = &match cmd {
845        McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
846        other => other.clone(),
847    };
848
849    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
850    let platforms = expand_platform(&resolved);
851
852    // Check for anything that would make this install fail halfway before
853    // writing a single byte.
854    for plat in &platforms {
855        preflight_check(project_root, home, plat, scope, &db_str, ext)?;
856    }
857
858    let ctx = Ctx {
859        project_root,
860        home,
861        scope,
862        db: &db_str,
863        cmd,
864        ext,
865        git_hooks: opts.git_hooks,
866        prewarm: opts.prewarm,
867    };
868
869    let manifest_path = manifest_path(project_root, home, scope, &platforms);
870
871    // Load the existing manifest so we can union it with what this run writes.
872    // This covers partial-drift re-installs: if SKILL.md was edited but the MCP
873    // entry is still intact, only the file is re-written this run; unioning
874    // preserves the MCP key in the saved manifest so uninstall cleans it up too.
875    let existing = load_manifest(&manifest_path);
876
877    let mut manifest = Manifest::default();
878    let mut notes: Vec<String> = Vec::new();
879
880    let outcome = write_everything(&ctx, &platforms, &mut manifest, &mut notes);
881    if let Err(e) = outcome {
882        // Persist whatever was already written (an earlier platform's files,
883        // a git hook) so uninstall can still clean up after a partial
884        // failure. Best effort: the original error wins.
885        if !manifest.is_empty() {
886            let merged = union_manifests(load_manifest(&manifest_path), &manifest);
887            let _ = write_manifest(&manifest_path, &merged);
888        }
889        return Err(e);
890    }
891
892    let anything_written = !manifest.is_empty();
893    if anything_written {
894        // Union this-run entries with the existing manifest (dedup by path/key).
895        let merged = union_manifests(existing, &manifest);
896        write_manifest(&manifest_path, &merged)?;
897    }
898
899    let labels: Vec<&str> = platforms.iter().map(Platform::label).collect();
900    let mut out = format!("mushroomdb installed ({})\n", labels.join(", "));
901    out.push_str(&format!(
902        "  scope  {}{}\n",
903        scope.label(),
904        if auto_scope { " (auto-detected)" } else { "" }
905    ));
906    for f in &manifest.files {
907        out.push_str(&format!("  wrote  {}\n", f.display()));
908    }
909    for k in &manifest.mcp_keys {
910        out.push_str(&format!(
911            "  added  mcpServers.{} in {}\n",
912            k.server,
913            k.file.display()
914        ));
915    }
916    for h in &manifest.hooks {
917        out.push_str(&format!(
918            "  added  {} hook in {}\n",
919            h.event,
920            h.file.display()
921        ));
922    }
923    for g in &manifest.gitignore {
924        out.push_str(&format!("  added  {} to {}\n", g.line, g.file.display()));
925    }
926    for h in &manifest.git_hooks {
927        out.push_str(&format!("  added  git hook {}\n", h.display()));
928    }
929    if manifest.codex {
930        out.push_str(&format!("  added  codex mcp server {SERVER_NAME}\n"));
931    }
932    if anything_written {
933        out.push_str(&format!("  manifest  {}\n", manifest_path.display()));
934        out.push_str(&format!("  mcp command  {}\n", cmd.shell()));
935    } else {
936        out.push_str("  (already installed — no changes)\n");
937    }
938    for n in &notes {
939        out.push_str(&format!("  {n}\n"));
940    }
941    out.push_str(&format!(
942        "next: restart Claude Code in {}, then type /mushroom\n",
943        project_root.display()
944    ));
945    Ok(out)
946}
947
948/// The whole write phase, so a failure anywhere in it still leaves the caller
949/// holding the partial manifest.
950fn write_everything(
951    ctx: &Ctx<'_>,
952    platforms: &[Platform],
953    manifest: &mut Manifest,
954    notes: &mut Vec<String>,
955) -> Result<(), CliError> {
956    if let Some(w) = scope_conflict_note(ctx, platforms) {
957        notes.push(w);
958    }
959
960    for plat in platforms {
961        install_platform(ctx, plat, manifest, notes)?;
962    }
963
964    // The repository-level wiring is shared by the platforms whose config
965    // lives in the repository: the store is ignored by git and the graph is
966    // re-synced after commits whichever of them reads it.
967    //
968    // A Codex-only install is excluded. It writes nothing else project-local
969    // (Codex keeps its own config, and the manifest for it lives under the
970    // home directory), so an ignore line and three git hooks recorded there
971    // would be removed by a `uninstall --platform codex` out from under a
972    // Claude Code install that shares the repository and never recorded them.
973    let repo_wiring = platforms
974        .iter()
975        .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
976    if ctx.scope == Scope::Project && repo_wiring {
977        ensure_gitignore_line(ctx, manifest)?;
978        if ctx.git_hooks {
979            install_git_hooks(ctx, manifest)?;
980        }
981    }
982
983    if let Some(w) = prewarm(ctx) {
984        notes.push(w);
985    }
986    Ok(())
987}
988
989/// Uninstall: remove exactly what install wrote. Reads the manifest.
990pub fn run_uninstall(
991    project_root: &Path,
992    home: &Path,
993    opts: &InstallOpts,
994) -> Result<String, CliError> {
995    run_uninstall_with(project_root, home, opts, &Externals::from_env())
996}
997
998/// Like [`run_uninstall`], with the external environment supplied by the
999/// caller (Codex removal shells out to the `codex` CLI).
1000pub fn run_uninstall_with(
1001    project_root: &Path,
1002    home: &Path,
1003    opts: &InstallOpts,
1004    ext: &Externals,
1005) -> Result<String, CliError> {
1006    let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1007    let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1008    let platforms = expand_platform(&resolved);
1009
1010    let mut scope = scope;
1011    let mut manifest_path = manifest_path(project_root, home, scope, &platforms);
1012    // An inferred scope is a guess, and guessing wrong here means telling
1013    // someone with a perfectly good user-scope install that they have nothing
1014    // to uninstall — 0.5.x had no scope detection, so every install made by it
1015    // inside a checkout is exactly that case. A scope the user stated is not
1016    // second-guessed.
1017    if auto_scope && !manifest_path.exists() {
1018        let other = match scope {
1019            Scope::Project => Scope::User,
1020            Scope::User => Scope::Project,
1021        };
1022        let alt = self::manifest_path(project_root, home, other, &platforms);
1023        if alt.exists() {
1024            scope = other;
1025            manifest_path = alt;
1026        }
1027    }
1028    if !manifest_path.exists() {
1029        return Err(CliError(format!(
1030            "no install manifest found at {} — nothing to uninstall",
1031            manifest_path.display()
1032        )));
1033    }
1034
1035    let raw = fs::read_to_string(&manifest_path)
1036        .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
1037    let manifest: Manifest = serde_json::from_str::<Manifest>(&raw)
1038        .map_err(|e| CliError(format!("corrupt manifest: {e}")))?
1039        .sanitised();
1040
1041    let mut removed = Vec::new();
1042
1043    // Remove MCP keys first (before files, in case files include .mcp.json).
1044    // Each line is printed only for a removal that happened: after an upgrade
1045    // the manifest also lists the commands the upgrade already replaced, and
1046    // claiming to have removed those would be a report of work not done.
1047    for key in &manifest.mcp_keys {
1048        if remove_mcp_key(&key.file, &key.server)? {
1049            removed.push(format!(
1050                "removed  mcpServers.{} from {}",
1051                key.server,
1052                key.file.display()
1053            ));
1054        }
1055    }
1056
1057    // Remove hooks (before files, same reasoning as MCP keys).
1058    for h in &manifest.hooks {
1059        if remove_hook_entry(&h.file, &h.event, &h.command)? {
1060            removed.push(format!(
1061                "removed  {} hook from {}",
1062                h.event,
1063                h.file.display()
1064            ));
1065        }
1066    }
1067
1068    // Git hooks: the marked region only, never the user's own lines.
1069    for h in &manifest.git_hooks {
1070        if remove_git_hook(h)? {
1071            removed.push(format!("removed  git hook block from {}", h.display()));
1072        }
1073    }
1074
1075    // The ignore line, exactly as it was written. A file that exists only
1076    // because install created it goes too — but only when our line was all it
1077    // ever held; anything the user added to it since is theirs to keep.
1078    for g in &manifest.gitignore {
1079        if remove_line(&g.file, &g.line)? {
1080            removed.push(format!("removed  {} from {}", g.line, g.file.display()));
1081        }
1082        if g.created && g.file.exists() && file_is_blank(&g.file) {
1083            fs::remove_file(&g.file)
1084                .map_err(|e| CliError(format!("cannot remove {}: {e}", g.file.display())))?;
1085            removed.push(format!("removed  {}", g.file.display()));
1086        }
1087    }
1088
1089    // Codex holds its own config; hand the removal back to its CLI.
1090    if manifest.codex {
1091        match ext.which("codex") {
1092            Some(bin) => {
1093                run_and_capture(&bin, &["mcp".into(), "remove".into(), SERVER_NAME.into()])
1094                    .map_err(|e| CliError(format!("codex mcp remove failed: {e}")))?;
1095                removed.push(format!("removed  codex mcp server {SERVER_NAME}"));
1096            }
1097            // Not being able to reach `codex` must not strand every other
1098            // thing the manifest lists.
1099            None => removed.push(
1100                "warning: codex is not on PATH — run `codex mcp remove mushroomdb` yourself"
1101                    .to_string(),
1102            ),
1103        }
1104    }
1105
1106    // Remove files.
1107    for f in &manifest.files {
1108        if f.exists() {
1109            fs::remove_file(f)
1110                .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
1111            removed.push(format!("removed  {}", f.display()));
1112        }
1113    }
1114
1115    // Remove the manifest itself.
1116    if manifest_path.exists() {
1117        fs::remove_file(&manifest_path)
1118            .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
1119    }
1120
1121    let mut out = "mushroomdb uninstalled\n".to_string();
1122    out.push_str(&format!(
1123        "  scope  {}{}\n",
1124        scope.label(),
1125        if auto_scope { " (auto-detected)" } else { "" }
1126    ));
1127    for line in &removed {
1128        out.push_str(&format!("  {line}\n"));
1129    }
1130    Ok(out)
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Platform resolution
1135// ---------------------------------------------------------------------------
1136
1137pub(crate) fn resolve_platform(
1138    project_root: &Path,
1139    home: &Path,
1140    requested: Option<&Platform>,
1141) -> Result<Platform, CliError> {
1142    if let Some(p) = requested {
1143        return Ok(p.clone());
1144    }
1145
1146    // Auto-detect. Codex is never inferred: registering with it runs another
1147    // program, which is not something to do because a directory exists.
1148    let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
1149    let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
1150
1151    match (has_claude, has_cursor) {
1152        (true, true) => Ok(Platform::All),
1153        (true, false) => Ok(Platform::ClaudeCode),
1154        (false, true) => Ok(Platform::Cursor),
1155        (false, false) => Err(CliError(
1156            "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
1157             Pass --platform claude-code, --platform cursor, --platform codex, or --platform all."
1158                .to_string(),
1159        )),
1160    }
1161}
1162
1163/// `All` is the two platforms whose config this program writes itself. Codex
1164/// is deliberately not in it: it is wired by running the `codex` CLI, which
1165/// may not exist, and `--platform all` must not fail on a machine that simply
1166/// does not have it.
1167pub(crate) fn expand_platform(p: &Platform) -> Vec<Platform> {
1168    match p {
1169        Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
1170        other => vec![other.clone()],
1171    }
1172}
1173
1174// ---------------------------------------------------------------------------
1175// Pre-flight conflict check (no writes)
1176// ---------------------------------------------------------------------------
1177
1178fn preflight_check(
1179    project_root: &Path,
1180    home: &Path,
1181    platform: &Platform,
1182    scope: Scope,
1183    db_str: &str,
1184    ext: &Externals,
1185) -> Result<(), CliError> {
1186    match platform {
1187        Platform::ClaudeCode => {
1188            check_mcp_conflict(&claude_mcp_file(project_root, home, scope), db_str)
1189        }
1190        Platform::Cursor => check_mcp_conflict(&cursor_mcp_file(project_root, home, scope), db_str),
1191        // Nothing of Codex's is a file we read; what can fail early is the CLI
1192        // being absent, and that is worth saying before anything is written.
1193        Platform::Codex => codex_bin(ext).map(|_| ()),
1194        Platform::All => unreachable!("expand_platform never produces All"),
1195    }
1196}
1197
1198pub(crate) fn claude_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
1199    match scope {
1200        Scope::Project => project_root.join(".mcp.json"),
1201        // User-scope: verified empirically on a live Claude Code install.
1202        // ~/.claude.json holds top-level mcpServers; ~/.claude/settings.json
1203        // holds env/permissions/hooks but no mcpServers key.
1204        Scope::User => home.join(".claude.json"),
1205    }
1206}
1207
1208pub(crate) fn cursor_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
1209    match scope {
1210        Scope::Project => project_root.join(".cursor").join("mcp.json"),
1211        Scope::User => home.join(".cursor").join("mcp.json"),
1212    }
1213}
1214
1215/// The database an existing entry serves: the argument straight after `mcp`.
1216///
1217/// Its position moved between versions — 0.5.x wrote `["mcp", db]`, the npx
1218/// form writes `["-y", "mushroomdb@x.y.z", "mcp", db]` — so the subcommand is
1219/// what locates it, not an index.
1220pub(crate) fn entry_db(entry: &serde_json::Value) -> Option<&str> {
1221    let args = entry["args"].as_array()?;
1222    let at = args.iter().position(|a| a == "mcp")?;
1223    args.get(at + 1)?.as_str()
1224}
1225
1226/// Check if a MCP JSON file has a conflicting `mushroomdb` entry.
1227///
1228/// A conflict is: the file exists, has `mcpServers.mushroomdb`, and the
1229/// database it names differs from what we'd write. An entry for the SAME db
1230/// with a different `command` is ours to repair (a bare name that never
1231/// resolved, an absolute path from 0.5.x, an older version pin), so it is not
1232/// a conflict.
1233fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
1234    if !mcp_file.exists() {
1235        return Ok(());
1236    }
1237    let raw = fs::read_to_string(mcp_file)
1238        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1239    let v: serde_json::Value = serde_json::from_str(&raw)
1240        .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
1241
1242    let existing = &v["mcpServers"][SERVER_NAME];
1243    if existing.is_null() {
1244        return Ok(()); // Key absent — no conflict.
1245    }
1246
1247    let existing_db = entry_db(existing).unwrap_or("");
1248    if existing_db == db_str {
1249        return Ok(()); // Same db — idempotent or repairable, no conflict.
1250    }
1251
1252    Err(CliError(format!(
1253        "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
1254         To update it, run `mushroomdb uninstall` first, then re-install.\n\
1255         Or manually edit {} and remove the existing mushroomdb entry.",
1256        mcp_file.display(),
1257        existing_db,
1258        mcp_file.display()
1259    )))
1260}
1261
1262/// A server registered in the *other* scope still shows up in the assistant,
1263/// and two of them pointed at two stores is a confusing place to be. Say so;
1264/// never touch the other scope's file.
1265fn scope_conflict_note(ctx: &Ctx<'_>, platforms: &[Platform]) -> Option<String> {
1266    if !platforms.contains(&Platform::ClaudeCode) {
1267        return None;
1268    }
1269    let (other, label, flag) = match ctx.scope {
1270        Scope::Project => (
1271            claude_mcp_file(ctx.project_root, ctx.home, Scope::User),
1272            "user",
1273            "--user",
1274        ),
1275        Scope::User => (
1276            claude_mcp_file(ctx.project_root, ctx.home, Scope::Project),
1277            "project",
1278            "--project",
1279        ),
1280    };
1281    if !has_our_server(&other) {
1282        return None;
1283    }
1284    Some(format!(
1285        "warning: a {label}-scope mushroomdb server also exists ({}) — \
1286         both will load; to remove that one run: mushroomdb uninstall {flag}",
1287        other.display()
1288    ))
1289}
1290
1291pub(crate) fn has_our_server(mcp_file: &Path) -> bool {
1292    let Ok(raw) = fs::read_to_string(mcp_file) else {
1293        return false;
1294    };
1295    serde_json::from_str::<serde_json::Value>(&raw)
1296        .map(|v| !v["mcpServers"][SERVER_NAME].is_null())
1297        .unwrap_or(false)
1298}
1299
1300// ---------------------------------------------------------------------------
1301// Per-platform installation
1302// ---------------------------------------------------------------------------
1303
1304fn install_platform(
1305    ctx: &Ctx<'_>,
1306    platform: &Platform,
1307    manifest: &mut Manifest,
1308    notes: &mut Vec<String>,
1309) -> Result<(), CliError> {
1310    match platform {
1311        Platform::ClaudeCode => install_claude_code(ctx, manifest, notes),
1312        Platform::Cursor => install_cursor(ctx, manifest, notes),
1313        Platform::Codex => install_codex(ctx, manifest),
1314        Platform::All => unreachable!("expand_platform never produces All"),
1315    }
1316}
1317
1318/// Substitute both template placeholders. `bin_cmd` is the pre-quoted shell
1319/// form, so the templates carry `{{BIN}}` unquoted.
1320fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
1321    template
1322        .replace(DB_PATH_PLACEHOLDER, db_str)
1323        .replace(BIN_PLACEHOLDER, bin_cmd)
1324}
1325
1326fn install_claude_code(
1327    ctx: &Ctx<'_>,
1328    manifest: &mut Manifest,
1329    notes: &mut Vec<String>,
1330) -> Result<(), CliError> {
1331    let shell = ctx.cmd.shell();
1332    let skill_content = render_template(SKILL_TEMPLATE, ctx.db, &shell);
1333
1334    let skill_dir = match ctx.scope {
1335        Scope::Project => ctx
1336            .project_root
1337            .join(".claude")
1338            .join("skills")
1339            .join("mushroom"),
1340        Scope::User => ctx.home.join(".claude").join("skills").join("mushroom"),
1341    };
1342    let skill_file = skill_dir.join("SKILL.md");
1343
1344    // Idempotent: skip if the file already has the same content.
1345    if !file_matches(&skill_file, &skill_content) {
1346        fs::create_dir_all(&skill_dir)
1347            .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
1348        fs::write(&skill_file, &skill_content)
1349            .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
1350        manifest.files.push(skill_file);
1351    }
1352
1353    let mcp_file = claude_mcp_file(ctx.project_root, ctx.home, ctx.scope);
1354    merge_mcp_entry(&mcp_file, ctx, manifest, notes)?;
1355
1356    // Both hooks: settings.json in the same scope as the skill. The prompt
1357    // hook first, so a manifest lists them in the order they were written.
1358    let settings_file = match ctx.scope {
1359        Scope::Project => ctx.project_root.join(".claude").join("settings.json"),
1360        Scope::User => ctx.home.join(".claude").join("settings.json"),
1361    };
1362    // An earlier install of ours for this same store is replaced, not joined:
1363    // its command names a binary this version no longer writes, and leaving it
1364    // would run both on every prompt.
1365    let recall = recall_hook_command(&shell, ctx.db);
1366    if remove_stale_hooks(&settings_file, HOOK_EVENT, "recall", ctx.db, &recall)? {
1367        notes.push(format!("replaced stale {HOOK_EVENT} hook"));
1368    }
1369    merge_hook_entry(
1370        &settings_file,
1371        HOOK_EVENT,
1372        &recall,
1373        hook_entry(&recall),
1374        manifest,
1375    )?;
1376    let touch = touch_hook_command(&shell, ctx.db);
1377    if remove_stale_hooks(&settings_file, TOUCH_EVENT, "touch", ctx.db, &touch)? {
1378        notes.push(format!("replaced stale {TOUCH_EVENT} hook"));
1379    }
1380    merge_hook_entry(
1381        &settings_file,
1382        TOUCH_EVENT,
1383        &touch,
1384        touch_hook_entry(&touch),
1385        manifest,
1386    )?;
1387
1388    Ok(())
1389}
1390
1391fn install_cursor(
1392    ctx: &Ctx<'_>,
1393    manifest: &mut Manifest,
1394    notes: &mut Vec<String>,
1395) -> Result<(), CliError> {
1396    let rules_content = render_template(CURSOR_RULES_TEMPLATE, ctx.db, &ctx.cmd.shell());
1397
1398    let rules_dir = match ctx.scope {
1399        Scope::Project => ctx.project_root.join(".cursor").join("rules"),
1400        Scope::User => ctx.home.join(".cursor").join("rules"),
1401    };
1402    let rules_file = rules_dir.join("mushroom.mdc");
1403
1404    if !file_matches(&rules_file, &rules_content) {
1405        fs::create_dir_all(&rules_dir)
1406            .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
1407        fs::write(&rules_file, &rules_content)
1408            .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
1409        manifest.files.push(rules_file);
1410    }
1411
1412    let mcp_file = cursor_mcp_file(ctx.project_root, ctx.home, ctx.scope);
1413    merge_mcp_entry(&mcp_file, ctx, manifest, notes)?;
1414
1415    Ok(())
1416}
1417
1418/// The `codex` executable, or an error that says what to do about it.
1419fn codex_bin(ext: &Externals) -> Result<PathBuf, CliError> {
1420    ext.which("codex").ok_or_else(|| {
1421        CliError(
1422            "codex was not found on PATH — install the Codex CLI, or drop \
1423             `--platform codex`"
1424                .to_string(),
1425        )
1426    })
1427}
1428
1429/// Register the server with Codex through its own CLI.
1430///
1431/// Codex owns its configuration file and its format is its business, so this
1432/// writes nothing: it runs `codex mcp add mushroomdb -- <command> <args…>` and
1433/// lets Codex record it. 0.6.0 ships no Codex skill — the MCP tools carry
1434/// their own descriptions, which is what Codex reads.
1435fn install_codex(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
1436    let bin = codex_bin(ctx.ext)?;
1437    let mut args = vec![
1438        "mcp".to_string(),
1439        "add".to_string(),
1440        SERVER_NAME.to_string(),
1441        "--".to_string(),
1442    ];
1443    args.extend(ctx.cmd.argv("mcp", ctx.db));
1444    run_and_capture(&bin, &args).map_err(|e| CliError(format!("codex mcp add failed: {e}")))?;
1445    manifest.codex = true;
1446    Ok(())
1447}
1448
1449// ---------------------------------------------------------------------------
1450// Repository wiring: the ignore line and the sync hooks
1451// ---------------------------------------------------------------------------
1452
1453/// The git hooks a sync belongs in: after a commit lands, after a branch
1454/// changes the working tree, and after a merge brings other people's commits
1455/// in. All three leave the graph a commit behind if they are skipped.
1456pub(crate) const GIT_HOOKS: &[&str] = &["post-commit", "post-checkout", "post-merge"];
1457
1458/// The `.gitignore` line for a store kept inside the repository, or `None`
1459/// when it is kept outside — a repository has no business ignoring a path it
1460/// does not contain.
1461fn gitignore_line(project_root: &Path, db: &str) -> Option<String> {
1462    let rel = Path::new(db).strip_prefix(project_root).ok()?;
1463    if rel.as_os_str().is_empty() {
1464        return None;
1465    }
1466    Some(format!("{}/", rel.to_string_lossy().replace('\\', "/")))
1467}
1468
1469/// Append the store directory to the repository's `.gitignore` unless some
1470/// spelling of it is already listed. Creates the file if it is absent.
1471fn ensure_gitignore_line(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
1472    let Some(line) = gitignore_line(ctx.project_root, ctx.db) else {
1473        return Ok(());
1474    };
1475    let path = ctx.project_root.join(".gitignore");
1476    let existed = path.exists();
1477    let current = match fs::read_to_string(&path) {
1478        Ok(s) => s,
1479        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
1480        Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
1481    };
1482    let bare = line.trim_end_matches('/');
1483    if current
1484        .lines()
1485        .map(str::trim)
1486        .any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
1487    {
1488        return Ok(());
1489    }
1490    let mut next = current;
1491    if !next.is_empty() && !next.ends_with('\n') {
1492        next.push('\n');
1493    }
1494    next.push_str(&line);
1495    next.push('\n');
1496    fs::write(&path, next)
1497        .map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
1498    // `created` is what lets uninstall leave a repository that had no
1499    // `.gitignore` with none again — but only if our line is still all that is
1500    // in it. Anything the user has added since is theirs, and the file stays.
1501    manifest.gitignore.push(ManagedLine {
1502        file: path,
1503        line,
1504        created: !existed,
1505    });
1506    Ok(())
1507}
1508
1509/// Whether the file is gone or holds nothing but whitespace.
1510fn file_is_blank(path: &Path) -> bool {
1511    match fs::read_to_string(path) {
1512        Ok(s) => s.trim().is_empty(),
1513        Err(_) => true,
1514    }
1515}
1516
1517/// Remove one exact line from a text file. Returns whether anything changed;
1518/// a file that does not hold the line is not rewritten at all.
1519fn remove_line(path: &Path, line: &str) -> Result<bool, CliError> {
1520    let Ok(current) = fs::read_to_string(path) else {
1521        return Ok(false);
1522    };
1523    if !current.lines().any(|l| l == line) {
1524        return Ok(false);
1525    }
1526    let kept: Vec<&str> = current.lines().filter(|l| *l != line).collect();
1527    let mut next = kept.join("\n");
1528    if !next.is_empty() {
1529        next.push('\n');
1530    }
1531    fs::write(path, next).map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
1532    Ok(true)
1533}
1534
1535/// The directory holding this checkout's hooks, following the `gitdir:` link a
1536/// worktree or submodule leaves in place of a `.git` directory.
1537pub(crate) fn git_hooks_dir(project_root: &Path) -> Option<PathBuf> {
1538    let dot_git = project_root.join(".git");
1539    if dot_git.is_dir() {
1540        return Some(dot_git.join("hooks"));
1541    }
1542    let text = fs::read_to_string(&dot_git).ok()?;
1543    let target = text.strip_prefix("gitdir:")?.trim();
1544    let target = Path::new(target);
1545    let resolved = if target.is_absolute() {
1546        target.to_path_buf()
1547    } else {
1548        project_root.join(target)
1549    };
1550    Some(resolved.join("hooks"))
1551}
1552
1553fn install_git_hooks(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
1554    // Not a checkout: there is nothing to hook into, and that is not an error.
1555    let Some(dir) = git_hooks_dir(ctx.project_root) else {
1556        return Ok(());
1557    };
1558    let shell = ctx.cmd.shell();
1559    for name in GIT_HOOKS {
1560        let file = dir.join(name);
1561        if merge_git_hook(&file, &shell, ctx.db)? {
1562            manifest.git_hooks.push(file);
1563        }
1564    }
1565    Ok(())
1566}
1567
1568// ---------------------------------------------------------------------------
1569// Pre-warm
1570// ---------------------------------------------------------------------------
1571
1572/// Fetch the pinned package once, so the assistant's first spawn of the MCP
1573/// server is not a cold `npx` download inside a startup timeout.
1574///
1575/// Best effort in every direction: it only applies to the `npx` form, it is
1576/// skipped when asked to be, and a failure is a line in the summary rather
1577/// than a failed install — the entry that was written is correct either way.
1578fn prewarm(ctx: &Ctx<'_>) -> Option<String> {
1579    if !ctx.prewarm {
1580        return None;
1581    }
1582    let McpCommand::Npx { version } = ctx.cmd else {
1583        return None;
1584    };
1585    let args = vec![
1586        "-y".to_string(),
1587        format!("{NPM_PACKAGE}@{version}"),
1588        "--version".to_string(),
1589    ];
1590    let Some(npx) = ctx.ext.which("npx") else {
1591        return Some(
1592            "warning: pre-warm skipped — npx is not on PATH; the first MCP \
1593             spawn will download the package"
1594                .to_string(),
1595        );
1596    };
1597    match run_with_timeout(&npx, &args, ctx.ext.prewarm_timeout) {
1598        Ok(()) => None,
1599        Err(e) => Some(format!(
1600            "warning: pre-warm of {NPM_PACKAGE}@{version} failed ({e}) — \
1601             the first MCP spawn will download the package"
1602        )),
1603    }
1604}
1605
1606// ---------------------------------------------------------------------------
1607// MCP JSON merge helpers
1608// ---------------------------------------------------------------------------
1609
1610/// Add `mcpServers.mushroomdb` to a JSON config file. Creates the file if
1611/// absent. No-op if the entry already matches (idempotent). An entry that is
1612/// present but different is an upgrade: it is rewritten, and the summary says
1613/// so, because a stale command is exactly the failure this replaces.
1614fn merge_mcp_entry(
1615    mcp_file: &Path,
1616    ctx: &Ctx<'_>,
1617    manifest: &mut Manifest,
1618    notes: &mut Vec<String>,
1619) -> Result<(), CliError> {
1620    let mut root: serde_json::Value = if mcp_file.exists() {
1621        let raw = fs::read_to_string(mcp_file)
1622            .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1623        serde_json::from_str(&raw)
1624            .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
1625    } else {
1626        serde_json::json!({})
1627    };
1628
1629    // Ensure `mcpServers` object exists.
1630    if !root["mcpServers"].is_object() {
1631        root["mcpServers"] = serde_json::json!({});
1632    }
1633
1634    let desired = ctx.cmd.json_entry("mcp", ctx.db);
1635    let existing = &root["mcpServers"][SERVER_NAME];
1636
1637    if existing == &desired {
1638        return Ok(()); // Exact match — idempotent.
1639    }
1640    let replaced = !existing.is_null();
1641
1642    // Write the entry.
1643    root["mcpServers"][SERVER_NAME] = desired;
1644
1645    let parent = mcp_file.parent().unwrap_or(Path::new("."));
1646    fs::create_dir_all(parent)
1647        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
1648
1649    let json = serde_json::to_string_pretty(&root)
1650        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
1651    fs::write(mcp_file, json)
1652        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
1653
1654    manifest.mcp_keys.push(ManagedMcpKey {
1655        file: mcp_file.to_path_buf(),
1656        server: SERVER_NAME.to_string(),
1657    });
1658    if replaced {
1659        notes.push(format!(
1660            "updated mcp command in {} → {}",
1661            mcp_file.display(),
1662            ctx.cmd.shell()
1663        ));
1664    }
1665
1666    Ok(())
1667}
1668
1669/// Remove `mcpServers.<server>` from a JSON config file. Leaves the file in
1670/// place (with the key removed) unless `mcpServers` becomes empty, in which
1671/// case we still leave the file (the user may have other keys).
1672///
1673/// Returns whether the key was there. A file that does not hold it is not
1674/// rewritten at all, for the same reason `drop_hooks` does not: re-serializing
1675/// a file we take nothing out of would reorder and re-indent the user's keys
1676/// for no reason.
1677fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<bool, CliError> {
1678    if !mcp_file.exists() {
1679        return Ok(false);
1680    }
1681    let raw = fs::read_to_string(mcp_file)
1682        .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1683    let mut root: serde_json::Value = serde_json::from_str(&raw)
1684        .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
1685
1686    let removed = root["mcpServers"]
1687        .as_object_mut()
1688        .is_some_and(|servers| servers.remove(server).is_some());
1689    if !removed {
1690        return Ok(false);
1691    }
1692
1693    let json = serde_json::to_string_pretty(&root)
1694        .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
1695    fs::write(mcp_file, json)
1696        .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
1697    Ok(true)
1698}
1699
1700// ---------------------------------------------------------------------------
1701// Manifest helpers
1702// ---------------------------------------------------------------------------
1703
1704fn manifest_path(
1705    project_root: &Path,
1706    home: &Path,
1707    scope: Scope,
1708    platforms: &[Platform],
1709) -> PathBuf {
1710    // Codex writes nothing project-local — its registration lives wherever the
1711    // Codex CLI keeps it — so a Codex-only install records itself under the
1712    // home directory whatever the scope, in its own file so it cannot collide
1713    // with a user-scope Claude Code manifest.
1714    if platforms == [Platform::Codex] {
1715        return home.join(".mushroomdb").join("install-manifest-codex.json");
1716    }
1717    if scope == Scope::User {
1718        return home.join(".mushroomdb").join("install-manifest.json");
1719    }
1720    // Project scope: prefer the Claude Code location; fall back to Cursor.
1721    if platforms.contains(&Platform::ClaudeCode) {
1722        project_root
1723            .join(".claude")
1724            .join("skills")
1725            .join("mushroom")
1726            .join(".install-manifest.json")
1727    } else {
1728        project_root.join(".cursor").join(".install-manifest.json")
1729    }
1730}
1731
1732/// Load an existing manifest from `path`. Returns an empty manifest if absent or unparseable.
1733///
1734/// A `.gitignore` is never a file this install may delete outright, whatever a
1735/// manifest says. An earlier 0.6.0 build recorded a `.gitignore` it created in
1736/// `files`, which the uninstall file loop removes unconditionally — taking any
1737/// line the user had added to it since. The `gitignore` entry in the same
1738/// manifest already carries the one line that is ours, and that is the only
1739/// route by which the file may be touched, so the stale `files` entry is
1740/// dropped on the way in.
1741fn load_manifest(path: &Path) -> Manifest {
1742    let raw = match fs::read_to_string(path) {
1743        Ok(s) => s,
1744        Err(_) => return Manifest::default(),
1745    };
1746    serde_json::from_str::<Manifest>(&raw)
1747        .unwrap_or_default()
1748        .sanitised()
1749}
1750
1751/// Union `existing` with `this_run`, deduplicating by path (files, git hooks),
1752/// by (file, server) pair (mcp_keys), and by full equality (hooks, lines).
1753/// Entries from `this_run` win on collision so the manifest always reflects
1754/// the latest state.
1755fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
1756    for f in &this_run.files {
1757        if !existing.files.contains(f) {
1758            existing.files.push(f.clone());
1759        }
1760    }
1761    for k in &this_run.mcp_keys {
1762        let already = existing
1763            .mcp_keys
1764            .iter()
1765            .any(|e| e.file == k.file && e.server == k.server);
1766        if !already {
1767            existing.mcp_keys.push(k.clone());
1768        }
1769    }
1770    for h in &this_run.hooks {
1771        if !existing.hooks.contains(h) {
1772            existing.hooks.push(h.clone());
1773        }
1774    }
1775    for h in &this_run.git_hooks {
1776        if !existing.git_hooks.contains(h) {
1777            existing.git_hooks.push(h.clone());
1778        }
1779    }
1780    for l in &this_run.gitignore {
1781        if !existing.gitignore.contains(l) {
1782            existing.gitignore.push(l.clone());
1783        }
1784    }
1785    existing.codex |= this_run.codex;
1786    existing
1787}
1788
1789fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
1790    let parent = path.parent().unwrap_or(Path::new("."));
1791    fs::create_dir_all(parent).map_err(|e| {
1792        CliError(format!(
1793            "cannot create manifest dir {}: {e}",
1794            parent.display()
1795        ))
1796    })?;
1797    let json = serde_json::to_string_pretty(manifest)
1798        .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
1799    fs::write(path, json)
1800        .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
1801    Ok(())
1802}
1803
1804// ---------------------------------------------------------------------------
1805// Git hook block — `mushroomdb sync` after every commit
1806// ---------------------------------------------------------------------------
1807//
1808// A git hook file belongs to the repository owner, not to us. Everything below
1809// therefore edits one marked region and nothing else: the region is rewritten
1810// in place when it changes, and removing it restores the user's lines exactly.
1811// The pure text transforms are split out from the filesystem wrappers so the
1812// merge and removal rules can be reasoned about — and tested — without a disk.
1813
1814/// Opening marker of the region this module owns inside a git hook.
1815pub const HOOK_BEGIN: &str = "# >>> mushroomdb >>>";
1816/// Closing marker of that region.
1817pub const HOOK_END: &str = "# <<< mushroomdb <<<";
1818/// Written as the first line when we create a hook file ourselves.
1819const HOOK_SHEBANG: &str = "#!/bin/sh";
1820
1821/// The block a git hook runs: one backgrounded, silenced `sync`.
1822///
1823/// Backgrounded (`( … & )` in a subshell, so no job-control notice reaches the
1824/// terminal) because a hook must not make `git commit` wait on a graph
1825/// refresh, and silenced because a hook that prints — or fails — on a store
1826/// that is momentarily busy would be noise on every commit. `sync` exits 3 when
1827/// another process holds the write lock, and the next commit picks the work up.
1828///
1829/// `shell` is the already-quoted command prefix from [`McpCommand::shell`];
1830/// the database path is quoted here, since a path with a space in it would
1831/// otherwise be word-split into two arguments.
1832#[must_use]
1833pub fn git_hook_block(shell: &str, db: &str) -> String {
1834    format!(
1835        "{HOOK_BEGIN}\n( {shell} sync {} >/dev/null 2>&1 & )\n{HOOK_END}\n",
1836        sh_quote(db)
1837    )
1838}
1839
1840/// What [`strip_hook_block`] found in a hook file.
1841enum Stripped {
1842    /// No opening marker: every line belongs to whoever wrote the file.
1843    Absent,
1844    /// A complete region was removed; this is what is left.
1845    Removed(String),
1846    /// An opening marker with no closing marker. Where our region ends is
1847    /// unknowable, so nothing may be removed.
1848    Unterminated,
1849}
1850
1851/// `text` with our marked region removed.
1852///
1853/// Blank lines left dangling at the end are dropped, so a merge followed by a
1854/// removal returns the original bytes rather than the original plus the blank
1855/// separator the merge inserted.
1856///
1857/// An opening marker with no closing marker is [`Stripped::Unterminated`]
1858/// rather than "ours to the end of the file". Someone hand-edited the region,
1859/// and the lines below the opening marker are now as likely to be theirs as
1860/// ours — a `make lint` they added under it would be deleted by the guess.
1861/// Both public helpers turn this into an error and write nothing, which is the
1862/// same rule the rest of this module follows for a config file whose shape it
1863/// does not recognise.
1864fn strip_hook_block(text: &str) -> Stripped {
1865    let mut kept: Vec<&str> = Vec::new();
1866    let mut inside = false;
1867    let mut found = false;
1868    for line in text.lines() {
1869        if !inside && line.trim_end() == HOOK_BEGIN {
1870            inside = true;
1871            found = true;
1872            continue;
1873        }
1874        if inside {
1875            if line.trim_end() == HOOK_END {
1876                inside = false;
1877            }
1878            continue;
1879        }
1880        kept.push(line);
1881    }
1882    if !found {
1883        return Stripped::Absent;
1884    }
1885    if inside {
1886        return Stripped::Unterminated;
1887    }
1888    while kept.last().is_some_and(|l| l.trim().is_empty()) {
1889        kept.pop();
1890    }
1891    let mut out = kept.join("\n");
1892    if !out.is_empty() {
1893        out.push('\n');
1894    }
1895    Stripped::Removed(out)
1896}
1897
1898/// The error both helpers return for [`Stripped::Unterminated`].
1899fn unterminated(hook_file: &Path) -> CliError {
1900    CliError(format!(
1901        "{}: a mushroomdb block opens with `{HOOK_BEGIN}` but never closes \
1902         — refusing to edit it; delete the block by hand and re-run",
1903        hook_file.display()
1904    ))
1905}
1906
1907/// What the hook file should contain once `block` is in it.
1908///
1909/// Idempotent by construction: any existing region is stripped first and the
1910/// fresh one appended, so a re-merge of the same block reproduces the same
1911/// bytes and a merge of a *different* block rewrites in place instead of
1912/// stacking a second region.
1913fn merged_hook_text(existing: Option<&str>, block: &str) -> Result<String, ()> {
1914    let base = match existing {
1915        None => String::new(),
1916        Some(text) => match strip_hook_block(text) {
1917            Stripped::Absent => text.to_string(),
1918            Stripped::Removed(rest) => rest,
1919            Stripped::Unterminated => return Err(()),
1920        },
1921    };
1922    let mut lines: Vec<&str> = base.lines().collect();
1923    while lines.last().is_some_and(|l| l.trim().is_empty()) {
1924        lines.pop();
1925    }
1926    // A file we are creating needs an interpreter line; one the user wrote
1927    // already has whichever they chose, and we must not add a second.
1928    if lines.is_empty() {
1929        lines.push(HOOK_SHEBANG);
1930    }
1931    let mut out = lines.join("\n");
1932    out.push_str("\n\n");
1933    out.push_str(block);
1934    Ok(out)
1935}
1936
1937/// Whether `text` is nothing but an interpreter line — the shape a hook file we
1938/// created is left in once our region is stripped out of it.
1939fn only_a_shebang(text: &str) -> bool {
1940    text.lines()
1941        .filter(|l| !l.trim().is_empty())
1942        .all(|l| l.starts_with("#!"))
1943}
1944
1945/// Put the sync block in `hook_file`, creating the file (mode 755, with a
1946/// `#!/bin/sh` line) if it is not there. Returns whether anything changed.
1947///
1948/// Every line the user has in the file is preserved, and running this twice
1949/// with the same arguments writes nothing the second time. A file whose
1950/// mushroomdb block was hand-edited so its closing marker is gone is an error
1951/// and is left byte-for-byte alone; see [`strip_hook_block`].
1952pub fn merge_git_hook(hook_file: &Path, shell: &str, db: &str) -> Result<bool, CliError> {
1953    let existing = if hook_file.exists() {
1954        Some(
1955            fs::read_to_string(hook_file)
1956                .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?,
1957        )
1958    } else {
1959        None
1960    };
1961    let next = merged_hook_text(existing.as_deref(), &git_hook_block(shell, db))
1962        .map_err(|()| unterminated(hook_file))?;
1963    if existing.as_deref() == Some(next.as_str()) {
1964        return Ok(false);
1965    }
1966    let parent = hook_file.parent().unwrap_or(Path::new("."));
1967    fs::create_dir_all(parent)
1968        .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
1969    fs::write(hook_file, &next)
1970        .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
1971    #[cfg(unix)]
1972    {
1973        use std::os::unix::fs::PermissionsExt;
1974        // git ignores a hook that is not executable, so this is not cosmetic.
1975        fs::set_permissions(hook_file, fs::Permissions::from_mode(0o755)).map_err(|e| {
1976            CliError(format!(
1977                "cannot make {} executable: {e}",
1978                hook_file.display()
1979            ))
1980        })?;
1981    }
1982    Ok(true)
1983}
1984
1985/// Take the sync block back out of `hook_file`. Returns whether anything
1986/// changed.
1987///
1988/// The file itself is deleted only when nothing but an interpreter line is
1989/// left, which is exactly the state a hook *we* created is in — a hook the user
1990/// wrote has their lines in it and is rewritten rather than removed. An empty
1991/// stub of theirs would be deleted too, which git cannot tell apart from the
1992/// stub never having existed.
1993///
1994/// An unterminated block is an error and the file is left alone; see
1995/// [`strip_hook_block`].
1996pub fn remove_git_hook(hook_file: &Path) -> Result<bool, CliError> {
1997    if !hook_file.exists() {
1998        return Ok(false);
1999    }
2000    let existing = fs::read_to_string(hook_file)
2001        .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?;
2002    let next = match strip_hook_block(&existing) {
2003        // None of it is ours; leave the file untouched.
2004        Stripped::Absent => return Ok(false),
2005        Stripped::Removed(rest) => rest,
2006        Stripped::Unterminated => return Err(unterminated(hook_file)),
2007    };
2008    if only_a_shebang(&next) {
2009        fs::remove_file(hook_file)
2010            .map_err(|e| CliError(format!("cannot remove {}: {e}", hook_file.display())))?;
2011        return Ok(true);
2012    }
2013    fs::write(hook_file, next)
2014        .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
2015    Ok(true)
2016}
2017
2018// ---------------------------------------------------------------------------
2019// Utilities
2020// ---------------------------------------------------------------------------
2021
2022/// True if the file exists and its content equals `expected`.
2023fn file_matches(path: &Path, expected: &str) -> bool {
2024    fs::read_to_string(path)
2025        .map(|s| s == expected)
2026        .unwrap_or(false)
2027}