1use anyhow::{Context, Result};
30use serde_json::{Value, json};
31use std::path::{Path, PathBuf};
32
33use super::shared::{
34 self, McpMergeOutcome, McpRemoveOutcome, build_mcp_entry, merge_mcp_entry, remove_mcp_entry,
35};
36use super::templates::{self, codex_hook_specs};
37use super::{
38 ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
39 InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
40};
41
42const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
44const HOOK_TIMEOUT_SESSION_END_MS: u64 = 10000;
46
47pub struct CodexInstaller {
48 home_override: Option<PathBuf>,
49}
50
51impl CodexInstaller {
52 pub fn new() -> Self {
53 Self {
54 home_override: None,
55 }
56 }
57
58 #[doc(hidden)]
59 pub fn with_home_root(root: PathBuf) -> Self {
60 Self {
61 home_override: Some(root),
62 }
63 }
64
65 fn home(&self) -> Result<PathBuf> {
66 match &self.home_override {
67 Some(p) => Ok(p.clone()),
68 None => shared::home_dir(),
69 }
70 }
71
72 fn codex_dir(&self) -> Result<PathBuf> {
73 Ok(self.home()?.join(".codex"))
74 }
75
76 fn config_json_path(&self) -> Result<PathBuf> {
77 Ok(self.codex_dir()?.join("config.json"))
78 }
79
80 fn hooks_json_path(&self) -> Result<PathBuf> {
81 Ok(self.codex_dir()?.join("hooks.json"))
82 }
83
84 fn hooks_dir(&self) -> Result<PathBuf> {
85 Ok(self.codex_dir()?.join("hooks"))
86 }
87
88 fn default_binary_path(&self) -> Result<PathBuf> {
89 Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
90 }
91
92 fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
93 match &ctx.binary_path {
94 Some(p) => Ok(p.clone()),
95 None => self.default_binary_path(),
96 }
97 }
98
99 fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
100 if !ctx.config_path.is_absolute() {
101 anyhow::bail!(
102 "config path must be absolute, got: {}",
103 ctx.config_path.display()
104 );
105 }
106 if !binary_path.is_absolute() {
107 anyhow::bail!(
108 "binary path must be absolute, got: {}",
109 binary_path.display()
110 );
111 }
112 Ok(())
113 }
114}
115
116impl Default for CodexInstaller {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122impl Installer for CodexInstaller {
123 fn id(&self) -> ClientId {
124 ClientId::Codex
125 }
126
127 fn detect(&self) -> Result<bool> {
128 let codex_dir = self.codex_dir()?;
129 Ok(codex_dir.exists())
130 }
131
132 fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
133 let binary_path = self.resolve_binary_path(ctx)?;
134 self.validate_inputs(ctx, &binary_path)?;
135
136 let mut planned_writes: Vec<PathBuf> = Vec::new();
137 let mut backups: Vec<PathBuf> = Vec::new();
138 let mut notes: Vec<String> = Vec::new();
139
140 let config_json = self.config_json_path()?;
142 let mut config_doc = shared::read_json_or_empty(&config_json)?;
143 let desired = build_mcp_entry(&binary_path, &ctx.config_path);
144 let mcp_outcome = merge_mcp_entry(&mut config_doc, "spool", desired, ctx.force);
145
146 if !binary_path.exists() {
147 notes.push(format!(
148 "spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
149 binary_path.display()
150 ));
151 }
152
153 let mcp_status = match mcp_outcome {
154 McpMergeOutcome::Inserted => MergeStatus::Changed,
155 McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
156 McpMergeOutcome::Conflict {
157 force_applied: true,
158 } => {
159 notes.push(format!(
160 "Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
161 config_json.display()
162 ));
163 MergeStatus::Changed
164 }
165 McpMergeOutcome::Conflict {
166 force_applied: false,
167 } => {
168 notes.push(
169 "Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first.".to_string(),
170 );
171 MergeStatus::Conflict
172 }
173 };
174
175 if matches!(mcp_status, MergeStatus::Conflict) {
176 return Ok(InstallReport {
177 client: ClientId::Codex.as_str().to_string(),
178 binary_path,
179 config_path: ctx.config_path.clone(),
180 status: InstallStatus::Conflict,
181 planned_writes,
182 backups,
183 notes,
184 });
185 }
186
187 let hooks_json = self.hooks_json_path()?;
189 let mut hooks_doc = shared::read_json_or_empty(&hooks_json)?;
190 let hooks_dir = self.hooks_dir()?;
191 let hook_specs = codex_hook_specs();
192 let mut hooks_json_changed = false;
193 for spec in &hook_specs {
194 let target_path = hooks_dir.join(spec.file_name);
195 let target_str = target_path.to_string_lossy().into_owned();
196 let timeout = timeout_for_event(spec.hook_event);
197 match upsert_codex_hook_entry(&mut hooks_doc, spec.hook_event, &target_str, timeout) {
198 CodexHookOutcome::Appended => hooks_json_changed = true,
199 CodexHookOutcome::Unchanged => {}
200 }
201 }
202
203 let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
205 let hook_files: Vec<HookFilePlan> = hook_specs
206 .iter()
207 .map(|spec| HookFilePlan {
208 target_path: hooks_dir.join(spec.file_name),
209 rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
210 })
211 .collect();
212 let hook_files_changed = hook_files
213 .iter()
214 .any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));
215
216 let any_change =
218 matches!(mcp_status, MergeStatus::Changed) || hooks_json_changed || hook_files_changed;
219
220 if ctx.dry_run {
221 if matches!(mcp_status, MergeStatus::Changed) {
222 planned_writes.push(config_json.clone());
223 }
224 if hooks_json_changed {
225 planned_writes.push(hooks_json.clone());
226 }
227 for plan in &hook_files {
228 if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
229 planned_writes.push(plan.target_path.clone());
230 }
231 }
232 return Ok(InstallReport {
233 client: ClientId::Codex.as_str().to_string(),
234 binary_path,
235 config_path: ctx.config_path.clone(),
236 status: if any_change {
237 InstallStatus::DryRun
238 } else {
239 InstallStatus::Unchanged
240 },
241 planned_writes,
242 backups,
243 notes,
244 });
245 }
246
247 if matches!(mcp_status, MergeStatus::Changed) {
249 if let Some(b) = shared::backup_file(&config_json)
250 .with_context(|| format!("backing up {}", config_json.display()))?
251 {
252 backups.push(b);
253 }
254 shared::write_json_atomic(&config_json, &config_doc)
255 .with_context(|| format!("writing {}", config_json.display()))?;
256 planned_writes.push(config_json.clone());
257 }
258
259 if hooks_json_changed {
261 if let Some(b) = shared::backup_file(&hooks_json)
262 .with_context(|| format!("backing up {}", hooks_json.display()))?
263 {
264 backups.push(b);
265 }
266 shared::write_json_atomic(&hooks_json, &hooks_doc)
267 .with_context(|| format!("writing {}", hooks_json.display()))?;
268 planned_writes.push(hooks_json.clone());
269 }
270
271 if !hooks_dir.exists() {
273 std::fs::create_dir_all(&hooks_dir)
274 .with_context(|| format!("creating {}", hooks_dir.display()))?;
275 }
276 for plan in &hook_files {
277 if file_has_exact_contents(&plan.target_path, &plan.rendered) {
278 continue;
279 }
280 std::fs::write(&plan.target_path, &plan.rendered)
281 .with_context(|| format!("writing {}", plan.target_path.display()))?;
282 set_executable(&plan.target_path)?;
283 planned_writes.push(plan.target_path.clone());
284 }
285
286 let final_status = if any_change {
287 InstallStatus::Installed
288 } else {
289 InstallStatus::Unchanged
290 };
291
292 Ok(InstallReport {
293 client: ClientId::Codex.as_str().to_string(),
294 binary_path,
295 config_path: ctx.config_path.clone(),
296 status: final_status,
297 planned_writes,
298 backups,
299 notes,
300 })
301 }
302
303 fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
304 let report = self.install(ctx)?;
305 let status = match report.status {
306 InstallStatus::Installed => UpdateStatus::Updated,
307 InstallStatus::Unchanged => UpdateStatus::Unchanged,
308 InstallStatus::DryRun => UpdateStatus::DryRun,
309 InstallStatus::Conflict => UpdateStatus::NotInstalled,
310 };
311 Ok(UpdateReport {
312 client: report.client,
313 status,
314 updated_paths: report.planned_writes,
315 notes: report.notes,
316 })
317 }
318
319 fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
320 let config_json = self.config_json_path()?;
321 let hooks_json = self.hooks_json_path()?;
322 let hooks_dir = self.hooks_dir()?;
323
324 let mut notes: Vec<String> = Vec::new();
325 let mut removed_paths: Vec<PathBuf> = Vec::new();
326 let mut backups: Vec<PathBuf> = Vec::new();
327 let mut any_change = false;
328
329 let config_doc_after_purge = if config_json.exists() {
331 let mut doc = shared::read_json_or_empty(&config_json)?;
332 match remove_mcp_entry(&mut doc, "spool") {
333 McpRemoveOutcome::Removed => {
334 any_change = true;
335 Some(doc)
336 }
337 McpRemoveOutcome::NotPresent => None,
338 }
339 } else {
340 None
341 };
342
343 let hooks_doc_after_purge = if hooks_json.exists() {
345 let mut doc = shared::read_json_or_empty(&hooks_json)?;
346 let removed = purge_codex_hook_entries(&mut doc, "spool-");
347 if removed > 0 {
348 any_change = true;
349 Some(doc)
350 } else {
351 None
352 }
353 } else {
354 None
355 };
356
357 let hook_files: Vec<PathBuf> = codex_hook_specs()
359 .iter()
360 .map(|s| hooks_dir.join(s.file_name))
361 .filter(|p| p.exists())
362 .collect();
363 if !hook_files.is_empty() {
364 any_change = true;
365 }
366
367 if !any_change {
368 notes.push("nothing to uninstall — no spool artifacts found.".to_string());
369 return Ok(UninstallReport {
370 client: ClientId::Codex.as_str().to_string(),
371 status: UninstallStatus::NotInstalled,
372 removed_paths,
373 backups,
374 notes,
375 });
376 }
377
378 if ctx.dry_run {
379 if config_doc_after_purge.is_some() {
380 removed_paths.push(config_json);
381 }
382 if hooks_doc_after_purge.is_some() {
383 removed_paths.push(hooks_json);
384 }
385 removed_paths.extend(hook_files);
386 return Ok(UninstallReport {
387 client: ClientId::Codex.as_str().to_string(),
388 status: UninstallStatus::DryRun,
389 removed_paths,
390 backups,
391 notes,
392 });
393 }
394
395 if let Some(doc) = config_doc_after_purge {
397 if let Some(b) = shared::backup_file(&config_json)? {
398 backups.push(b);
399 }
400 shared::write_json_atomic(&config_json, &doc)?;
401 removed_paths.push(config_json);
402 }
403
404 if let Some(doc) = hooks_doc_after_purge {
406 if let Some(b) = shared::backup_file(&hooks_json)? {
407 backups.push(b);
408 }
409 shared::write_json_atomic(&hooks_json, &doc)?;
410 removed_paths.push(hooks_json);
411 }
412
413 for p in hook_files {
415 std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
416 removed_paths.push(p);
417 }
418
419 Ok(UninstallReport {
420 client: ClientId::Codex.as_str().to_string(),
421 status: UninstallStatus::Removed,
422 removed_paths,
423 backups,
424 notes,
425 })
426 }
427
428 fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
429 let mut checks = Vec::new();
430
431 let codex_dir = self.codex_dir()?;
433 checks.push(DiagnosticCheck {
434 name: "codex_dir_exists".into(),
435 status: if codex_dir.exists() {
436 DiagnosticStatus::Ok
437 } else {
438 DiagnosticStatus::Warn
439 },
440 detail: format!("{}", codex_dir.display()),
441 });
442
443 let config_json = self.config_json_path()?;
445 let registration_status = if config_json.exists() {
446 let doc = shared::read_json_or_empty(&config_json)?;
447 if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
448 DiagnosticStatus::Ok
449 } else {
450 DiagnosticStatus::Warn
451 }
452 } else {
453 DiagnosticStatus::NotApplicable
454 };
455 checks.push(DiagnosticCheck {
456 name: "mcp_servers_spool_registered".into(),
457 status: registration_status,
458 detail: "mcpServers.spool entry presence".into(),
459 });
460
461 let binary_path = self.resolve_binary_path(ctx)?;
463 checks.push(DiagnosticCheck {
464 name: "spool_mcp_binary".into(),
465 status: if binary_path.exists() {
466 DiagnosticStatus::Ok
467 } else {
468 DiagnosticStatus::Fail
469 },
470 detail: format!("{}", binary_path.display()),
471 });
472
473 checks.push(DiagnosticCheck {
475 name: "spool_config_readable".into(),
476 status: if ctx.config_path.exists() {
477 DiagnosticStatus::Ok
478 } else {
479 DiagnosticStatus::Fail
480 },
481 detail: format!("{}", ctx.config_path.display()),
482 });
483
484 let hooks_json = self.hooks_json_path()?;
486 let hooks_registered_status = if hooks_json.exists() {
487 let doc = shared::read_json_or_empty(&hooks_json)?;
488 if has_any_spool_hook_entry(&doc) {
489 DiagnosticStatus::Ok
490 } else {
491 DiagnosticStatus::Warn
492 }
493 } else {
494 DiagnosticStatus::Warn
495 };
496 checks.push(DiagnosticCheck {
497 name: "codex_hooks_registered".into(),
498 status: hooks_registered_status,
499 detail: format!("{}", hooks_json.display()),
500 });
501
502 let hooks_dir = self.hooks_dir()?;
504 let mut missing: Vec<String> = Vec::new();
505 for spec in codex_hook_specs() {
506 let p = hooks_dir.join(spec.file_name);
507 if !p.exists() {
508 missing.push(spec.file_name.to_string());
509 }
510 }
511 let hook_files_detail = if missing.is_empty() {
512 format!("{} (3/3 present)", hooks_dir.display())
513 } else {
514 format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
515 };
516 checks.push(DiagnosticCheck {
517 name: "spool_hook_scripts".into(),
518 status: if missing.is_empty() {
519 DiagnosticStatus::Ok
520 } else {
521 DiagnosticStatus::Warn
522 },
523 detail: hook_files_detail,
524 });
525
526 Ok(DiagnosticReport {
527 client: ClientId::Codex.as_str().to_string(),
528 checks,
529 })
530 }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538enum MergeStatus {
539 Changed,
540 Unchanged,
541 Conflict,
542}
543
544struct HookFilePlan {
545 target_path: PathBuf,
546 rendered: String,
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
550enum CodexHookOutcome {
551 Appended,
552 Unchanged,
553}
554
555fn timeout_for_event(event: &str) -> u64 {
556 match event {
557 "session_end" => HOOK_TIMEOUT_SESSION_END_MS,
558 _ => HOOK_TIMEOUT_DEFAULT_MS,
559 }
560}
561
562fn upsert_codex_hook_entry(
565 doc: &mut Value,
566 event: &str,
567 command_path: &str,
568 timeout_ms: u64,
569) -> CodexHookOutcome {
570 let root = match doc.as_object_mut() {
571 Some(obj) => obj,
572 None => {
573 *doc = json!({});
574 doc.as_object_mut().expect("just inserted")
575 }
576 };
577 let hooks = root.entry("hooks").or_insert_with(|| json!({}));
578 if !hooks.is_object() {
579 *hooks = json!({});
580 }
581 let hooks_obj = hooks.as_object_mut().expect("hooks must be object");
582 let entries = hooks_obj
583 .entry(event)
584 .or_insert_with(|| Value::Array(Vec::new()));
585 if !entries.is_array() {
586 *entries = Value::Array(Vec::new());
587 }
588 let array = entries.as_array_mut().expect("entries must be array");
589
590 for entry in array.iter() {
591 if entry.get("command").and_then(Value::as_str) == Some(command_path) {
592 return CodexHookOutcome::Unchanged;
593 }
594 }
595
596 array.push(json!({
597 "command": command_path,
598 "timeout_ms": timeout_ms,
599 }));
600 CodexHookOutcome::Appended
601}
602
603fn purge_codex_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
606 let mut removed = 0usize;
607 let Some(root) = doc.as_object_mut() else {
608 return 0;
609 };
610 let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
611 return 0;
612 };
613 for (_event, entries) in hooks.iter_mut() {
614 let Some(array) = entries.as_array_mut() else {
615 continue;
616 };
617 let before = array.len();
618 array.retain(|entry| {
619 !entry
620 .get("command")
621 .and_then(Value::as_str)
622 .is_some_and(|c| c.contains(marker_substring))
623 });
624 removed += before - array.len();
625 }
626 hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
627 if hooks.is_empty() {
628 root.remove("hooks");
629 }
630 removed
631}
632
633fn has_any_spool_hook_entry(doc: &Value) -> bool {
634 let Some(hooks) = doc.get("hooks").and_then(|v| v.as_object()) else {
635 return false;
636 };
637 for entries in hooks.values() {
638 let Some(arr) = entries.as_array() else {
639 continue;
640 };
641 for entry in arr {
642 if entry
643 .get("command")
644 .and_then(Value::as_str)
645 .is_some_and(|c| c.contains("spool-"))
646 {
647 return true;
648 }
649 }
650 }
651 false
652}
653
654fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
655 if !path.exists() {
656 return false;
657 }
658 match std::fs::read_to_string(path) {
659 Ok(actual) => actual == expected,
660 Err(_) => false,
661 }
662}
663
664#[cfg(unix)]
665fn set_executable(path: &Path) -> Result<()> {
666 use std::os::unix::fs::PermissionsExt;
667 let mut perms = std::fs::metadata(path)
668 .with_context(|| format!("stat {}", path.display()))?
669 .permissions();
670 perms.set_mode(0o755);
671 std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
672 Ok(())
673}
674
675#[cfg(not(unix))]
676fn set_executable(_path: &Path) -> Result<()> {
677 Ok(())
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use std::fs;
684 use tempfile::tempdir;
685
686 fn setup() -> (tempfile::TempDir, CodexInstaller, InstallContext) {
687 let temp = tempdir().unwrap();
688 let home = temp.path().to_path_buf();
689 let installer = CodexInstaller::with_home_root(home.clone());
690
691 let config_path = home.join("spool.toml");
692 fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
693 let binary_path = home.join("fake-spool-mcp");
694 fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
695
696 let ctx = InstallContext {
697 binary_path: Some(binary_path),
698 config_path,
699 dry_run: false,
700 force: false,
701 };
702 (temp, installer, ctx)
703 }
704
705 #[test]
706 fn detect_returns_false_when_no_codex_dir() {
707 let temp = tempdir().unwrap();
708 let installer = CodexInstaller::with_home_root(temp.path().to_path_buf());
709 assert!(!installer.detect().unwrap());
710 }
711
712 #[test]
713 fn detect_returns_true_when_codex_dir_present() {
714 let temp = tempdir().unwrap();
715 fs::create_dir_all(temp.path().join(".codex")).unwrap();
716 let installer = CodexInstaller::with_home_root(temp.path().to_path_buf());
717 assert!(installer.detect().unwrap());
718 }
719
720 #[test]
721 fn install_creates_config_and_hooks() {
722 let (temp, installer, ctx) = setup();
723 let report = installer.install(&ctx).unwrap();
724 assert_eq!(report.status, InstallStatus::Installed);
725
726 let config_json = temp.path().join(".codex").join("config.json");
728 assert!(config_json.exists());
729 let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
730 assert!(doc["mcpServers"]["spool"].is_object());
731
732 let hooks_json = temp.path().join(".codex").join("hooks.json");
734 assert!(hooks_json.exists());
735 let hooks_doc: Value =
736 serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap();
737 assert!(hooks_doc["hooks"]["session_start"].is_array());
738 assert!(hooks_doc["hooks"]["post_tool_use"].is_array());
739 assert!(hooks_doc["hooks"]["session_end"].is_array());
740
741 let end_entry = &hooks_doc["hooks"]["session_end"][0];
743 assert_eq!(end_entry["timeout_ms"], HOOK_TIMEOUT_SESSION_END_MS);
744
745 for spec in codex_hook_specs() {
747 let p = temp
748 .path()
749 .join(".codex")
750 .join("hooks")
751 .join(spec.file_name);
752 assert!(p.exists(), "{} missing", p.display());
753 }
754 }
755
756 #[test]
757 fn install_dry_run_does_not_write() {
758 let (temp, installer, mut ctx) = setup();
759 ctx.dry_run = true;
760 let report = installer.install(&ctx).unwrap();
761 assert_eq!(report.status, InstallStatus::DryRun);
762 assert!(!report.planned_writes.is_empty());
763 assert!(!temp.path().join(".codex").exists());
764 }
765
766 #[test]
767 fn install_unchanged_on_repeat() {
768 let (_temp, installer, ctx) = setup();
769 let _ = installer.install(&ctx).unwrap();
770 let second = installer.install(&ctx).unwrap();
771 assert_eq!(second.status, InstallStatus::Unchanged);
772 }
773
774 #[test]
775 fn install_marks_conflict_when_existing_mcp_entry_differs() {
776 let (temp, installer, ctx) = setup();
777 let codex_dir = temp.path().join(".codex");
778 fs::create_dir_all(&codex_dir).unwrap();
779 let preexisting = json!({
780 "mcpServers": {
781 "spool": {"type": "stdio", "command": "/old/path", "args": []}
782 }
783 });
784 fs::write(
785 codex_dir.join("config.json"),
786 serde_json::to_string_pretty(&preexisting).unwrap(),
787 )
788 .unwrap();
789
790 let report = installer.install(&ctx).unwrap();
791 assert_eq!(report.status, InstallStatus::Conflict);
792 }
793
794 #[test]
795 fn uninstall_removes_entries() {
796 let (temp, installer, ctx) = setup();
797 let _ = installer.install(&ctx).unwrap();
798
799 let report = installer.uninstall(&ctx).unwrap();
800 assert_eq!(report.status, UninstallStatus::Removed);
801
802 for spec in codex_hook_specs() {
804 let p = temp
805 .path()
806 .join(".codex")
807 .join("hooks")
808 .join(spec.file_name);
809 assert!(!p.exists(), "{} should be removed", p.display());
810 }
811
812 let config_json = temp.path().join(".codex").join("config.json");
814 let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
815 assert!(doc["mcpServers"].get("spool").is_none());
816 }
817
818 #[test]
819 fn uninstall_not_installed_when_clean() {
820 let (_temp, installer, ctx) = setup();
821 let report = installer.uninstall(&ctx).unwrap();
822 assert_eq!(report.status, UninstallStatus::NotInstalled);
823 }
824
825 #[test]
826 fn uninstall_preserves_other_hooks() {
827 let (temp, installer, ctx) = setup();
828 let codex_dir = temp.path().join(".codex");
830 fs::create_dir_all(&codex_dir).unwrap();
831 let preexisting = json!({
832 "hooks": {
833 "session_start": [
834 {"command": "/other/tool", "timeout_ms": 3000}
835 ]
836 }
837 });
838 fs::write(
839 codex_dir.join("hooks.json"),
840 serde_json::to_string_pretty(&preexisting).unwrap(),
841 )
842 .unwrap();
843
844 let _ = installer.install(&ctx).unwrap();
845 let _ = installer.uninstall(&ctx).unwrap();
846
847 let hooks_doc: Value =
848 serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
849 .unwrap();
850 let entries = hooks_doc["hooks"]["session_start"].as_array().unwrap();
851 assert_eq!(entries.len(), 1);
852 assert_eq!(entries[0]["command"], "/other/tool");
853 }
854
855 #[test]
856 fn diagnose_reports_full_check_set_after_install() {
857 let (_temp, installer, ctx) = setup();
858 let _ = installer.install(&ctx).unwrap();
859 let report = installer.diagnose(&ctx).unwrap();
860 let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
861 for expected in [
862 "codex_dir_exists",
863 "mcp_servers_spool_registered",
864 "spool_mcp_binary",
865 "spool_config_readable",
866 "codex_hooks_registered",
867 "spool_hook_scripts",
868 ] {
869 assert!(names.contains(&expected), "missing check {}", expected);
870 }
871 }
872
873 #[cfg(unix)]
874 #[test]
875 fn install_makes_hook_scripts_executable() {
876 use std::os::unix::fs::PermissionsExt;
877 let (temp, installer, ctx) = setup();
878 let _ = installer.install(&ctx).unwrap();
879 let session = temp
880 .path()
881 .join(".codex")
882 .join("hooks")
883 .join("spool-session_start.sh");
884 let perms = fs::metadata(&session).unwrap().permissions();
885 assert_eq!(perms.mode() & 0o777, 0o755);
886 }
887
888 #[test]
889 fn codex_hook_entries_use_flat_format() {
890 let mut doc = json!({});
891 upsert_codex_hook_entry(
892 &mut doc,
893 "session_start",
894 "/abs/spool-session_start.sh",
895 5000,
896 );
897 let entries = doc["hooks"]["session_start"].as_array().unwrap();
898 assert_eq!(entries.len(), 1);
899 assert_eq!(entries[0]["command"], "/abs/spool-session_start.sh");
900 assert_eq!(entries[0]["timeout_ms"], 5000);
901 assert!(entries[0].get("matcher").is_none());
903 }
904
905 #[test]
906 fn upsert_codex_hook_unchanged_on_repeat() {
907 let mut doc = json!({});
908 upsert_codex_hook_entry(&mut doc, "session_start", "/abs/hook.sh", 5000);
909 let outcome = upsert_codex_hook_entry(&mut doc, "session_start", "/abs/hook.sh", 5000);
910 assert_eq!(outcome, CodexHookOutcome::Unchanged);
911 assert_eq!(doc["hooks"]["session_start"].as_array().unwrap().len(), 1);
912 }
913
914 #[test]
915 fn purge_codex_hook_entries_removes_spool_only() {
916 let mut doc = json!({
917 "hooks": {
918 "session_start": [
919 {"command": "/other/tool", "timeout_ms": 3000},
920 {"command": "/abs/.codex/hooks/spool-session_start.sh", "timeout_ms": 5000}
921 ],
922 "session_end": [
923 {"command": "/abs/spool-session_end.sh", "timeout_ms": 10000}
924 ]
925 }
926 });
927 let removed = purge_codex_hook_entries(&mut doc, "spool-");
928 assert_eq!(removed, 2);
929 let entries = doc["hooks"]["session_start"].as_array().unwrap();
930 assert_eq!(entries.len(), 1);
931 assert_eq!(entries[0]["command"], "/other/tool");
932 assert!(doc["hooks"].get("session_end").is_none());
934 }
935}