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, codex_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 CodexInstaller {
home_override: Option<PathBuf>,
}
impl CodexInstaller {
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 codex_dir(&self) -> Result<PathBuf> {
Ok(self.home()?.join(".codex"))
}
fn config_json_path(&self) -> Result<PathBuf> {
Ok(self.codex_dir()?.join("config.json"))
}
fn hooks_json_path(&self) -> Result<PathBuf> {
Ok(self.codex_dir()?.join("hooks.json"))
}
fn hooks_dir(&self) -> Result<PathBuf> {
Ok(self.codex_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 CodexInstaller {
fn default() -> Self {
Self::new()
}
}
impl Installer for CodexInstaller {
fn id(&self) -> ClientId {
ClientId::Codex
}
fn detect(&self) -> Result<bool> {
let codex_dir = self.codex_dir()?;
Ok(codex_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 config_json = self.config_json_path()?;
let mut config_doc = shared::read_json_or_empty(&config_json)?;
let desired = build_mcp_entry(&binary_path, &ctx.config_path);
let mcp_outcome = merge_mcp_entry(&mut config_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 {}.",
config_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::Codex.as_str().to_string(),
binary_path,
config_path: ctx.config_path.clone(),
status: InstallStatus::Conflict,
planned_writes,
backups,
notes,
});
}
let hooks_json = self.hooks_json_path()?;
let mut hooks_doc = shared::read_json_or_empty(&hooks_json)?;
let hooks_dir = self.hooks_dir()?;
let hook_specs = codex_hook_specs();
let mut hooks_json_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_codex_hook_entry(&mut hooks_doc, spec.hook_event, &target_str, timeout) {
CodexHookOutcome::Appended => hooks_json_changed = true,
CodexHookOutcome::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) || hooks_json_changed || hook_files_changed;
if ctx.dry_run {
if matches!(mcp_status, MergeStatus::Changed) {
planned_writes.push(config_json.clone());
}
if hooks_json_changed {
planned_writes.push(hooks_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::Codex.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(&config_json)
.with_context(|| format!("backing up {}", config_json.display()))?
{
backups.push(b);
}
shared::write_json_atomic(&config_json, &config_doc)
.with_context(|| format!("writing {}", config_json.display()))?;
planned_writes.push(config_json.clone());
}
if hooks_json_changed {
if let Some(b) = shared::backup_file(&hooks_json)
.with_context(|| format!("backing up {}", hooks_json.display()))?
{
backups.push(b);
}
shared::write_json_atomic(&hooks_json, &hooks_doc)
.with_context(|| format!("writing {}", hooks_json.display()))?;
planned_writes.push(hooks_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::Codex.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 config_json = self.config_json_path()?;
let hooks_json = self.hooks_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 config_doc_after_purge = if config_json.exists() {
let mut doc = shared::read_json_or_empty(&config_json)?;
match remove_mcp_entry(&mut doc, "spool") {
McpRemoveOutcome::Removed => {
any_change = true;
Some(doc)
}
McpRemoveOutcome::NotPresent => None,
}
} else {
None
};
let hooks_doc_after_purge = if hooks_json.exists() {
let mut doc = shared::read_json_or_empty(&hooks_json)?;
let removed = purge_codex_hook_entries(&mut doc, "spool-");
if removed > 0 {
any_change = true;
Some(doc)
} else {
None
}
} else {
None
};
let hook_files: Vec<PathBuf> = codex_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::Codex.as_str().to_string(),
status: UninstallStatus::NotInstalled,
removed_paths,
backups,
notes,
});
}
if ctx.dry_run {
if config_doc_after_purge.is_some() {
removed_paths.push(config_json);
}
if hooks_doc_after_purge.is_some() {
removed_paths.push(hooks_json);
}
removed_paths.extend(hook_files);
return Ok(UninstallReport {
client: ClientId::Codex.as_str().to_string(),
status: UninstallStatus::DryRun,
removed_paths,
backups,
notes,
});
}
if let Some(doc) = config_doc_after_purge {
if let Some(b) = shared::backup_file(&config_json)? {
backups.push(b);
}
shared::write_json_atomic(&config_json, &doc)?;
removed_paths.push(config_json);
}
if let Some(doc) = hooks_doc_after_purge {
if let Some(b) = shared::backup_file(&hooks_json)? {
backups.push(b);
}
shared::write_json_atomic(&hooks_json, &doc)?;
removed_paths.push(hooks_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::Codex.as_str().to_string(),
status: UninstallStatus::Removed,
removed_paths,
backups,
notes,
})
}
fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
let mut checks = Vec::new();
let codex_dir = self.codex_dir()?;
checks.push(DiagnosticCheck {
name: "codex_dir_exists".into(),
status: if codex_dir.exists() {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
},
detail: format!("{}", codex_dir.display()),
});
let config_json = self.config_json_path()?;
let registration_status = if config_json.exists() {
let doc = shared::read_json_or_empty(&config_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 hooks_json = self.hooks_json_path()?;
let hooks_registered_status = if hooks_json.exists() {
let doc = shared::read_json_or_empty(&hooks_json)?;
if has_any_spool_hook_entry(&doc) {
DiagnosticStatus::Ok
} else {
DiagnosticStatus::Warn
}
} else {
DiagnosticStatus::Warn
};
checks.push(DiagnosticCheck {
name: "codex_hooks_registered".into(),
status: hooks_registered_status,
detail: format!("{}", hooks_json.display()),
});
let hooks_dir = self.hooks_dir()?;
let mut missing: Vec<String> = Vec::new();
for spec in codex_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!("{} (3/3 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::Codex.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 CodexHookOutcome {
Appended,
Unchanged,
}
fn timeout_for_event(event: &str) -> u64 {
match event {
"session_end" => HOOK_TIMEOUT_SESSION_END_MS,
_ => HOOK_TIMEOUT_DEFAULT_MS,
}
}
fn upsert_codex_hook_entry(
doc: &mut Value,
event: &str,
command_path: &str,
timeout_ms: u64,
) -> CodexHookOutcome {
let root = match doc.as_object_mut() {
Some(obj) => obj,
None => {
*doc = json!({});
doc.as_object_mut().expect("just inserted")
}
};
let hooks = root.entry("hooks").or_insert_with(|| json!({}));
if !hooks.is_object() {
*hooks = json!({});
}
let hooks_obj = hooks.as_object_mut().expect("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 CodexHookOutcome::Unchanged;
}
}
array.push(json!({
"command": command_path,
"timeout_ms": timeout_ms,
}));
CodexHookOutcome::Appended
}
fn purge_codex_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(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
return 0;
};
for (_event, entries) in 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();
}
hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
if hooks.is_empty() {
root.remove("hooks");
}
removed
}
fn has_any_spool_hook_entry(doc: &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 {
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, CodexInstaller, InstallContext) {
let temp = tempdir().unwrap();
let home = temp.path().to_path_buf();
let installer = CodexInstaller::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_codex_dir() {
let temp = tempdir().unwrap();
let installer = CodexInstaller::with_home_root(temp.path().to_path_buf());
assert!(!installer.detect().unwrap());
}
#[test]
fn detect_returns_true_when_codex_dir_present() {
let temp = tempdir().unwrap();
fs::create_dir_all(temp.path().join(".codex")).unwrap();
let installer = CodexInstaller::with_home_root(temp.path().to_path_buf());
assert!(installer.detect().unwrap());
}
#[test]
fn install_creates_config_and_hooks() {
let (temp, installer, ctx) = setup();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Installed);
let config_json = temp.path().join(".codex").join("config.json");
assert!(config_json.exists());
let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
assert!(doc["mcpServers"]["spool"].is_object());
let hooks_json = temp.path().join(".codex").join("hooks.json");
assert!(hooks_json.exists());
let hooks_doc: Value =
serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap();
assert!(hooks_doc["hooks"]["session_start"].is_array());
assert!(hooks_doc["hooks"]["post_tool_use"].is_array());
assert!(hooks_doc["hooks"]["session_end"].is_array());
let end_entry = &hooks_doc["hooks"]["session_end"][0];
assert_eq!(end_entry["timeout_ms"], HOOK_TIMEOUT_SESSION_END_MS);
for spec in codex_hook_specs() {
let p = temp
.path()
.join(".codex")
.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(".codex").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_marks_conflict_when_existing_mcp_entry_differs() {
let (temp, installer, ctx) = setup();
let codex_dir = temp.path().join(".codex");
fs::create_dir_all(&codex_dir).unwrap();
let preexisting = json!({
"mcpServers": {
"spool": {"type": "stdio", "command": "/old/path", "args": []}
}
});
fs::write(
codex_dir.join("config.json"),
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
let report = installer.install(&ctx).unwrap();
assert_eq!(report.status, InstallStatus::Conflict);
}
#[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 codex_hook_specs() {
let p = temp
.path()
.join(".codex")
.join("hooks")
.join(spec.file_name);
assert!(!p.exists(), "{} should be removed", p.display());
}
let config_json = temp.path().join(".codex").join("config.json");
let doc: Value = serde_json::from_str(&fs::read_to_string(&config_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 uninstall_preserves_other_hooks() {
let (temp, installer, ctx) = setup();
let codex_dir = temp.path().join(".codex");
fs::create_dir_all(&codex_dir).unwrap();
let preexisting = json!({
"hooks": {
"session_start": [
{"command": "/other/tool", "timeout_ms": 3000}
]
}
});
fs::write(
codex_dir.join("hooks.json"),
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
let _ = installer.install(&ctx).unwrap();
let _ = installer.uninstall(&ctx).unwrap();
let hooks_doc: Value =
serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
.unwrap();
let entries = hooks_doc["hooks"]["session_start"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["command"], "/other/tool");
}
#[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 [
"codex_dir_exists",
"mcp_servers_spool_registered",
"spool_mcp_binary",
"spool_config_readable",
"codex_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(".codex")
.join("hooks")
.join("spool-session_start.sh");
let perms = fs::metadata(&session).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o755);
}
#[test]
fn codex_hook_entries_use_flat_format() {
let mut doc = json!({});
upsert_codex_hook_entry(
&mut doc,
"session_start",
"/abs/spool-session_start.sh",
5000,
);
let entries = doc["hooks"]["session_start"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["command"], "/abs/spool-session_start.sh");
assert_eq!(entries[0]["timeout_ms"], 5000);
assert!(entries[0].get("matcher").is_none());
}
#[test]
fn upsert_codex_hook_unchanged_on_repeat() {
let mut doc = json!({});
upsert_codex_hook_entry(&mut doc, "session_start", "/abs/hook.sh", 5000);
let outcome = upsert_codex_hook_entry(&mut doc, "session_start", "/abs/hook.sh", 5000);
assert_eq!(outcome, CodexHookOutcome::Unchanged);
assert_eq!(doc["hooks"]["session_start"].as_array().unwrap().len(), 1);
}
#[test]
fn purge_codex_hook_entries_removes_spool_only() {
let mut doc = json!({
"hooks": {
"session_start": [
{"command": "/other/tool", "timeout_ms": 3000},
{"command": "/abs/.codex/hooks/spool-session_start.sh", "timeout_ms": 5000}
],
"session_end": [
{"command": "/abs/spool-session_end.sh", "timeout_ms": 10000}
]
}
});
let removed = purge_codex_hook_entries(&mut doc, "spool-");
assert_eq!(removed, 2);
let entries = doc["hooks"]["session_start"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["command"], "/other/tool");
assert!(doc["hooks"].get("session_end").is_none());
}
}