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