Skip to main content

agent_hooks/
codex.rs

1//! Copilot/Codex CLI adapter.
2//!
3//! Registers hooks in `~/.codex/config.toml` or `~/.codex/hooks.json`.
4
5use std::path::{Path, PathBuf};
6
7use serde_json::{json, Value};
8
9use crate::detection;
10use crate::{AdapterError, Result, ToolAdapter};
11
12const HOOKS_REL: &str = ".codex/hooks.json";
13
14const EVENTS: &[&str] = &[
15    "postToolUse",
16    "userPromptSubmitted",
17    "errorOccurred",
18];
19
20pub struct CodexAdapter;
21
22impl CodexAdapter {
23    pub fn new() -> Self {
24        Self
25    }
26
27    fn hooks_path(&self) -> Option<PathBuf> {
28        detection::home_path(HOOKS_REL)
29    }
30}
31
32impl ToolAdapter for CodexAdapter {
33    fn name(&self) -> &str {
34        "codex"
35    }
36
37    fn display_name(&self) -> &str {
38        "Codex CLI"
39    }
40
41    fn is_installed(&self) -> bool {
42        detection::home_dir_exists(".codex") || detection::command_exists("codex")
43    }
44
45    fn hooks_registered(&self) -> bool {
46        let Some(path) = self.hooks_path() else {
47            return false;
48        };
49        let Ok(content) = std::fs::read_to_string(&path) else {
50            return false;
51        };
52        content.contains("hook_event_bridge")
53    }
54
55    fn register_hooks(&self, bridge_script: &Path) -> Result<()> {
56        let path = self.hooks_path().ok_or(AdapterError::NoHomeDir)?;
57        detection::ensure_parent_dir(&path)?;
58
59        let mut root: Value = if path.exists() {
60            let content = std::fs::read_to_string(&path)?;
61            serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
62        } else {
63            json!({})
64        };
65
66        let obj = root
67            .as_object_mut()
68            .ok_or_else(|| AdapterError::Config("Invalid hooks.json".into()))?;
69
70        let cmd = bridge_script.to_string_lossy().to_string();
71
72        for event in EVENTS {
73            let arr = obj.entry(*event).or_insert_with(|| json!([]));
74            if !arr.is_array() {
75                *arr = json!([]);
76            }
77            let hooks = arr.as_array_mut().unwrap();
78
79            let already = hooks.iter().any(|h| {
80                h.get("command")
81                    .and_then(|c| c.as_str())
82                    .is_some_and(|c| c == cmd)
83            });
84
85            if !already {
86                hooks.push(json!({ "command": cmd }));
87            }
88        }
89
90        detection::backup_file(&path);
91        let output = serde_json::to_string_pretty(&root)?;
92        std::fs::write(&path, output)?;
93        Ok(())
94    }
95
96    fn unregister_hooks(&self) -> Result<()> {
97        let path = self.hooks_path().ok_or(AdapterError::NoHomeDir)?;
98        if !path.exists() {
99            return Ok(());
100        }
101
102        let content = std::fs::read_to_string(&path)?;
103        let mut root: Value = serde_json::from_str(&content)?;
104
105        let Some(obj) = root.as_object_mut() else {
106            return Ok(());
107        };
108
109        let mut modified = false;
110        for event in EVENTS {
111            if let Some(arr) = obj.get_mut(*event).and_then(|v| v.as_array_mut()) {
112                let before = arr.len();
113                arr.retain(|h| {
114                    !h.get("command")
115                        .and_then(|c| c.as_str())
116                        .is_some_and(|c| c.contains("hook_event_bridge"))
117                });
118                if arr.len() != before {
119                    modified = true;
120                }
121            }
122        }
123
124        if modified {
125            detection::backup_file(&path);
126            let output = serde_json::to_string_pretty(&root)?;
127            std::fs::write(&path, output)?;
128        }
129        Ok(())
130    }
131
132    fn config_path(&self) -> Option<PathBuf> {
133        self.hooks_path()
134    }
135
136    fn supported_events(&self) -> &[&str] {
137        EVENTS
138    }
139}