Skip to main content

spool/installers/
codex.rs

1//! Codex CLI installer adapter.
2//!
3//! ## What we touch
4//! - `~/.codex/config.json`    — `mcpServers.spool` entry
5//! - `~/.codex/hooks.json`     — hook entries for session_start,
6//!   post_tool_use, session_end pointing to our hook scripts
7//! - `~/.codex/hooks/spool-*.sh` — three hook scripts
8//!
9//! ## Codex hook format
10//! Codex uses a flat `hooks.json` with the shape:
11//! ```json
12//! {
13//!   "hooks": {
14//!     "session_start": [{"command": "...", "timeout_ms": 5000}],
15//!     "post_tool_use": [{"command": "...", "timeout_ms": 5000}],
16//!     "session_end":   [{"command": "...", "timeout_ms": 10000}]
17//!   }
18//! }
19//! ```
20//!
21//! Unlike Claude Code's `settings.json` (which wraps each entry in a
22//! `{matcher, hooks: [...]}` envelope), Codex entries are flat objects
23//! with `command` + `timeout_ms`.
24//!
25//! ## Binary resolution
26//! Same strategy as Claude: default to `~/.cargo/bin/spool-mcp`, with
27//! `--binary-path` as an escape hatch.
28
29use 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
42/// Default timeout for session_start and post_tool_use hooks (ms).
43const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
44/// Longer timeout for session_end (distill pipeline may take longer).
45const 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        // ── 1. mcpServers entry in ~/.codex/config.json ───────────
141        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        // ── 2. hooks.json entries ─────────────────────────────────
188        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        // ── 3. plan hook script writes ────────────────────────────
204        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        // ── 4. dry-run preview ────────────────────────────────────
217        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        // ── 5. apply mcpServers ───────────────────────────────────
248        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        // ── 6. apply hooks.json ───────────────────────────────────
260        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        // ── 7. apply hook script files ────────────────────────────
272        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        // ── 1. mcpServers ─────────────────────────────────────────
330        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        // ── 2. hooks.json purge ───────────────────────────────────
344        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        // ── 3. plan file removals ─────────────────────────────────
358        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        // ── 4. apply mcpServers purge ─────────────────────────────
396        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        // ── 5. apply hooks.json purge ─────────────────────────────
405        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        // ── 6. delete hook scripts ────────────────────────────────
414        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        // Codex dir presence
432        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        // mcpServers.spool registration
444        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        // Binary path
462        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        // Config TOML readable
474        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        // hooks.json has spool entries
485        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        // hook script files present
503        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// ─────────────────────────────────────────────────────────────────────
534// internal helpers
535// ─────────────────────────────────────────────────────────────────────
536
537#[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
562/// Ensure `doc.hooks.{event}` contains an entry with `command_path`.
563/// Codex hook entries are flat: `{"command": "...", "timeout_ms": N}`.
564fn 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
603/// Remove entries from `doc.hooks.{event}` whose `command` contains
604/// `marker_substring`. Returns the number of entries removed.
605fn 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        // config.json written with mcpServers.spool
727        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        // hooks.json written with 3 events
733        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        // session_end has longer timeout
742        let end_entry = &hooks_doc["hooks"]["session_end"][0];
743        assert_eq!(end_entry["timeout_ms"], HOOK_TIMEOUT_SESSION_END_MS);
744
745        // hook script files exist
746        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        // hook scripts gone
803        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        // mcpServers.spool gone
813        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        // Pre-seed another tool's hook
829        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        // No "matcher" or nested "hooks" array — flat format.
902        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        // session_end is now empty → swept.
933        assert!(doc["hooks"].get("session_end").is_none());
934    }
935}