use anyhow::{Context, Result};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use super::shared::{
self, McpMergeOutcome, McpRemoveOutcome, build_mcp_entry, merge_mcp_entry, remove_mcp_entry,
};
use super::templates::{self, cursor_hook_specs};
use super::{
ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
};
const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
const HOOK_TIMEOUT_SESSION_END_MS: u64 = 10000;
pub struct CursorInstaller {
home_override: Option<PathBuf>,
}
impl CursorInstaller {
pub fn new() -> Self {
Self {
home_override: None,
}
}
#[doc(hidden)]
pub fn with_home_root(root: PathBuf) -> Self {
Self {
home_override: Some(root),
}
}
fn home(&self) -> Result<PathBuf> {
match &self.home_override {
Some(p) => Ok(p.clone()),
None => shared::home_dir(),
}
}
fn cursor_dir(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".cursor"))
}
fn mcp_json_path(&self) -> Result<PathBuf> {
Ok(self.cursor_dir()?.join("mcp.json"))
}
fn settings_json_path(&self) -> Result<PathBuf> {
Ok(self.cursor_dir()?.join("settings.json"))
}
fn hooks_dir(&self) -> Result<PathBuf> {
Ok(self.cursor_dir()?.join("hooks"))
}
fn default_binary_path(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
}
fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
match &ctx.binary_path {
Some(p) => Ok(p.clone()),
None => self.default_binary_path(),
}
}
fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
if !ctx.config_path.is_absolute() {
anyhow::bail!(
"config path must be absolute, got: {}",
ctx.config_path.display()
);
}
if !binary_path.is_absolute() {
anyhow::bail!(
"binary path must be absolute, got: {}",
binary_path.display()
);
}
Ok(())
}
}
impl Default for CursorInstaller {
fn default() -> Self {
Self::new()
}
}
impl Installer for CursorInstaller {
fn id(&self) -> ClientId {
ClientId::Cursor
}
fn detect(&self) -> Result<bool> {
let cursor_dir = self.cursor_dir()?;
Ok(cursor_dir.exists())
}
fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
let binary_path = self.resolve_binary_path(ctx)?;
self.validate_inputs(ctx, &binary_path)?;
let mut planned_writes: Vec<PathBuf> = Vec::new();
let mut backups: Vec<PathBuf> = Vec::new();
let mut notes: Vec<String> = Vec::new();
let mcp_json = self.mcp_json_path()?;
let mut mcp_doc = shared::read_json_or_empty(&mcp_json)?;
let desired = build_mcp_entry(&binary_path, &ctx.config_path);
let mcp_outcome = merge_mcp_entry(&mut mcp_doc, "spool", desired, ctx.force);
if !binary_path.exists() {
notes.push(format!(
"spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
binary_path.display()
));
}
let mcp_status = match mcp_outcome {
McpMergeOutcome::Inserted => MergeStatus::Changed,
McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
McpMergeOutcome::Conflict {
force_applied: true,
} => {
notes.push(format!(
"Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
mcp_json.display()
));
MergeStatus::Changed
}
McpMergeOutcome::Conflict {
force_applied: false,
} => {
notes.push(
"Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first.".to_string(),
);
MergeStatus::Conflict
}
};
if matches!(mcp_status, MergeStatus::Conflict) {
return Ok(InstallReport {
client: ClientId::Cursor.as_str().to_string(),
binary_path,
config_path: ctx.config_path.clone(),
status: InstallStatus::Conflict,
planned_writes,
backups,
notes,
});
}
let settings_json = self.settings_json_path()?;
let mut settings_doc = shared::read_json_or_empty(&settings_json)?;
let hooks_dir = self.hooks_dir()?;
let hook_specs = cursor_hook_specs();
let mut settings_changed = false;
for spec in &hook_specs {
let target_path = hooks_dir.join(spec.file_name);
let target_str = target_path.to_string_lossy().into_owned();
let timeout = timeout_for_event(spec.hook_event);
match upsert_cursor_hook_entry(&mut settings_doc, spec.hook_event, &target_str, timeout)
{
CursorHookOutcome::Appended => settings_changed = true,
CursorHookOutcome::Unchanged => {}
}
}
let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
let hook_files: Vec<HookFilePlan> = hook_specs
.iter()
.map(|spec| HookFilePlan {
target_path: hooks_dir.join(spec.file_name),
rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
})
.collect();
let hook_files_changed = hook_files
.iter()
.any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));
let any_change =
matches!(mcp_status, MergeStatus::Changed) || settings_changed || hook_files_changed;
if ctx.dry_run {
if matches!(mcp_status, MergeStatus::Changed) {
planned_writes.push(mcp_json.clone());
}
if settings_changed {
planned_writes.push(settings_json.clone());
}
for plan in &hook_files {
if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
planned_writes.push(plan.target_path.clone());
}
}
return Ok(InstallReport {
client: ClientId::Cursor.as_str().to_string(),
binary_path,
config_path: ctx.config_path.clone(),
status: if any_change {
InstallStatus::DryRun
} else {
InstallStatus::Unchanged
},
planned_writes,
backups,
notes,
});
}
if matches!(mcp_status, MergeStatus::Changed) {
if let Some(b) = shared::backup_file(&mcp_json)
.with_context(|| format!("backing up {}", mcp_json.display()))?
{
backups.push(b);
}
shared::write_json_atomic(&mcp_json, &mcp_doc)
.with_context(|| format!("writing {}", mcp_json.display()))?;
planned_writes.push(mcp_json.clone());
}
if settings_changed {
if let Some(b) = shared::backup_file(&settings_json)
.with_context(|| format!("backing up {}", settings_json.display()))?
{
backups.push(b);
}
shared::write_json_atomic(&settings_json, &settings_doc)
.with_context(|| format!("writing {}", settings_json.display()))?;
planned_writes.push(settings_json.clone());
}
if !hooks_dir.exists() {
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("creating {}", hooks_dir.display()))?;
}
for plan in &hook_files {
if file_has_exact_contents(&plan.target_path, &plan.rendered) {
continue;
}
std::fs::write(&plan.target_path, &plan.rendered)
.with_context(|| format!("writing {}", plan.target_path.display()))?;
set_executable(&plan.target_path)?;
planned_writes.push(plan.target_path.clone());
}
let final_status = if any_change {
InstallStatus::Installed
} else {
InstallStatus::Unchanged
};
Ok(InstallReport {
client: ClientId::Cursor.as_str().to_string(),
binary_path,
config_path: ctx.config_path.clone(),
status: final_status,
planned_writes,
backups,
notes,
})
}
fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
let report = self.install(ctx)?;
let status = match report.status {
InstallStatus::Installed => UpdateStatus::Updated,
InstallStatus::Unchanged => UpdateStatus::Unchanged,
InstallStatus::DryRun => UpdateStatus::DryRun,
InstallStatus::Conflict => UpdateStatus::NotInstalled,
};
Ok(UpdateReport {
client: report.client,
status,
updated_paths: report.planned_writes,
notes: report.notes,
})
}
fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
let mcp_json = self.mcp_json_path()?;
let settings_json = self.settings_json_path()?;
let hooks_dir = self.hooks_dir()?;
let mut notes: Vec<String> = Vec::new();
let mut removed_paths: Vec<PathBuf> = Vec::new();
let mut backups: Vec<PathBuf> = Vec::new();
let mut any_change = false;
let mcp_doc_after_purge = if mcp_json.exists() {
let mut doc = shared::read_json_or_empty(&mcp_json)?;
match remove_mcp_entry(&mut doc, "spool") {
McpRemoveOutcome::Removed => {
any_change = true;
Some(doc)
}
McpRemoveOutcome::NotPresent => None,
}
} else {
None
};
let settings_doc_after_purge = if settings_json.exists() {
let mut doc = shared::read_json_or_empty(&settings_json)?;
let removed = purge_cursor_hook_entries(&mut doc, "spool-");
if removed > 0 {
any_change = true;
Some(doc)
} else {
None
}
} else {
None
};
let hook_files: Vec<PathBuf> = cursor_hook_specs()
.iter()
.map(|s| hooks_dir.join(s.file_name))
.filter(|p| p.exists())
.collect();
if !hook_files.is_empty() {
any_change = true;
}
if !any_change {
notes.push("nothing to uninstall — no spool artifacts found.".to_string());
return Ok(UninstallReport {
client: ClientId::Cursor.as_str().to_string(),
status: UninstallStatus::NotInstalled,
removed_paths,
backups,
notes,
});
}
if ctx.dry_run {
if mcp_doc_after_purge.is_some() {
removed_paths.push(mcp_json);
}
if settings_doc_after_purge.is_some() {
removed_paths.push(settings_json);
}
removed_paths.extend(hook_files);
return Ok(UninstallReport {
client: ClientId::Cursor.as_str().to_string(),
status: UninstallStatus::DryRun,
removed_paths,
backups,
notes,
});
}
if let Some(doc) = mcp_doc_after_purge {
if let Some(b) = shared::backup_file(&mcp_json)? {
backups.push(b);
}
shared::write_json_atomic(&mcp_json, &doc)?;
removed_paths.push(mcp_json);
}
if let Some(doc) = settings_doc_after_purge {
if let Some(b) = shared::backup_file(&settings_json)? {
backups.push(b);
}
shared::write_json_atomic(&settings_json, &doc)?;
removed_paths.push(settings_json);
}
for p in hook_files {
std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
removed_paths.push(p);
}
Ok(UninstallReport {
client: ClientId::Cursor.as_str().to_string(),
status: UninstallStatus::Removed,
removed_paths,
backups,
notes,
})
}
fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
let mut checks = Vec::new();
let cursor_dir = self.cursor_dir()?;
checks.push(DiagnosticCheck {
name: "cursor_dir_exists".into(),
status: if cursor_dir.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
},
detail: format!("{}", cursor_dir.display()),
});
let mcp_json = self.mcp_json_path()?;
let registration_status = if mcp_json.exists() {
let doc = shared::read_json_or_empty(&mcp_json)?;
if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
}
} else {
DiagnosticStatus::NotApplicable
};
checks.push(DiagnosticCheck {
name: "mcp_servers_spool_registered".into(),
status: registration_status,
detail: "mcpServers.spool entry presence".into(),
});
let binary_path = self.resolve_binary_path(ctx)?;
checks.push(DiagnosticCheck {
name: "spool_mcp_binary".into(),
status: if binary_path.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Fail
},
detail: format!("{}", binary_path.display()),
});
checks.push(DiagnosticCheck {
name: "spool_config_readable".into(),
status: if ctx.config_path.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Fail
},
detail: format!("{}", ctx.config_path.display()),
});
let settings_json = self.settings_json_path()?;
let hooks_registered_status = if settings_json.exists() {
let doc = shared::read_json_or_empty(&settings_json)?;
if has_any_spool_cursor_hook(&doc) {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
}
} else {
DiagnosticStatus::Warn
};
checks.push(DiagnosticCheck {
name: "cursor_hooks_registered".into(),
status: hooks_registered_status,
detail: format!("{}", settings_json.display()),
});
let hooks_dir = self.hooks_dir()?;
let mut missing: Vec<String> = Vec::new();
for spec in cursor_hook_specs() {
let p = hooks_dir.join(spec.file_name);
if !p.exists() {
missing.push(spec.file_name.to_string());
}
}
let hook_files_detail = if missing.is_empty() {
format!("{} (2/2 present)", hooks_dir.display())
} else {
format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
};
checks.push(DiagnosticCheck {
name: "spool_hook_scripts".into(),
status: if missing.is_empty() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
},
detail: hook_files_detail,
});
Ok(DiagnosticReport {
client: ClientId::Cursor.as_str().to_string(),
checks,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeStatus {
Changed,
Unchanged,
Conflict,
}
struct HookFilePlan {
target_path: PathBuf,
rendered: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CursorHookOutcome {
Appended,
Unchanged,
}
fn timeout_for_event(event: &str) -> u64 {
match event {
"onSessionEnd" => HOOK_TIMEOUT_SESSION_END_MS,
_ => HOOK_TIMEOUT_DEFAULT_MS,
}
}
fn upsert_cursor_hook_entry(
doc: &mut Value,
event: &str,
command_path: &str,
timeout_ms: u64,
) -> CursorHookOutcome {
let root = match doc.as_object_mut() {
Some(obj) => obj,
None => {
*doc = json!({});
doc.as_object_mut().expect("just inserted")
}
};
let cursor_hooks = root.entry("cursor.hooks").or_insert_with(|| json!({}));
if !cursor_hooks.is_object() {
*cursor_hooks = json!({});
}
let hooks_obj = cursor_hooks
.as_object_mut()
.expect("cursor.hooks must be object");
let entries = hooks_obj
.entry(event)
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
let array = entries.as_array_mut().expect("entries must be array");
for entry in array.iter() {
if entry.get("command").and_then(Value::as_str) == Some(command_path) {
return CursorHookOutcome::Unchanged;
}
}
array.push(json!({
"command": command_path,
"timeout": timeout_ms,
}));
CursorHookOutcome::Appended
}
fn purge_cursor_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
let mut removed = 0usize;
let Some(root) = doc.as_object_mut() else {
return 0;
};
let Some(cursor_hooks) = root.get_mut("cursor.hooks").and_then(|v| v.as_object_mut()) else {
return 0;
};
for (_event, entries) in cursor_hooks.iter_mut() {
let Some(array) = entries.as_array_mut() else {
continue;
};
let before = array.len();
array.retain(|entry| {
!entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|c| c.contains(marker_substring))
});
removed += before - array.len();
}
cursor_hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
if cursor_hooks.is_empty() {
root.remove("cursor.hooks");
}
removed
}
fn has_any_spool_cursor_hook(doc: &Value) -> bool {
let Some(cursor_hooks) = doc.get("cursor.hooks").and_then(|v| v.as_object()) else {
return false;
};
for entries in cursor_hooks.values() {
let Some(arr) = entries.as_array() else {
continue;
};
for entry in arr {
if entry
.get("command")
.and_then(Value::as_str)
.is_some_and(|c| c.contains("spool-"))
{
return true;
}
}
}
false
}
fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
if !path.exists() {
return false;
}
match std::fs::read_to_string(path) {
Ok(actual) => actual == expected,
Err(_) => false,
}
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn setup() -> (tempfile::TempDir, CursorInstaller, InstallContext) {
let temp = tempdir().unwrap();
let home = temp.path().to_path_buf();
let installer = CursorInstaller::with_home_root(home.clone());
let config_path = home.join("spool.toml");
fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
let binary_path = home.join("fake-spool-mcp");
fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
let ctx = InstallContext {
binary_path: Some(binary_path),
config_path,
dry_run: false,
force: false,
};
(temp, installer, ctx)
}
#[test]
fn detect_returns_false_when_no_cursor_dir() {
let temp = tempdir().unwrap();
let installer = CursorInstaller::with_home_root(temp.path().to_path_buf());
assert!(!installer.detect().unwrap());
}
#[test]
fn detect_returns_true_when_cursor_dir_present() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join(".cursor")).unwrap();
let installer = CursorInstaller::with_home_root(temp.path().to_path_buf());
assert!(installer.detect().unwrap());
}
#[test]
fn install_creates_mcp_json_and_hooks() {
let (temp, installer, ctx) = setup();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Installed);
let mcp_json = temp.path().join(".cursor").join("mcp.json");
assert!(mcp_json.exists());
let doc: Value = serde_json::from_str(&fs::read_to_string(&mcp_json).unwrap()).unwrap();
assert!(doc["mcpServers"]["spool"].is_object());
let settings_json = temp.path().join(".cursor").join("settings.json");
assert!(settings_json.exists());
let settings_doc: Value =
serde_json::from_str(&fs::read_to_string(&settings_json).unwrap()).unwrap();
assert!(settings_doc["cursor.hooks"]["onSessionStart"].is_array());
assert!(settings_doc["cursor.hooks"]["onSessionEnd"].is_array());
let end_entry = &settings_doc["cursor.hooks"]["onSessionEnd"][0];
assert_eq!(end_entry["timeout"], HOOK_TIMEOUT_SESSION_END_MS);
for spec in cursor_hook_specs() {
let p = temp
.path()
.join(".cursor")
.join("hooks")
.join(spec.file_name);
assert!(p.exists(), "{} missing", p.display());
}
}
#[test]
fn install_dry_run_does_not_write() {
let (temp, installer, mut ctx) = setup();
ctx.dry_run = true;
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::DryRun);
assert!(!report.planned_writes.is_empty());
assert!(!temp.path().join(".cursor").exists());
}
#[test]
fn install_unchanged_on_repeat() {
let (_temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let second = installer.install(&ctx).unwrap();
assert_eq!(second.status, InstallStatus::Unchanged);
}
#[test]
fn uninstall_removes_entries() {
let (temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let report = installer.uninstall(&ctx).unwrap();
assert_eq!(report.status, UninstallStatus::Removed);
for spec in cursor_hook_specs() {
let p = temp
.path()
.join(".cursor")
.join("hooks")
.join(spec.file_name);
assert!(!p.exists(), "{} should be removed", p.display());
}
let mcp_json = temp.path().join(".cursor").join("mcp.json");
let doc: Value = serde_json::from_str(&fs::read_to_string(&mcp_json).unwrap()).unwrap();
assert!(doc["mcpServers"].get("spool").is_none());
}
#[test]
fn uninstall_not_installed_when_clean() {
let (_temp, installer, ctx) = setup();
let report = installer.uninstall(&ctx).unwrap();
assert_eq!(report.status, UninstallStatus::NotInstalled);
}
#[test]
fn update_unchanged_when_templates_match() {
let (_temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let report = installer.update(&ctx).unwrap();
assert_eq!(report.status, UpdateStatus::Unchanged);
}
#[test]
fn diagnose_reports_full_check_set_after_install() {
let (_temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let report = installer.diagnose(&ctx).unwrap();
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
for expected in [
"cursor_dir_exists",
"mcp_servers_spool_registered",
"spool_mcp_binary",
"spool_config_readable",
"cursor_hooks_registered",
"spool_hook_scripts",
] {
assert!(names.contains(&expected), "missing check {}", expected);
}
}
#[cfg(unix)]
#[test]
fn install_makes_hook_scripts_executable() {
use std::os::unix::fs::PermissionsExt;
let (temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let session = temp
.path()
.join(".cursor")
.join("hooks")
.join("spool-on_session_start.sh");
let perms = fs::metadata(&session).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o755);
}
#[test]
fn cursor_hook_entries_use_flat_format_with_timeout() {
let mut doc = json!({});
upsert_cursor_hook_entry(
&mut doc,
"onSessionStart",
"/abs/spool-on_session_start.sh",
5000,
);
let entries = doc["cursor.hooks"]["onSessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["command"], "/abs/spool-on_session_start.sh");
assert_eq!(entries[0]["timeout"], 5000);
}
#[test]
fn upsert_cursor_hook_unchanged_on_repeat() {
let mut doc = json!({});
upsert_cursor_hook_entry(&mut doc, "onSessionStart", "/abs/hook.sh", 5000);
let outcome = upsert_cursor_hook_entry(&mut doc, "onSessionStart", "/abs/hook.sh", 5000);
assert_eq!(outcome, CursorHookOutcome::Unchanged);
assert_eq!(
doc["cursor.hooks"]["onSessionStart"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn purge_cursor_hook_entries_removes_spool_only() {
let mut doc = json!({
"cursor.hooks": {
"onSessionStart": [
{"command": "/other/tool", "timeout": 3000},
{"command": "/abs/.cursor/hooks/spool-on_session_start.sh", "timeout": 5000}
],
"onSessionEnd": [
{"command": "/abs/spool-on_session_end.sh", "timeout": 10000}
]
}
});
let removed = purge_cursor_hook_entries(&mut doc, "spool-");
assert_eq!(removed, 2);
let entries = doc["cursor.hooks"]["onSessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["command"], "/other/tool");
assert!(doc["cursor.hooks"].get("onSessionEnd").is_none());
}
}