Skip to main content

edda_bridge_codex/
admin.rs

1//! Install / uninstall / doctor for the Codex bridge.
2//!
3//! Codex reads hooks from `~/.codex/hooks.json` (user-level) or
4//! `<repo>/.codex/hooks.json` (project-level). We install to user-level by
5//! default so every project a user opens under Codex gets edda hooks
6//! automatically — the same push-mode experience Claude Code users have.
7//!
8//! Hook config shape (from https://developers.openai.com/codex/hooks):
9//! ```json
10//! {
11//!   "hooks": {
12//!     "SessionStart": [
13//!       { "hooks": [{ "type": "command", "command": "edda hook codex" }] }
14//!     ],
15//!     "PreToolUse": [
16//!       { "matcher": "Bash", "hooks": [{ "type": "command", "command": "edda hook codex" }] }
17//!     ]
18//!   }
19//! }
20//! ```
21
22use std::fs;
23use std::path::{Path, PathBuf};
24
25const HOOK_COMMAND: &str = "edda hook codex";
26
27const HOOK_EVENTS: &[&str] = &[
28    "SessionStart",
29    "UserPromptSubmit",
30    "PreToolUse",
31    "PostToolUse",
32    "PreCompact",
33    "SessionEnd",
34    "Stop",
35];
36
37fn default_hooks_path() -> Option<PathBuf> {
38    dirs::home_dir().map(|h| h.join(".codex").join("hooks.json"))
39}
40
41/// Write (or merge) edda hooks into the Codex hooks config.
42///
43/// If a config already exists, we merge — respecting other users' hooks and
44/// only adding our own `edda hook codex` handler under each event. Idempotent.
45pub fn install(target: Option<&Path>) -> anyhow::Result<PathBuf> {
46    let path = match target {
47        Some(p) => p.to_path_buf(),
48        None => default_hooks_path()
49            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?,
50    };
51
52    if let Some(parent) = path.parent() {
53        fs::create_dir_all(parent)?;
54    }
55
56    let mut config: serde_json::Value = if path.exists() {
57        let raw = fs::read_to_string(&path)?;
58        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
59    } else {
60        serde_json::json!({})
61    };
62
63    if !config.is_object() {
64        config = serde_json::json!({});
65    }
66    let hooks_obj = config
67        .as_object_mut()
68        .unwrap()
69        .entry("hooks")
70        .or_insert_with(|| serde_json::json!({}));
71    if !hooks_obj.is_object() {
72        *hooks_obj = serde_json::json!({});
73    }
74    let hooks_map = hooks_obj.as_object_mut().unwrap();
75
76    for event in HOOK_EVENTS {
77        let entries = hooks_map
78            .entry(event.to_string())
79            .or_insert_with(|| serde_json::json!([]));
80        if !entries.is_array() {
81            *entries = serde_json::json!([]);
82        }
83        let array = entries.as_array_mut().unwrap();
84
85        let already_present = array.iter().any(|group| {
86            group
87                .get("hooks")
88                .and_then(|h| h.as_array())
89                .map(|hooks| {
90                    hooks.iter().any(|h| {
91                        h.get("command")
92                            .and_then(|c| c.as_str())
93                            .map(|c| c == HOOK_COMMAND)
94                            .unwrap_or(false)
95                    })
96                })
97                .unwrap_or(false)
98        });
99        if !already_present {
100            array.push(serde_json::json!({
101                "hooks": [{
102                    "type": "command",
103                    "command": HOOK_COMMAND
104                }]
105            }));
106        }
107    }
108
109    let pretty = serde_json::to_string_pretty(&config)?;
110    fs::write(&path, pretty)?;
111
112    println!("Installed edda Codex hooks to {}", path.display());
113    println!();
114    println!("Events wired: {}", HOOK_EVENTS.join(", "));
115    println!("Hook command: {HOOK_COMMAND}");
116    Ok(path)
117}
118
119/// Remove edda hooks from the Codex config (preserving other users' entries).
120pub fn uninstall(target: Option<&Path>) -> anyhow::Result<()> {
121    let path = match target {
122        Some(p) => p.to_path_buf(),
123        None => default_hooks_path()
124            .ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?,
125    };
126    if !path.exists() {
127        println!("No hooks config at {}", path.display());
128        return Ok(());
129    }
130
131    let raw = fs::read_to_string(&path)?;
132    let mut config: serde_json::Value = match serde_json::from_str(&raw) {
133        Ok(v) => v,
134        Err(_) => {
135            println!(
136                "Hooks config at {} is not valid JSON; leaving untouched",
137                path.display()
138            );
139            return Ok(());
140        }
141    };
142
143    if let Some(hooks_map) = config.get_mut("hooks").and_then(|h| h.as_object_mut()) {
144        for entries in hooks_map.values_mut() {
145            if let Some(array) = entries.as_array_mut() {
146                array.retain(|group| {
147                    let mut is_edda = false;
148                    if let Some(hooks) = group.get("hooks").and_then(|h| h.as_array()) {
149                        is_edda = hooks.iter().all(|h| {
150                            h.get("command")
151                                .and_then(|c| c.as_str())
152                                .map(|c| c == HOOK_COMMAND)
153                                .unwrap_or(false)
154                        }) && !hooks.is_empty();
155                    }
156                    !is_edda
157                });
158            }
159        }
160        // Drop empty event arrays entirely.
161        hooks_map.retain(|_, v| v.as_array().map(|a| !a.is_empty()).unwrap_or(true));
162    }
163
164    let pretty = serde_json::to_string_pretty(&config)?;
165    fs::write(&path, pretty)?;
166    println!("Removed edda hooks from {}", path.display());
167    Ok(())
168}
169
170/// Print a health report for the Codex bridge.
171pub fn doctor() -> anyhow::Result<()> {
172    let edda_in_path = which_edda();
173    println!(
174        "[{}] edda in PATH: {}",
175        if edda_in_path.is_some() { "OK" } else { "WARN" },
176        edda_in_path.as_deref().unwrap_or("not found")
177    );
178
179    let hooks_path = default_hooks_path();
180    let has_hooks = hooks_path
181        .as_ref()
182        .and_then(|p| fs::read_to_string(p).ok())
183        .map(|raw| raw.contains(HOOK_COMMAND))
184        .unwrap_or(false);
185    println!(
186        "[{}] Codex hooks installed: {}",
187        if has_hooks { "OK" } else { "WARN" },
188        hooks_path
189            .as_ref()
190            .map(|p| p.display().to_string())
191            .unwrap_or_else(|| "unknown".into())
192    );
193
194    let store_root = edda_store::store_root();
195    println!(
196        "[{}] store root: {}",
197        if store_root.exists() { "OK" } else { "WARN" },
198        store_root.display()
199    );
200    Ok(())
201}
202
203fn which_edda() -> Option<String> {
204    let path_var = std::env::var("PATH").unwrap_or_default();
205    let sep = if cfg!(windows) { ';' } else { ':' };
206    let exe = if cfg!(windows) { "edda.exe" } else { "edda" };
207    for dir in path_var.split(sep) {
208        let candidate = Path::new(dir).join(exe);
209        if candidate.exists() {
210            return Some(candidate.to_string_lossy().to_string());
211        }
212    }
213    None
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn install_target(tmp: &tempfile::TempDir) -> PathBuf {
221        tmp.path().join(".codex").join("hooks.json")
222    }
223
224    #[test]
225    fn install_creates_hooks_file_with_all_events() {
226        let tmp = tempfile::tempdir().unwrap();
227        let path = install_target(&tmp);
228        install(Some(&path)).unwrap();
229        let raw = fs::read_to_string(&path).unwrap();
230        let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
231        let hooks = v.get("hooks").unwrap().as_object().unwrap();
232        for event in HOOK_EVENTS {
233            let entries = hooks.get(*event).unwrap().as_array().unwrap();
234            assert!(!entries.is_empty(), "event {event} should have entries");
235            let cmd = entries[0]["hooks"][0]["command"].as_str().unwrap();
236            assert_eq!(cmd, HOOK_COMMAND);
237        }
238    }
239
240    #[test]
241    fn install_is_idempotent() {
242        let tmp = tempfile::tempdir().unwrap();
243        let path = install_target(&tmp);
244        install(Some(&path)).unwrap();
245        install(Some(&path)).unwrap();
246        let raw = fs::read_to_string(&path).unwrap();
247        let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
248        let ptu = v["hooks"]["PreToolUse"].as_array().unwrap();
249        // Only one edda group per event even after two installs.
250        let edda_groups = ptu
251            .iter()
252            .filter(|g| {
253                g["hooks"]
254                    .as_array()
255                    .map(|hs| {
256                        hs.iter()
257                            .any(|h| h["command"].as_str() == Some(HOOK_COMMAND))
258                    })
259                    .unwrap_or(false)
260            })
261            .count();
262        assert_eq!(edda_groups, 1);
263    }
264
265    #[test]
266    fn install_preserves_other_users_hooks() {
267        let tmp = tempfile::tempdir().unwrap();
268        let path = install_target(&tmp);
269        fs::create_dir_all(path.parent().unwrap()).unwrap();
270        fs::write(
271            &path,
272            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other-tool"}]}]}}"#,
273        )
274        .unwrap();
275        install(Some(&path)).unwrap();
276        let raw = fs::read_to_string(&path).unwrap();
277        assert!(raw.contains("other-tool"));
278        assert!(raw.contains(HOOK_COMMAND));
279    }
280
281    #[test]
282    fn uninstall_removes_only_edda_entries() {
283        let tmp = tempfile::tempdir().unwrap();
284        let path = install_target(&tmp);
285        fs::create_dir_all(path.parent().unwrap()).unwrap();
286        fs::write(
287            &path,
288            r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"other-tool"}]}]}}"#,
289        )
290        .unwrap();
291        install(Some(&path)).unwrap();
292        uninstall(Some(&path)).unwrap();
293        let raw = fs::read_to_string(&path).unwrap();
294        assert!(raw.contains("other-tool"));
295        assert!(!raw.contains(HOOK_COMMAND));
296    }
297
298    #[test]
299    fn uninstall_on_missing_file_is_ok() {
300        let tmp = tempfile::tempdir().unwrap();
301        uninstall(Some(&tmp.path().join("nonexistent.json"))).unwrap();
302    }
303}