use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use super::shared::{
self, McpMergeOutcome, McpRemoveOutcome, SettingsHookOutcome, build_mcp_entry, merge_mcp_entry,
purge_settings_hook_entries, remove_mcp_entry, upsert_settings_hook_command,
};
use super::templates::{
self, CommandSpec, HookSpec, SkillSpec, claude_command_specs, claude_hook_specs,
claude_skill_specs,
};
use super::{
ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
};
pub struct ClaudeInstaller {
home_override: Option<PathBuf>,
}
impl ClaudeInstaller {
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 claude_config_path(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".claude.json"))
}
fn settings_path(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".claude").join("settings.json"))
}
fn hooks_dir(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".claude").join("hooks"))
}
fn commands_dir(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".claude").join("commands"))
}
fn skills_dir(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".claude").join("skills"))
}
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 ClaudeInstaller {
fn default() -> Self {
Self::new()
}
}
impl Installer for ClaudeInstaller {
fn id(&self) -> ClientId {
ClientId::Claude
}
fn detect(&self) -> Result<bool> {
let claude_dir = self.home()?.join(".claude");
Ok(claude_dir.exists() || self.claude_config_path()?.exists())
}
fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
let binary_path = self.resolve_binary_path(ctx)?;
self.validate_inputs(ctx, &binary_path)?;
shared::ensure_config_exists(&ctx.config_path)?;
let mut planned_writes: Vec<PathBuf> = Vec::new();
let mut backups: Vec<PathBuf> = Vec::new();
let mut notes: Vec<String> = Vec::new();
let claude_config_path = self.claude_config_path()?;
let mut claude_doc = shared::read_json_or_empty(&claude_config_path)?;
let desired = build_mcp_entry(&binary_path, &ctx.config_path);
let mcp_outcome = merge_mcp_entry(&mut claude_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 {}.",
claude_config_path.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::Claude.as_str().to_string(),
binary_path,
config_path: ctx.config_path.clone(),
status: InstallStatus::Conflict,
planned_writes,
backups,
notes,
});
}
let settings_path = self.settings_path()?;
let mut settings_doc = shared::read_json_or_empty(&settings_path)?;
let hooks_dir = self.hooks_dir()?;
let hook_specs = claude_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();
match upsert_settings_hook_command(&mut settings_doc, spec.hook_event, &target_str) {
SettingsHookOutcome::Appended => settings_changed = true,
SettingsHookOutcome::Unchanged => {}
}
}
let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
let hook_files: Vec<HookFilePlan> = hook_specs
.iter()
.map(|spec| HookFilePlan {
spec,
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 commands_dir = self.commands_dir()?;
let command_specs = claude_command_specs();
let command_files: Vec<CommandFilePlan> = command_specs
.iter()
.map(|spec| CommandFilePlan {
spec,
target_path: commands_dir.join(spec.file_name),
})
.collect();
let commands_changed = command_files
.iter()
.any(|p| !file_has_exact_contents(&p.target_path, p.spec.body));
let skills_dir = self.skills_dir()?;
let skill_specs = claude_skill_specs();
let skill_files: Vec<SkillFilePlan> = skill_specs
.iter()
.map(|spec| SkillFilePlan {
spec,
target_path: skills_dir.join(spec.dir_name).join("SKILL.md"),
})
.collect();
let skills_changed = skill_files
.iter()
.any(|p| !file_has_exact_contents(&p.target_path, p.spec.body));
let any_change = matches!(mcp_status, MergeStatus::Changed)
|| settings_changed
|| hook_files_changed
|| commands_changed
|| skills_changed;
if ctx.dry_run {
if matches!(mcp_status, MergeStatus::Changed) {
planned_writes.push(claude_config_path.clone());
}
if settings_changed {
planned_writes.push(settings_path.clone());
}
for plan in &hook_files {
if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
planned_writes.push(plan.target_path.clone());
}
}
for plan in &command_files {
if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
planned_writes.push(plan.target_path.clone());
}
}
for plan in &skill_files {
if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
planned_writes.push(plan.target_path.clone());
}
}
return Ok(InstallReport {
client: ClientId::Claude.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(&claude_config_path)
.with_context(|| format!("backing up {}", claude_config_path.display()))?
{
backups.push(b);
}
shared::write_json_atomic(&claude_config_path, &claude_doc)
.with_context(|| format!("writing {}", claude_config_path.display()))?;
planned_writes.push(claude_config_path.clone());
}
if settings_changed {
if let Some(b) = shared::backup_file(&settings_path)
.with_context(|| format!("backing up {}", settings_path.display()))?
{
backups.push(b);
}
shared::write_json_atomic(&settings_path, &settings_doc)
.with_context(|| format!("writing {}", settings_path.display()))?;
planned_writes.push(settings_path.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());
}
if !commands_dir.exists() {
std::fs::create_dir_all(&commands_dir)
.with_context(|| format!("creating {}", commands_dir.display()))?;
}
for plan in &command_files {
if file_has_exact_contents(&plan.target_path, plan.spec.body) {
continue;
}
std::fs::write(&plan.target_path, plan.spec.body)
.with_context(|| format!("writing {}", plan.target_path.display()))?;
planned_writes.push(plan.target_path.clone());
}
for plan in &skill_files {
if file_has_exact_contents(&plan.target_path, plan.spec.body) {
continue;
}
if let Some(parent) = plan.target_path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&plan.target_path, plan.spec.body)
.with_context(|| format!("writing {}", plan.target_path.display()))?;
planned_writes.push(plan.target_path.clone());
}
let final_status = if any_change {
InstallStatus::Installed
} else {
InstallStatus::Unchanged
};
Ok(InstallReport {
client: ClientId::Claude.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 mut updated_paths: Vec<PathBuf> = Vec::new();
let mut notes: Vec<String> = Vec::new();
let claude_config_path = self.claude_config_path()?;
if !claude_config_path.exists() {
return Ok(UpdateReport {
client: ClientId::Claude.as_str().to_string(),
status: UpdateStatus::NotInstalled,
updated_paths,
notes: vec!["spool is not installed (no ~/.claude.json found). Run `spool mcp install` first.".to_string()],
});
}
let claude_doc = shared::read_json_or_empty(&claude_config_path)?;
let mcp_entry = claude_doc.get("mcpServers").and_then(|v| v.get("spool"));
if mcp_entry.is_none() {
return Ok(UpdateReport {
client: ClientId::Claude.as_str().to_string(),
status: UpdateStatus::NotInstalled,
updated_paths,
notes: vec![
"spool is not registered in mcpServers. Run `spool mcp install` first."
.to_string(),
],
});
}
let binary_path = match &ctx.binary_path {
Some(p) => p.clone(),
None => {
let existing_bin = mcp_entry
.and_then(|e| e.get("command"))
.and_then(|c| c.as_str())
.map(PathBuf::from);
match existing_bin {
Some(p) => p,
None => self.default_binary_path()?,
}
}
};
let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
let hooks_dir = self.hooks_dir()?;
let hook_specs = claude_hook_specs();
let hook_files: Vec<HookFilePlan> = hook_specs
.iter()
.map(|spec| HookFilePlan {
spec,
target_path: hooks_dir.join(spec.file_name),
rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
})
.collect();
let commands_dir = self.commands_dir()?;
let command_specs = claude_command_specs();
let command_files: Vec<CommandFilePlan> = command_specs
.iter()
.map(|spec| CommandFilePlan {
spec,
target_path: commands_dir.join(spec.file_name),
})
.collect();
let skills_dir = self.skills_dir()?;
let skill_specs = claude_skill_specs();
let skill_files: Vec<SkillFilePlan> = skill_specs
.iter()
.map(|spec| SkillFilePlan {
spec,
target_path: skills_dir.join(spec.dir_name).join("SKILL.md"),
})
.collect();
let mut diffs: Vec<PathBuf> = Vec::new();
for plan in &hook_files {
if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
diffs.push(plan.target_path.clone());
}
}
for plan in &command_files {
if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
diffs.push(plan.target_path.clone());
}
}
for plan in &skill_files {
if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
diffs.push(plan.target_path.clone());
}
}
if diffs.is_empty() {
return Ok(UpdateReport {
client: ClientId::Claude.as_str().to_string(),
status: UpdateStatus::Unchanged,
updated_paths,
notes,
});
}
if ctx.dry_run {
return Ok(UpdateReport {
client: ClientId::Claude.as_str().to_string(),
status: UpdateStatus::DryRun,
updated_paths: diffs,
notes,
});
}
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)?;
updated_paths.push(plan.target_path.clone());
}
if !commands_dir.exists() {
std::fs::create_dir_all(&commands_dir)
.with_context(|| format!("creating {}", commands_dir.display()))?;
}
for plan in &command_files {
if file_has_exact_contents(&plan.target_path, plan.spec.body) {
continue;
}
std::fs::write(&plan.target_path, plan.spec.body)
.with_context(|| format!("writing {}", plan.target_path.display()))?;
updated_paths.push(plan.target_path.clone());
}
for plan in &skill_files {
if file_has_exact_contents(&plan.target_path, plan.spec.body) {
continue;
}
if let Some(parent) = plan.target_path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&plan.target_path, plan.spec.body)
.with_context(|| format!("writing {}", plan.target_path.display()))?;
updated_paths.push(plan.target_path.clone());
}
if !updated_paths.is_empty() {
notes.push(format!(
"{} file(s) updated to latest templates.",
updated_paths.len()
));
}
Ok(UpdateReport {
client: ClientId::Claude.as_str().to_string(),
status: UpdateStatus::Updated,
updated_paths,
notes,
})
}
fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
let claude_config_path = self.claude_config_path()?;
let settings_path = self.settings_path()?;
let hooks_dir = self.hooks_dir()?;
let commands_dir = self.commands_dir()?;
let skills_dir = self.skills_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 claude_doc_after_purge = if claude_config_path.exists() {
let mut doc = shared::read_json_or_empty(&claude_config_path)?;
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_path.exists() {
let mut doc = shared::read_json_or_empty(&settings_path)?;
let removed = purge_settings_hook_entries(&mut doc, "spool-");
if removed > 0 {
any_change = true;
Some(doc)
} else {
None
}
} else {
None
};
let hook_files: Vec<PathBuf> = claude_hook_specs()
.iter()
.map(|s| hooks_dir.join(s.file_name))
.filter(|p| p.exists())
.collect();
if !hook_files.is_empty() {
any_change = true;
}
let command_files: Vec<PathBuf> = claude_command_specs()
.iter()
.map(|s| commands_dir.join(s.file_name))
.filter(|p| p.exists())
.collect();
if !command_files.is_empty() {
any_change = true;
}
let skill_dirs: Vec<PathBuf> = claude_skill_specs()
.iter()
.map(|s| skills_dir.join(s.dir_name))
.filter(|p| p.exists())
.collect();
if !skill_dirs.is_empty() {
any_change = true;
}
if !any_change {
notes.push("nothing to uninstall — no spool artifacts found.".to_string());
return Ok(UninstallReport {
client: ClientId::Claude.as_str().to_string(),
status: UninstallStatus::NotInstalled,
removed_paths,
backups,
notes,
});
}
if ctx.dry_run {
if claude_doc_after_purge.is_some() {
removed_paths.push(claude_config_path);
}
if settings_doc_after_purge.is_some() {
removed_paths.push(settings_path);
}
removed_paths.extend(hook_files);
removed_paths.extend(command_files);
removed_paths.extend(skill_dirs);
return Ok(UninstallReport {
client: ClientId::Claude.as_str().to_string(),
status: UninstallStatus::DryRun,
removed_paths,
backups,
notes,
});
}
if let Some(doc) = claude_doc_after_purge {
if let Some(b) = shared::backup_file(&claude_config_path)? {
backups.push(b);
}
shared::write_json_atomic(&claude_config_path, &doc)?;
removed_paths.push(claude_config_path);
}
if let Some(doc) = settings_doc_after_purge {
if let Some(b) = shared::backup_file(&settings_path)? {
backups.push(b);
}
shared::write_json_atomic(&settings_path, &doc)?;
removed_paths.push(settings_path);
}
for p in hook_files {
std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
removed_paths.push(p);
}
for p in command_files {
std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
removed_paths.push(p);
}
for p in skill_dirs {
std::fs::remove_dir_all(&p).with_context(|| format!("removing {}", p.display()))?;
removed_paths.push(p);
}
Ok(UninstallReport {
client: ClientId::Claude.as_str().to_string(),
status: UninstallStatus::Removed,
removed_paths,
backups,
notes,
})
}
fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
let mut checks = Vec::new();
let config_doc_path = self.claude_config_path()?;
let config_status = if config_doc_path.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
};
checks.push(DiagnosticCheck {
name: "claude_config_exists".into(),
status: config_status,
detail: format!("{}", config_doc_path.display()),
});
let registration_status = if config_doc_path.exists() {
let doc = shared::read_json_or_empty(&config_doc_path)?;
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)?;
let binary_status = if binary_path.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Fail
};
checks.push(DiagnosticCheck {
name: "spool_mcp_binary".into(),
status: binary_status,
detail: format!("{}", binary_path.display()),
});
let toml_status = if ctx.config_path.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Fail
};
checks.push(DiagnosticCheck {
name: "spool_config_readable".into(),
status: toml_status,
detail: format!("{}", ctx.config_path.display()),
});
let settings_path = self.settings_path()?;
let hooks_registered_status = if settings_path.exists() {
let doc = shared::read_json_or_empty(&settings_path)?;
if has_any_spool_hook_entry(&doc) {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
}
} else {
DiagnosticStatus::Warn
};
checks.push(DiagnosticCheck {
name: "claude_settings_hooks_registered".into(),
status: hooks_registered_status,
detail: format!("{}", settings_path.display()),
});
let hooks_dir = self.hooks_dir()?;
let mut missing: Vec<String> = Vec::new();
for spec in claude_hook_specs() {
let p = hooks_dir.join(spec.file_name);
if !p.exists() {
missing.push(spec.file_name.to_string());
}
}
let hook_files_status = if missing.is_empty() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
};
let hook_files_detail = if missing.is_empty() {
format!("{} (5/5 present)", hooks_dir.display())
} else {
format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
};
checks.push(DiagnosticCheck {
name: "spool_hook_scripts".into(),
status: hook_files_status,
detail: hook_files_detail,
});
let skills_dir = self.skills_dir()?;
let skill_present = claude_skill_specs()
.iter()
.all(|s| skills_dir.join(s.dir_name).join("SKILL.md").exists());
checks.push(DiagnosticCheck {
name: "spool_skill_present".into(),
status: if skill_present {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
},
detail: format!("{}", skills_dir.display()),
});
Ok(DiagnosticReport {
client: ClientId::Claude.as_str().to_string(),
checks,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeStatus {
Changed,
Unchanged,
Conflict,
}
struct HookFilePlan<'a> {
#[allow(dead_code)] spec: &'a HookSpec,
target_path: PathBuf,
rendered: String,
}
struct CommandFilePlan<'a> {
spec: &'a CommandSpec,
target_path: PathBuf,
}
struct SkillFilePlan<'a> {
spec: &'a SkillSpec,
target_path: PathBuf,
}
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,
}
}
fn has_any_spool_hook_entry(doc: &serde_json::Value) -> bool {
let Some(hooks) = doc.get("hooks").and_then(|v| v.as_object()) else {
return false;
};
for entries in hooks.values() {
let Some(arr) = entries.as_array() else {
continue;
};
for entry in arr {
let Some(inner) = entry.get("hooks").and_then(|v| v.as_array()) else {
continue;
};
for h in inner {
if let Some(cmd) = h.get("command").and_then(|c| c.as_str())
&& cmd.contains("spool-")
{
return true;
}
}
}
}
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 serde_json::json;
use std::fs;
use tempfile::tempdir;
fn setup() -> (tempfile::TempDir, ClaudeInstaller, InstallContext) {
let temp = tempdir().unwrap();
let home = temp.path().to_path_buf();
let installer = ClaudeInstaller::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_claude_dir() {
let temp = tempdir().unwrap();
let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
assert!(!installer.detect().unwrap());
}
#[test]
fn detect_returns_true_when_claude_dir_present() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join(".claude")).unwrap();
let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
assert!(installer.detect().unwrap());
}
#[test]
fn install_writes_full_payload_on_first_run() {
let (temp, installer, ctx) = setup();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Installed);
assert!(temp.path().join(".claude.json").exists());
assert!(temp.path().join(".claude").join("settings.json").exists());
for spec in claude_hook_specs() {
let p = temp
.path()
.join(".claude")
.join("hooks")
.join(spec.file_name);
assert!(p.exists(), "{} missing", p.display());
}
for spec in claude_command_specs() {
let p = temp
.path()
.join(".claude")
.join("commands")
.join(spec.file_name);
assert!(p.exists(), "{} missing", p.display());
}
let skill_path = temp
.path()
.join(".claude")
.join("skills")
.join("spool-runtime")
.join("SKILL.md");
assert!(skill_path.exists());
let claude: serde_json::Value =
serde_json::from_str(&fs::read_to_string(temp.path().join(".claude.json")).unwrap())
.unwrap();
assert!(claude["mcpServers"]["spool"].is_object());
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
for spec in claude_hook_specs() {
let entries = settings["hooks"][spec.hook_event].as_array().unwrap();
assert!(
entries.iter().any(|e| e["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.contains("spool-"))),
"{} entry missing in settings.json",
spec.hook_event
);
}
}
#[test]
fn install_preserves_existing_session_start_hook() {
let (temp, installer, ctx) = setup();
fs::create_dir_all(temp.path().join(".claude")).unwrap();
let preexisting = json!({
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
]
}
});
fs::write(
temp.path().join(".claude").join("settings.json"),
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Installed);
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
let session_entries = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(session_entries.len(), 2);
assert_eq!(session_entries[0]["hooks"][0]["command"], "bd prime");
let spool_cmd = session_entries[1]["hooks"][0]["command"].as_str().unwrap();
assert!(spool_cmd.contains("spool-SessionStart.sh"));
}
#[test]
fn install_dry_run_does_not_write_anything() {
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(".claude").exists());
assert!(!temp.path().join(".claude.json").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 install_re_renders_hook_when_template_drifts() {
let (temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let stop_path = temp
.path()
.join(".claude")
.join("hooks")
.join("spool-Stop.sh");
fs::write(&stop_path, "tampered\n").unwrap();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Installed);
let restored = fs::read_to_string(&stop_path).unwrap();
assert!(!restored.contains("tampered"));
assert!(restored.contains("hook stop"));
}
#[test]
fn install_marks_conflict_when_existing_mcp_entry_differs() {
let (temp, installer, ctx) = setup();
let claude_json = temp.path().join(".claude.json");
let preexisting = json!({
"mcpServers": {
"spool": {"type": "stdio", "command": "/old/path", "args": []}
}
});
fs::write(
&claude_json,
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Conflict);
assert!(!temp.path().join(".claude").join("hooks").exists());
}
#[test]
fn install_force_overrides_conflict() {
let (temp, installer, mut ctx) = setup();
ctx.force = true;
let claude_json = temp.path().join(".claude.json");
let preexisting = json!({
"mcpServers": {
"spool": {"type": "stdio", "command": "/old/path", "args": []}
}
});
fs::write(
&claude_json,
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Installed);
assert!(temp.path().join(".claude").join("hooks").exists());
}
#[test]
fn install_records_warning_when_binary_missing() {
let temp = tempdir().unwrap();
let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, "x=1").unwrap();
let ctx = InstallContext {
binary_path: Some(temp.path().join("nope")),
config_path,
dry_run: false,
force: false,
};
let report = installer.install(&ctx).unwrap();
assert!(report.notes.iter().any(|n| n.contains("not found")));
}
#[test]
fn install_rejects_relative_binary_path() {
let (_temp, installer, mut ctx) = setup();
ctx.binary_path = Some(PathBuf::from("relative/path"));
let err = installer.install(&ctx).unwrap_err();
assert!(err.to_string().contains("binary path must be absolute"));
}
#[test]
fn install_rejects_relative_config_path() {
let (_temp, installer, mut ctx) = setup();
ctx.config_path = PathBuf::from("relative/spool.toml");
let err = installer.install(&ctx).unwrap_err();
assert!(err.to_string().contains("config path must be absolute"));
}
#[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(".claude")
.join("hooks")
.join("spool-SessionStart.sh");
let perms = fs::metadata(&session).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o755);
}
#[test]
fn uninstall_removes_full_payload() {
let (temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let report = installer.uninstall(&ctx).unwrap();
assert_eq!(report.status, UninstallStatus::Removed);
assert!(
!temp
.path()
.join(".claude")
.join("hooks")
.join("spool-SessionStart.sh")
.exists()
);
assert!(
!temp
.path()
.join(".claude")
.join("commands")
.join("spool-wakeup.md")
.exists()
);
assert!(
!temp
.path()
.join(".claude")
.join("skills")
.join("spool-runtime")
.exists()
);
let claude: serde_json::Value =
serde_json::from_str(&fs::read_to_string(temp.path().join(".claude.json")).unwrap())
.unwrap();
assert!(claude["mcpServers"].get("spool").is_none());
}
#[test]
fn uninstall_preserves_other_hooks() {
let (temp, installer, ctx) = setup();
fs::create_dir_all(temp.path().join(".claude")).unwrap();
let preexisting = json!({
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
]
}
});
fs::write(
temp.path().join(".claude").join("settings.json"),
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
let _ = installer.install(&ctx).unwrap();
let _ = installer.uninstall(&ctx).unwrap();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
let entries = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
}
#[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 uninstall_dry_run_changes_nothing() {
let (temp, installer, mut ctx) = setup();
let _ = installer.install(&ctx).unwrap();
ctx.dry_run = true;
let report = installer.uninstall(&ctx).unwrap();
assert_eq!(report.status, UninstallStatus::DryRun);
assert!(
temp.path()
.join(".claude")
.join("hooks")
.join("spool-SessionStart.sh")
.exists(),
"dry-run must keep file"
);
}
#[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 [
"claude_config_exists",
"mcp_servers_spool_registered",
"spool_mcp_binary",
"spool_config_readable",
"claude_settings_hooks_registered",
"spool_hook_scripts",
"spool_skill_present",
] {
assert!(names.contains(&expected), "missing check {}", expected);
}
let hooks_check = report
.checks
.iter()
.find(|c| c.name == "claude_settings_hooks_registered")
.unwrap();
assert_eq!(hooks_check.status, DiagnosticStatus::Ok);
}
#[test]
fn diagnose_warns_when_hooks_not_registered() {
let temp = tempdir().unwrap();
let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
fs::create_dir_all(temp.path().join(".claude")).unwrap();
fs::write(temp.path().join(".claude.json"), r#"{"mcpServers":{}}"#).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, "x=1").unwrap();
let binary_path = temp.path().join("fake-spool-mcp");
fs::write(&binary_path, "").unwrap();
let ctx = InstallContext {
binary_path: Some(binary_path),
config_path,
dry_run: false,
force: false,
};
let report = installer.diagnose(&ctx).unwrap();
let hooks_check = report
.checks
.iter()
.find(|c| c.name == "claude_settings_hooks_registered")
.unwrap();
assert_eq!(hooks_check.status, DiagnosticStatus::Warn);
}
#[test]
fn update_returns_not_installed_when_no_mcp_entry() {
let (_temp, installer, ctx) = setup();
let report = installer.update(&ctx).unwrap();
assert_eq!(report.status, UpdateStatus::NotInstalled);
assert!(
report
.notes
.iter()
.any(|n| n.contains("not installed") || n.contains("not registered"))
);
}
#[test]
fn update_re_renders_drifted_hooks() {
let (temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let stop_path = temp
.path()
.join(".claude")
.join("hooks")
.join("spool-Stop.sh");
fs::write(&stop_path, "tampered content\n").unwrap();
let report = installer.update(&ctx).unwrap();
assert_eq!(report.status, UpdateStatus::Updated);
assert!(report.updated_paths.contains(&stop_path));
let restored = fs::read_to_string(&stop_path).unwrap();
assert!(!restored.contains("tampered"));
assert!(restored.contains("hook stop"));
}
#[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);
assert!(report.updated_paths.is_empty());
}
#[test]
fn update_dry_run_does_not_write() {
let (temp, installer, mut ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let stop_path = temp
.path()
.join(".claude")
.join("hooks")
.join("spool-Stop.sh");
fs::write(&stop_path, "tampered\n").unwrap();
ctx.dry_run = true;
let report = installer.update(&ctx).unwrap();
assert_eq!(report.status, UpdateStatus::DryRun);
assert!(!report.updated_paths.is_empty());
let content = fs::read_to_string(&stop_path).unwrap();
assert!(content.contains("tampered"));
}
#[test]
fn update_uses_binary_path_from_existing_mcp_entry() {
let (temp, installer, ctx) = setup();
let _ = installer.install(&ctx).unwrap();
let session_path = temp
.path()
.join(".claude")
.join("hooks")
.join("spool-SessionStart.sh");
fs::write(&session_path, "old\n").unwrap();
let update_ctx = InstallContext {
binary_path: None,
config_path: ctx.config_path.clone(),
dry_run: false,
force: false,
};
let report = installer.update(&update_ctx).unwrap();
assert_eq!(report.status, UpdateStatus::Updated);
let restored = fs::read_to_string(&session_path).unwrap();
let expected_bin = ctx.binary_path.unwrap();
let expected_spool = expected_bin.parent().unwrap().join("spool");
assert!(restored.contains(&expected_spool.to_string_lossy().to_string()));
}
}