edda_bridge_codex/
admin.rs1use 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
41pub 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
119pub 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 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
170pub 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 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}