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