use crate::CliError;
use serde::{Deserialize, Serialize};
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
const BIN_PLACEHOLDER: &str = "{{BIN}}";
const SERVER_NAME: &str = "mushroomdb";
const BIN_NAME: &str = "mushroomdb";
const NPM_PACKAGE: &str = "mushroomdb";
const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
const PREWARM_TIMEOUT_SECS: u64 = 180;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpCommand {
Npx { version: String },
Explicit(PathBuf),
OnPath,
}
impl McpCommand {
#[must_use]
pub fn npx() -> Self {
McpCommand::Npx {
version: CRATE_VERSION.to_string(),
}
}
fn program(&self) -> (String, Vec<String>) {
match self {
McpCommand::Npx { version } => (
"npx".to_string(),
vec!["-y".to_string(), format!("{NPM_PACKAGE}@{version}")],
),
McpCommand::Explicit(p) => (p.to_string_lossy().into_owned(), Vec::new()),
McpCommand::OnPath => (BIN_NAME.to_string(), Vec::new()),
}
}
#[must_use]
pub fn json_entry(&self, sub: &str, db: &str) -> serde_json::Value {
let (command, mut args) = self.program();
args.push(sub.to_string());
args.push(db.to_string());
serde_json::json!({ "command": command, "args": args })
}
#[must_use]
pub fn shell(&self) -> String {
let (command, args) = self.program();
let mut out = match self {
McpCommand::Explicit(_) => sh_quote(&command),
_ => command,
};
for a in args {
out.push(' ');
out.push_str(&a);
}
out
}
fn argv(&self, sub: &str, db: &str) -> Vec<String> {
let (command, mut args) = self.program();
args.push(sub.to_string());
args.push(db.to_string());
let mut out = vec![command];
out.extend(args);
out
}
}
#[must_use]
pub fn detect_mcp_command(opts: &InstallOpts) -> McpCommand {
if let Some(path) = &opts.command {
return McpCommand::Explicit(path.clone());
}
match std::env::current_exe() {
Ok(exe) => classify_mcp_command(std::env::var_os("PATH").as_deref(), &exe),
Err(_) => McpCommand::npx(),
}
}
#[must_use]
pub fn classify_mcp_command(path_var: Option<&OsStr>, current_exe: &Path) -> McpCommand {
let Some(hit) = path_var.and_then(|p| {
std::env::split_paths(p)
.map(|dir| dir.join(BIN_NAME))
.find(|candidate| candidate.is_file())
}) else {
return McpCommand::npx();
};
match (fs::canonicalize(&hit), fs::canonicalize(current_exe)) {
(Ok(on_path), Ok(running)) if on_path == running => McpCommand::OnPath,
_ => McpCommand::npx(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Platform {
ClaudeCode,
Cursor,
Codex,
All,
}
impl Platform {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"claude-code" => Ok(Platform::ClaudeCode),
"cursor" => Ok(Platform::Cursor),
"codex" => Ok(Platform::Codex),
"all" => Ok(Platform::All),
other => Err(format!(
"--platform must be claude-code | cursor | codex | all, got: {other}"
)),
}
}
pub(crate) fn label(&self) -> &'static str {
match self {
Platform::ClaudeCode => "claude-code",
Platform::Cursor => "cursor",
Platform::Codex => "codex",
Platform::All => "all",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Project,
User,
}
impl Scope {
pub(crate) fn label(self) -> &'static str {
match self {
Scope::Project => "project",
Scope::User => "user",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOpts {
pub platform: Option<Platform>,
pub scope: Option<Scope>,
pub db: Option<PathBuf>,
pub command: Option<PathBuf>,
pub git_hooks: bool,
pub prewarm: bool,
}
#[must_use]
pub fn default_db(scope: Scope, project_root: &Path, home: &Path) -> PathBuf {
match scope {
Scope::Project => project_root.join("mushroom-memory"),
Scope::User => home.join(".mushroomdb").join("memory"),
}
}
pub(crate) fn resolve_scope(project_root: &Path, requested: Option<Scope>) -> (Scope, bool) {
match requested {
Some(s) => (s, false),
None if project_root.join(".git").exists() => (Scope::Project, true),
None => (Scope::User, true),
}
}
#[derive(Debug, Clone)]
pub struct Externals {
pub path: Option<OsString>,
pub prewarm_timeout: Duration,
}
impl Externals {
#[must_use]
pub fn from_env() -> Self {
Self::with_path(std::env::var_os("PATH"))
}
#[must_use]
pub fn with_path(path: Option<OsString>) -> Self {
Self {
path,
prewarm_timeout: Duration::from_secs(PREWARM_TIMEOUT_SECS),
}
}
pub(crate) fn which(&self, program: &str) -> Option<PathBuf> {
let path = self.path.as_ref()?;
std::env::split_paths(path)
.map(|dir| dir.join(program))
.find(|c| is_executable(c))
}
}
fn is_executable(path: &Path) -> bool {
let Ok(meta) = fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn run_and_capture(bin: &Path, args: &[String]) -> Result<(), String> {
let out = std::process::Command::new(bin)
.args(args)
.output()
.map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let detail = if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
};
Err(format!(
"{} {} exited with {}{detail}",
bin.display(),
args.join(" "),
out.status
))
}
fn run_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<(), String> {
let mut child = std::process::Command::new(bin)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return Err(format!("exited with {status}")),
Ok(None) => {}
Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!("timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(25));
}
}
#[derive(Serialize, Deserialize, Default, Debug)]
struct Manifest {
files: Vec<PathBuf>,
mcp_keys: Vec<ManagedMcpKey>,
#[serde(default)]
hooks: Vec<ManagedHook>,
#[serde(default)]
git_hooks: Vec<PathBuf>,
#[serde(default)]
gitignore: Vec<ManagedLine>,
#[serde(default)]
codex: bool,
}
impl Manifest {
fn sanitised(mut self) -> Self {
self.files
.retain(|f| f.file_name() != Some(OsStr::new(".gitignore")));
self
}
fn is_empty(&self) -> bool {
self.files.is_empty()
&& self.mcp_keys.is_empty()
&& self.hooks.is_empty()
&& self.git_hooks.is_empty()
&& self.gitignore.is_empty()
&& !self.codex
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ManagedMcpKey {
file: PathBuf,
server: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct ManagedHook {
file: PathBuf,
event: String,
command: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct ManagedLine {
file: PathBuf,
line: String,
#[serde(default)]
created: bool,
}
pub(crate) const HOOK_EVENT: &str = "UserPromptSubmit";
const HOOK_TIMEOUT_SECS: u64 = 5;
pub(crate) const TOUCH_EVENT: &str = "PostToolUse";
const TOUCH_MATCHER: &str = "Edit|Write|MultiEdit";
const TOUCH_TIMEOUT_SECS: u64 = 30;
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn recall_hook_command(shell: &str, db_str: &str) -> String {
format!("{shell} recall {}", sh_quote(db_str))
}
fn touch_hook_command(shell: &str, db_str: &str) -> String {
format!("{shell} touch {}", sh_quote(db_str))
}
fn hook_entry(command: &str) -> serde_json::Value {
serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
}
fn touch_hook_entry(command: &str) -> serde_json::Value {
serde_json::json!({
"matcher": TOUCH_MATCHER,
"hooks": [ {
"type": "command",
"command": command,
"timeout": TOUCH_TIMEOUT_SECS,
"async": true
} ]
})
}
pub(crate) fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
root["hooks"][event]
.as_array()
.map(|groups| {
groups.iter().any(|g| {
g["hooks"]
.as_array()
.map(|hs| hs.iter().any(|h| h["command"] == command))
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn merge_hook_entry(
settings_file: &Path,
event: &str,
command: &str,
entry: serde_json::Value,
manifest: &mut Manifest,
) -> Result<(), CliError> {
let mut root: serde_json::Value = if settings_file.exists() {
let raw = fs::read_to_string(settings_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
serde_json::from_str(&raw)
.map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
} else {
serde_json::json!({})
};
if !root.is_object() {
return Err(CliError(format!(
"{} is not a JSON object at its top level — refusing to add a hook",
settings_file.display()
)));
}
if settings_has_hook(&root, event, command) {
return Ok(());
}
match root.get("hooks") {
None => root["hooks"] = serde_json::json!({}),
Some(v) if v.is_object() => {}
Some(_) => {
return Err(CliError(format!(
"{}: \"hooks\" is not a JSON object — refusing to overwrite it",
settings_file.display()
)));
}
}
match root["hooks"].get(event) {
None => root["hooks"][event] = serde_json::json!([]),
Some(v) if v.is_array() => {}
Some(_) => {
return Err(CliError(format!(
"{}: \"hooks.{event}\" is not a JSON array — refusing to overwrite it",
settings_file.display()
)));
}
}
root["hooks"][event].as_array_mut().unwrap().push(entry);
let parent = settings_file.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
fs::write(settings_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
manifest.hooks.push(ManagedHook {
file: settings_file.to_path_buf(),
event: event.into(),
command: command.into(),
});
Ok(())
}
fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<bool, CliError> {
drop_hooks(settings_file, event, |c| c == command)
}
pub(crate) fn is_our_hook_command(command: &str, sub: &str, db_str: &str) -> bool {
command.ends_with(&format!(" {sub} {}", sh_quote(db_str)))
}
fn remove_stale_hooks(
settings_file: &Path,
event: &str,
sub: &str,
db_str: &str,
desired: &str,
) -> Result<bool, CliError> {
drop_hooks(settings_file, event, |c| {
c != desired && is_our_hook_command(c, sub, db_str)
})
}
fn drop_hooks(
settings_file: &Path,
event: &str,
drop_it: impl Fn(&str) -> bool,
) -> Result<bool, CliError> {
if !settings_file.exists() {
return Ok(false);
}
let raw = fs::read_to_string(settings_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
CliError(format!(
"corrupt settings json at {}: {e}",
settings_file.display()
))
})?;
let Some(mut groups) = root
.get("hooks")
.and_then(|h| h.get(event))
.and_then(|g| g.as_array())
.cloned()
else {
return Ok(false);
};
for g in groups.iter_mut() {
if let Some(hs) = g["hooks"].as_array_mut() {
hs.retain(|h| !h["command"].as_str().is_some_and(&drop_it));
}
}
groups.retain(|g| {
g["hooks"]
.as_array()
.map(|hs| !hs.is_empty())
.unwrap_or(true)
});
let before = root.clone();
if groups.is_empty() {
root["hooks"].as_object_mut().unwrap().remove(event);
} else {
root["hooks"][event] = serde_json::Value::Array(groups);
}
if root == before {
return Ok(false);
}
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
fs::write(settings_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
Ok(true)
}
struct Ctx<'a> {
project_root: &'a Path,
home: &'a Path,
scope: Scope,
db: &'a str,
cmd: &'a McpCommand,
ext: &'a Externals,
git_hooks: bool,
prewarm: bool,
}
fn absolutise(path: &Path, base: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
};
let mut out = PathBuf::new();
for c in joined.components() {
match c {
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn is_bare_program_name(p: &Path) -> bool {
let mut components = p.components();
matches!(
(components.next(), components.next()),
(Some(std::path::Component::Normal(_)), None)
)
}
fn absolutise_command(path: &Path, base: &Path) -> PathBuf {
if is_bare_program_name(path) {
path.to_path_buf()
} else {
absolutise(path, base)
}
}
pub fn run_install(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
) -> Result<String, CliError> {
run_install_with(
project_root,
home,
opts,
&detect_mcp_command(opts),
&Externals::from_env(),
)
}
pub fn run_install_with(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
cmd: &McpCommand,
ext: &Externals,
) -> Result<String, CliError> {
let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
let db = opts
.db
.as_ref()
.map(|d| absolutise(d, project_root))
.unwrap_or_else(|| default_db(scope, project_root, home));
let db_str = db.to_string_lossy();
let cmd = &match cmd {
McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
other => other.clone(),
};
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
for plat in &platforms {
preflight_check(project_root, home, plat, scope, &db_str, ext)?;
}
let ctx = Ctx {
project_root,
home,
scope,
db: &db_str,
cmd,
ext,
git_hooks: opts.git_hooks,
prewarm: opts.prewarm,
};
let manifest_path = manifest_path(project_root, home, scope, &platforms);
let existing = load_manifest(&manifest_path);
let mut manifest = Manifest::default();
let mut notes: Vec<String> = Vec::new();
let outcome = write_everything(&ctx, &platforms, &mut manifest, &mut notes);
if let Err(e) = outcome {
if !manifest.is_empty() {
let merged = union_manifests(load_manifest(&manifest_path), &manifest);
let _ = write_manifest(&manifest_path, &merged);
}
return Err(e);
}
let anything_written = !manifest.is_empty();
if anything_written {
let merged = union_manifests(existing, &manifest);
write_manifest(&manifest_path, &merged)?;
}
let labels: Vec<&str> = platforms.iter().map(Platform::label).collect();
let mut out = format!("mushroomdb installed ({})\n", labels.join(", "));
out.push_str(&format!(
" scope {}{}\n",
scope.label(),
if auto_scope { " (auto-detected)" } else { "" }
));
for f in &manifest.files {
out.push_str(&format!(" wrote {}\n", f.display()));
}
for k in &manifest.mcp_keys {
out.push_str(&format!(
" added mcpServers.{} in {}\n",
k.server,
k.file.display()
));
}
for h in &manifest.hooks {
out.push_str(&format!(
" added {} hook in {}\n",
h.event,
h.file.display()
));
}
for g in &manifest.gitignore {
out.push_str(&format!(" added {} to {}\n", g.line, g.file.display()));
}
for h in &manifest.git_hooks {
out.push_str(&format!(" added git hook {}\n", h.display()));
}
if manifest.codex {
out.push_str(&format!(" added codex mcp server {SERVER_NAME}\n"));
}
if anything_written {
out.push_str(&format!(" manifest {}\n", manifest_path.display()));
out.push_str(&format!(" mcp command {}\n", cmd.shell()));
} else {
out.push_str(" (already installed — no changes)\n");
}
for n in ¬es {
out.push_str(&format!(" {n}\n"));
}
out.push_str(&format!(
"next: restart Claude Code in {}, then type /mushroom\n",
project_root.display()
));
Ok(out)
}
fn write_everything(
ctx: &Ctx<'_>,
platforms: &[Platform],
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
if let Some(w) = scope_conflict_note(ctx, platforms) {
notes.push(w);
}
for plat in platforms {
install_platform(ctx, plat, manifest, notes)?;
}
let repo_wiring = platforms
.iter()
.any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
if ctx.scope == Scope::Project && repo_wiring {
ensure_gitignore_line(ctx, manifest)?;
if ctx.git_hooks {
install_git_hooks(ctx, manifest)?;
}
}
if let Some(w) = prewarm(ctx) {
notes.push(w);
}
Ok(())
}
pub fn run_uninstall(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
) -> Result<String, CliError> {
run_uninstall_with(project_root, home, opts, &Externals::from_env())
}
pub fn run_uninstall_with(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
ext: &Externals,
) -> Result<String, CliError> {
let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
let mut scope = scope;
let mut manifest_path = manifest_path(project_root, home, scope, &platforms);
if auto_scope && !manifest_path.exists() {
let other = match scope {
Scope::Project => Scope::User,
Scope::User => Scope::Project,
};
let alt = self::manifest_path(project_root, home, other, &platforms);
if alt.exists() {
scope = other;
manifest_path = alt;
}
}
if !manifest_path.exists() {
return Err(CliError(format!(
"no install manifest found at {} — nothing to uninstall",
manifest_path.display()
)));
}
let raw = fs::read_to_string(&manifest_path)
.map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
let manifest: Manifest = serde_json::from_str::<Manifest>(&raw)
.map_err(|e| CliError(format!("corrupt manifest: {e}")))?
.sanitised();
let mut removed = Vec::new();
for key in &manifest.mcp_keys {
if remove_mcp_key(&key.file, &key.server)? {
removed.push(format!(
"removed mcpServers.{} from {}",
key.server,
key.file.display()
));
}
}
for h in &manifest.hooks {
if remove_hook_entry(&h.file, &h.event, &h.command)? {
removed.push(format!(
"removed {} hook from {}",
h.event,
h.file.display()
));
}
}
for h in &manifest.git_hooks {
if remove_git_hook(h)? {
removed.push(format!("removed git hook block from {}", h.display()));
}
}
for g in &manifest.gitignore {
if remove_line(&g.file, &g.line)? {
removed.push(format!("removed {} from {}", g.line, g.file.display()));
}
if g.created && g.file.exists() && file_is_blank(&g.file) {
fs::remove_file(&g.file)
.map_err(|e| CliError(format!("cannot remove {}: {e}", g.file.display())))?;
removed.push(format!("removed {}", g.file.display()));
}
}
if manifest.codex {
match ext.which("codex") {
Some(bin) => {
run_and_capture(&bin, &["mcp".into(), "remove".into(), SERVER_NAME.into()])
.map_err(|e| CliError(format!("codex mcp remove failed: {e}")))?;
removed.push(format!("removed codex mcp server {SERVER_NAME}"));
}
None => removed.push(
"warning: codex is not on PATH — run `codex mcp remove mushroomdb` yourself"
.to_string(),
),
}
}
for f in &manifest.files {
if f.exists() {
fs::remove_file(f)
.map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
removed.push(format!("removed {}", f.display()));
}
}
if manifest_path.exists() {
fs::remove_file(&manifest_path)
.map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
}
let mut out = "mushroomdb uninstalled\n".to_string();
out.push_str(&format!(
" scope {}{}\n",
scope.label(),
if auto_scope { " (auto-detected)" } else { "" }
));
for line in &removed {
out.push_str(&format!(" {line}\n"));
}
Ok(out)
}
pub(crate) fn resolve_platform(
project_root: &Path,
home: &Path,
requested: Option<&Platform>,
) -> Result<Platform, CliError> {
if let Some(p) = requested {
return Ok(p.clone());
}
let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
match (has_claude, has_cursor) {
(true, true) => Ok(Platform::All),
(true, false) => Ok(Platform::ClaudeCode),
(false, true) => Ok(Platform::Cursor),
(false, false) => Err(CliError(
"cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
Pass --platform claude-code, --platform cursor, --platform codex, or --platform all."
.to_string(),
)),
}
}
pub(crate) fn expand_platform(p: &Platform) -> Vec<Platform> {
match p {
Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
other => vec![other.clone()],
}
}
fn preflight_check(
project_root: &Path,
home: &Path,
platform: &Platform,
scope: Scope,
db_str: &str,
ext: &Externals,
) -> Result<(), CliError> {
match platform {
Platform::ClaudeCode => {
check_mcp_conflict(&claude_mcp_file(project_root, home, scope), db_str)
}
Platform::Cursor => check_mcp_conflict(&cursor_mcp_file(project_root, home, scope), db_str),
Platform::Codex => codex_bin(ext).map(|_| ()),
Platform::All => unreachable!("expand_platform never produces All"),
}
}
pub(crate) fn claude_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
match scope {
Scope::Project => project_root.join(".mcp.json"),
Scope::User => home.join(".claude.json"),
}
}
pub(crate) fn cursor_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
match scope {
Scope::Project => project_root.join(".cursor").join("mcp.json"),
Scope::User => home.join(".cursor").join("mcp.json"),
}
}
pub(crate) fn entry_db(entry: &serde_json::Value) -> Option<&str> {
let args = entry["args"].as_array()?;
let at = args.iter().position(|a| a == "mcp")?;
args.get(at + 1)?.as_str()
}
fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
if !mcp_file.exists() {
return Ok(());
}
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
let v: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
let existing = &v["mcpServers"][SERVER_NAME];
if existing.is_null() {
return Ok(()); }
let existing_db = entry_db(existing).unwrap_or("");
if existing_db == db_str {
return Ok(()); }
Err(CliError(format!(
"conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
To update it, run `mushroomdb uninstall` first, then re-install.\n\
Or manually edit {} and remove the existing mushroomdb entry.",
mcp_file.display(),
existing_db,
mcp_file.display()
)))
}
fn scope_conflict_note(ctx: &Ctx<'_>, platforms: &[Platform]) -> Option<String> {
if !platforms.contains(&Platform::ClaudeCode) {
return None;
}
let (other, label, flag) = match ctx.scope {
Scope::Project => (
claude_mcp_file(ctx.project_root, ctx.home, Scope::User),
"user",
"--user",
),
Scope::User => (
claude_mcp_file(ctx.project_root, ctx.home, Scope::Project),
"project",
"--project",
),
};
if !has_our_server(&other) {
return None;
}
Some(format!(
"warning: a {label}-scope mushroomdb server also exists ({}) — \
both will load; to remove that one run: mushroomdb uninstall {flag}",
other.display()
))
}
pub(crate) fn has_our_server(mcp_file: &Path) -> bool {
let Ok(raw) = fs::read_to_string(mcp_file) else {
return false;
};
serde_json::from_str::<serde_json::Value>(&raw)
.map(|v| !v["mcpServers"][SERVER_NAME].is_null())
.unwrap_or(false)
}
fn install_platform(
ctx: &Ctx<'_>,
platform: &Platform,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
match platform {
Platform::ClaudeCode => install_claude_code(ctx, manifest, notes),
Platform::Cursor => install_cursor(ctx, manifest, notes),
Platform::Codex => install_codex(ctx, manifest),
Platform::All => unreachable!("expand_platform never produces All"),
}
}
fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
template
.replace(DB_PATH_PLACEHOLDER, db_str)
.replace(BIN_PLACEHOLDER, bin_cmd)
}
fn install_claude_code(
ctx: &Ctx<'_>,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let shell = ctx.cmd.shell();
let skill_content = render_template(SKILL_TEMPLATE, ctx.db, &shell);
let skill_dir = match ctx.scope {
Scope::Project => ctx
.project_root
.join(".claude")
.join("skills")
.join("mushroom"),
Scope::User => ctx.home.join(".claude").join("skills").join("mushroom"),
};
let skill_file = skill_dir.join("SKILL.md");
if !file_matches(&skill_file, &skill_content) {
fs::create_dir_all(&skill_dir)
.map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
fs::write(&skill_file, &skill_content)
.map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
manifest.files.push(skill_file);
}
let mcp_file = claude_mcp_file(ctx.project_root, ctx.home, ctx.scope);
merge_mcp_entry(&mcp_file, ctx, manifest, notes)?;
let settings_file = match ctx.scope {
Scope::Project => ctx.project_root.join(".claude").join("settings.json"),
Scope::User => ctx.home.join(".claude").join("settings.json"),
};
let recall = recall_hook_command(&shell, ctx.db);
if remove_stale_hooks(&settings_file, HOOK_EVENT, "recall", ctx.db, &recall)? {
notes.push(format!("replaced stale {HOOK_EVENT} hook"));
}
merge_hook_entry(
&settings_file,
HOOK_EVENT,
&recall,
hook_entry(&recall),
manifest,
)?;
let touch = touch_hook_command(&shell, ctx.db);
if remove_stale_hooks(&settings_file, TOUCH_EVENT, "touch", ctx.db, &touch)? {
notes.push(format!("replaced stale {TOUCH_EVENT} hook"));
}
merge_hook_entry(
&settings_file,
TOUCH_EVENT,
&touch,
touch_hook_entry(&touch),
manifest,
)?;
Ok(())
}
fn install_cursor(
ctx: &Ctx<'_>,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let rules_content = render_template(CURSOR_RULES_TEMPLATE, ctx.db, &ctx.cmd.shell());
let rules_dir = match ctx.scope {
Scope::Project => ctx.project_root.join(".cursor").join("rules"),
Scope::User => ctx.home.join(".cursor").join("rules"),
};
let rules_file = rules_dir.join("mushroom.mdc");
if !file_matches(&rules_file, &rules_content) {
fs::create_dir_all(&rules_dir)
.map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
fs::write(&rules_file, &rules_content)
.map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
manifest.files.push(rules_file);
}
let mcp_file = cursor_mcp_file(ctx.project_root, ctx.home, ctx.scope);
merge_mcp_entry(&mcp_file, ctx, manifest, notes)?;
Ok(())
}
fn codex_bin(ext: &Externals) -> Result<PathBuf, CliError> {
ext.which("codex").ok_or_else(|| {
CliError(
"codex was not found on PATH — install the Codex CLI, or drop \
`--platform codex`"
.to_string(),
)
})
}
fn install_codex(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
let bin = codex_bin(ctx.ext)?;
let mut args = vec![
"mcp".to_string(),
"add".to_string(),
SERVER_NAME.to_string(),
"--".to_string(),
];
args.extend(ctx.cmd.argv("mcp", ctx.db));
run_and_capture(&bin, &args).map_err(|e| CliError(format!("codex mcp add failed: {e}")))?;
manifest.codex = true;
Ok(())
}
pub(crate) const GIT_HOOKS: &[&str] = &["post-commit", "post-checkout", "post-merge"];
fn gitignore_line(project_root: &Path, db: &str) -> Option<String> {
let rel = Path::new(db).strip_prefix(project_root).ok()?;
if rel.as_os_str().is_empty() {
return None;
}
Some(format!("{}/", rel.to_string_lossy().replace('\\', "/")))
}
fn ensure_gitignore_line(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
let Some(line) = gitignore_line(ctx.project_root, ctx.db) else {
return Ok(());
};
let path = ctx.project_root.join(".gitignore");
let existed = path.exists();
let current = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
};
let bare = line.trim_end_matches('/');
if current
.lines()
.map(str::trim)
.any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
{
return Ok(());
}
let mut next = current;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(&line);
next.push('\n');
fs::write(&path, next)
.map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
manifest.gitignore.push(ManagedLine {
file: path,
line,
created: !existed,
});
Ok(())
}
fn file_is_blank(path: &Path) -> bool {
match fs::read_to_string(path) {
Ok(s) => s.trim().is_empty(),
Err(_) => true,
}
}
fn remove_line(path: &Path, line: &str) -> Result<bool, CliError> {
let Ok(current) = fs::read_to_string(path) else {
return Ok(false);
};
if !current.lines().any(|l| l == line) {
return Ok(false);
}
let kept: Vec<&str> = current.lines().filter(|l| *l != line).collect();
let mut next = kept.join("\n");
if !next.is_empty() {
next.push('\n');
}
fs::write(path, next).map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
Ok(true)
}
pub(crate) fn git_hooks_dir(project_root: &Path) -> Option<PathBuf> {
let dot_git = project_root.join(".git");
if dot_git.is_dir() {
return Some(dot_git.join("hooks"));
}
let text = fs::read_to_string(&dot_git).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let target = Path::new(target);
let resolved = if target.is_absolute() {
target.to_path_buf()
} else {
project_root.join(target)
};
Some(resolved.join("hooks"))
}
fn install_git_hooks(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
let Some(dir) = git_hooks_dir(ctx.project_root) else {
return Ok(());
};
let shell = ctx.cmd.shell();
for name in GIT_HOOKS {
let file = dir.join(name);
if merge_git_hook(&file, &shell, ctx.db)? {
manifest.git_hooks.push(file);
}
}
Ok(())
}
fn prewarm(ctx: &Ctx<'_>) -> Option<String> {
if !ctx.prewarm {
return None;
}
let McpCommand::Npx { version } = ctx.cmd else {
return None;
};
let args = vec![
"-y".to_string(),
format!("{NPM_PACKAGE}@{version}"),
"--version".to_string(),
];
let Some(npx) = ctx.ext.which("npx") else {
return Some(
"warning: pre-warm skipped — npx is not on PATH; the first MCP \
spawn will download the package"
.to_string(),
);
};
match run_with_timeout(&npx, &args, ctx.ext.prewarm_timeout) {
Ok(()) => None,
Err(e) => Some(format!(
"warning: pre-warm of {NPM_PACKAGE}@{version} failed ({e}) — \
the first MCP spawn will download the package"
)),
}
}
fn merge_mcp_entry(
mcp_file: &Path,
ctx: &Ctx<'_>,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let mut root: serde_json::Value = if mcp_file.exists() {
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
serde_json::from_str(&raw)
.map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
} else {
serde_json::json!({})
};
if !root["mcpServers"].is_object() {
root["mcpServers"] = serde_json::json!({});
}
let desired = ctx.cmd.json_entry("mcp", ctx.db);
let existing = &root["mcpServers"][SERVER_NAME];
if existing == &desired {
return Ok(()); }
let replaced = !existing.is_null();
root["mcpServers"][SERVER_NAME] = desired;
let parent = mcp_file.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
fs::write(mcp_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
manifest.mcp_keys.push(ManagedMcpKey {
file: mcp_file.to_path_buf(),
server: SERVER_NAME.to_string(),
});
if replaced {
notes.push(format!(
"updated mcp command in {} → {}",
mcp_file.display(),
ctx.cmd.shell()
));
}
Ok(())
}
fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<bool, CliError> {
if !mcp_file.exists() {
return Ok(false);
}
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
let mut root: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
let removed = root["mcpServers"]
.as_object_mut()
.is_some_and(|servers| servers.remove(server).is_some());
if !removed {
return Ok(false);
}
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
fs::write(mcp_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
Ok(true)
}
fn manifest_path(
project_root: &Path,
home: &Path,
scope: Scope,
platforms: &[Platform],
) -> PathBuf {
if platforms == [Platform::Codex] {
return home.join(".mushroomdb").join("install-manifest-codex.json");
}
if scope == Scope::User {
return home.join(".mushroomdb").join("install-manifest.json");
}
if platforms.contains(&Platform::ClaudeCode) {
project_root
.join(".claude")
.join("skills")
.join("mushroom")
.join(".install-manifest.json")
} else {
project_root.join(".cursor").join(".install-manifest.json")
}
}
fn load_manifest(path: &Path) -> Manifest {
let raw = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return Manifest::default(),
};
serde_json::from_str::<Manifest>(&raw)
.unwrap_or_default()
.sanitised()
}
fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
for f in &this_run.files {
if !existing.files.contains(f) {
existing.files.push(f.clone());
}
}
for k in &this_run.mcp_keys {
let already = existing
.mcp_keys
.iter()
.any(|e| e.file == k.file && e.server == k.server);
if !already {
existing.mcp_keys.push(k.clone());
}
}
for h in &this_run.hooks {
if !existing.hooks.contains(h) {
existing.hooks.push(h.clone());
}
}
for h in &this_run.git_hooks {
if !existing.git_hooks.contains(h) {
existing.git_hooks.push(h.clone());
}
}
for l in &this_run.gitignore {
if !existing.gitignore.contains(l) {
existing.gitignore.push(l.clone());
}
}
existing.codex |= this_run.codex;
existing
}
fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
let parent = path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent).map_err(|e| {
CliError(format!(
"cannot create manifest dir {}: {e}",
parent.display()
))
})?;
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
fs::write(path, json)
.map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
Ok(())
}
pub const HOOK_BEGIN: &str = "# >>> mushroomdb >>>";
pub const HOOK_END: &str = "# <<< mushroomdb <<<";
const HOOK_SHEBANG: &str = "#!/bin/sh";
#[must_use]
pub fn git_hook_block(shell: &str, db: &str) -> String {
format!(
"{HOOK_BEGIN}\n( {shell} sync {} >/dev/null 2>&1 & )\n{HOOK_END}\n",
sh_quote(db)
)
}
enum Stripped {
Absent,
Removed(String),
Unterminated,
}
fn strip_hook_block(text: &str) -> Stripped {
let mut kept: Vec<&str> = Vec::new();
let mut inside = false;
let mut found = false;
for line in text.lines() {
if !inside && line.trim_end() == HOOK_BEGIN {
inside = true;
found = true;
continue;
}
if inside {
if line.trim_end() == HOOK_END {
inside = false;
}
continue;
}
kept.push(line);
}
if !found {
return Stripped::Absent;
}
if inside {
return Stripped::Unterminated;
}
while kept.last().is_some_and(|l| l.trim().is_empty()) {
kept.pop();
}
let mut out = kept.join("\n");
if !out.is_empty() {
out.push('\n');
}
Stripped::Removed(out)
}
fn unterminated(hook_file: &Path) -> CliError {
CliError(format!(
"{}: a mushroomdb block opens with `{HOOK_BEGIN}` but never closes \
— refusing to edit it; delete the block by hand and re-run",
hook_file.display()
))
}
fn merged_hook_text(existing: Option<&str>, block: &str) -> Result<String, ()> {
let base = match existing {
None => String::new(),
Some(text) => match strip_hook_block(text) {
Stripped::Absent => text.to_string(),
Stripped::Removed(rest) => rest,
Stripped::Unterminated => return Err(()),
},
};
let mut lines: Vec<&str> = base.lines().collect();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
if lines.is_empty() {
lines.push(HOOK_SHEBANG);
}
let mut out = lines.join("\n");
out.push_str("\n\n");
out.push_str(block);
Ok(out)
}
fn only_a_shebang(text: &str) -> bool {
text.lines()
.filter(|l| !l.trim().is_empty())
.all(|l| l.starts_with("#!"))
}
pub fn merge_git_hook(hook_file: &Path, shell: &str, db: &str) -> Result<bool, CliError> {
let existing = if hook_file.exists() {
Some(
fs::read_to_string(hook_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?,
)
} else {
None
};
let next = merged_hook_text(existing.as_deref(), &git_hook_block(shell, db))
.map_err(|()| unterminated(hook_file))?;
if existing.as_deref() == Some(next.as_str()) {
return Ok(false);
}
let parent = hook_file.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
fs::write(hook_file, &next)
.map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(hook_file, fs::Permissions::from_mode(0o755)).map_err(|e| {
CliError(format!(
"cannot make {} executable: {e}",
hook_file.display()
))
})?;
}
Ok(true)
}
pub fn remove_git_hook(hook_file: &Path) -> Result<bool, CliError> {
if !hook_file.exists() {
return Ok(false);
}
let existing = fs::read_to_string(hook_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?;
let next = match strip_hook_block(&existing) {
Stripped::Absent => return Ok(false),
Stripped::Removed(rest) => rest,
Stripped::Unterminated => return Err(unterminated(hook_file)),
};
if only_a_shebang(&next) {
fs::remove_file(hook_file)
.map_err(|e| CliError(format!("cannot remove {}: {e}", hook_file.display())))?;
return Ok(true);
}
fs::write(hook_file, next)
.map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
Ok(true)
}
fn file_matches(path: &Path, expected: &str) -> bool {
fs::read_to_string(path)
.map(|s| s == expected)
.unwrap_or(false)
}