edda_bridge_cursor/
admin.rs1use std::fs;
2use std::path::{Path, PathBuf};
3
4const HOOK_COMMAND: &str = "edda hook cursor";
5const HOOK_EVENTS: &[&str] = &[
6 "sessionStart",
7 "beforeSubmitPrompt",
8 "preToolUse",
9 "postToolUse",
10 "preCompact",
11 "sessionEnd",
12 "stop",
13 "subagentStart",
14 "subagentStop",
15];
16
17fn default_hooks_path() -> anyhow::Result<PathBuf> {
18 dirs::home_dir()
19 .map(|home| home.join(".cursor").join("hooks.json"))
20 .ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))
21}
22
23pub fn install(target: Option<&Path>) -> anyhow::Result<PathBuf> {
24 let path = match target {
25 Some(path) => path.to_path_buf(),
26 None => default_hooks_path()?,
27 };
28 if let Some(parent) = path.parent() {
29 fs::create_dir_all(parent)?;
30 }
31
32 let mut config = if path.exists() {
33 serde_json::from_str::<serde_json::Value>(&fs::read_to_string(&path)?)?
34 } else {
35 serde_json::json!({})
36 };
37 let root = config
38 .as_object_mut()
39 .ok_or_else(|| anyhow::anyhow!("Cursor hooks config must be a JSON object"))?;
40 root.entry("version".to_string())
41 .or_insert(serde_json::json!(1));
42 let hooks = root
43 .entry("hooks".to_string())
44 .or_insert_with(|| serde_json::json!({}))
45 .as_object_mut()
46 .ok_or_else(|| anyhow::anyhow!("Cursor hooks field must be a JSON object"))?;
47 for event in HOOK_EVENTS {
48 let entries = hooks
49 .entry((*event).to_string())
50 .or_insert_with(|| serde_json::json!([]))
51 .as_array_mut()
52 .ok_or_else(|| anyhow::anyhow!("Cursor hook event {event} must be an array"))?;
53 let installed = entries.iter().any(|entry| {
54 entry.get("command").and_then(serde_json::Value::as_str) == Some(HOOK_COMMAND)
55 });
56 if !installed {
57 entries.push(serde_json::json!({"command": HOOK_COMMAND}));
58 }
59 }
60 fs::write(&path, serde_json::to_string_pretty(&config)?)?;
61 println!("Installed edda Cursor hooks to {}", path.display());
62 Ok(path)
63}
64
65pub fn uninstall(target: Option<&Path>) -> anyhow::Result<()> {
66 let path = match target {
67 Some(path) => path.to_path_buf(),
68 None => default_hooks_path()?,
69 };
70 if !path.exists() {
71 println!("No Cursor hooks config at {}", path.display());
72 return Ok(());
73 }
74
75 let mut config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path)?)?;
76 if let Some(hooks) = config
77 .get_mut("hooks")
78 .and_then(serde_json::Value::as_object_mut)
79 {
80 for entries in hooks.values_mut() {
81 if let Some(entries) = entries.as_array_mut() {
82 entries.retain(|entry| {
83 entry.get("command").and_then(serde_json::Value::as_str) != Some(HOOK_COMMAND)
84 });
85 }
86 }
87 hooks.retain(|_, entries| entries.as_array().is_none_or(|entries| !entries.is_empty()));
88 }
89
90 fs::write(&path, serde_json::to_string_pretty(&config)?)?;
91 println!("Removed edda Cursor hooks from {}", path.display());
92 Ok(())
93}
94
95#[derive(Debug, PartialEq, Eq)]
96struct HookHealth {
97 configured_events: usize,
98 expected_events: usize,
99}
100
101fn inspect_hooks(path: &Path) -> anyhow::Result<HookHealth> {
102 if !path.exists() {
103 return Ok(HookHealth {
104 configured_events: 0,
105 expected_events: HOOK_EVENTS.len(),
106 });
107 }
108 let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(path)?)?;
109 let hooks = config.get("hooks").and_then(serde_json::Value::as_object);
110 let configured_events = HOOK_EVENTS
111 .iter()
112 .filter(|event| {
113 hooks
114 .and_then(|hooks| hooks.get(**event))
115 .and_then(serde_json::Value::as_array)
116 .is_some_and(|entries| {
117 entries.iter().any(|entry| {
118 entry.get("command").and_then(serde_json::Value::as_str)
119 == Some(HOOK_COMMAND)
120 })
121 })
122 })
123 .count();
124 Ok(HookHealth {
125 configured_events,
126 expected_events: HOOK_EVENTS.len(),
127 })
128}
129
130pub fn doctor() -> anyhow::Result<()> {
131 let edda = which_edda();
132 println!(
133 "[{}] edda in PATH: {}",
134 if edda.is_some() { "OK" } else { "WARN" },
135 edda.as_deref().unwrap_or("not found")
136 );
137
138 let path = default_hooks_path()?;
139 let health = inspect_hooks(&path)?;
140 let hooks_ok = health.configured_events == health.expected_events;
141 println!(
142 "[{}] Cursor hooks installed: {}/{} events ({})",
143 if hooks_ok { "OK" } else { "WARN" },
144 health.configured_events,
145 health.expected_events,
146 path.display()
147 );
148
149 let store_root = edda_store::store_root();
150 let store_writable = store_is_writable(&store_root);
151 println!(
152 "[{}] store writable: {}",
153 if store_writable { "OK" } else { "WARN" },
154 store_root.display()
155 );
156
157 if claude_hook_detected() {
158 println!(
159 "[WARN] Claude edda hooks also detected; disable Cursor third-party hook import to avoid duplicate injection"
160 );
161 }
162 Ok(())
163}
164
165fn which_edda() -> Option<String> {
166 let separator = if cfg!(windows) { ';' } else { ':' };
167 let executable = if cfg!(windows) { "edda.exe" } else { "edda" };
168 std::env::var("PATH")
169 .unwrap_or_default()
170 .split(separator)
171 .map(|directory| Path::new(directory).join(executable))
172 .find(|candidate| candidate.is_file())
173 .map(|candidate| candidate.to_string_lossy().into_owned())
174}
175
176fn store_is_writable(store_root: &Path) -> bool {
177 if fs::create_dir_all(store_root).is_err() {
178 return false;
179 }
180 let probe = store_root.join(format!(".doctor-write-{}", std::process::id()));
181 if fs::write(&probe, b"ok").is_err() {
182 return false;
183 }
184 fs::remove_file(probe).is_ok()
185}
186
187fn claude_hook_detected() -> bool {
188 let Some(home) = dirs::home_dir() else {
189 return false;
190 };
191 [
192 home.join(".claude").join("settings.json"),
193 home.join(".claude").join("settings.local.json"),
194 ]
195 .iter()
196 .filter_map(|path| fs::read_to_string(path).ok())
197 .any(|settings| settings.contains("edda hook claude"))
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn hooks_path(temp: &tempfile::TempDir) -> std::path::PathBuf {
205 temp.path().join(".cursor").join("hooks.json")
206 }
207
208 #[test]
209 fn install_creates_native_cursor_hooks_for_all_events() {
210 let temp = tempfile::tempdir().unwrap();
211 let path = hooks_path(&temp);
212
213 let installed = install(Some(&path)).unwrap();
214
215 assert_eq!(installed, path);
216 let config: serde_json::Value =
217 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
218 assert_eq!(config["version"], 1);
219 for event in HOOK_EVENTS {
220 let entries = config["hooks"][event].as_array().unwrap();
221 assert_eq!(entries.len(), 1, "event {event} should have one hook");
222 assert_eq!(entries[0]["command"], HOOK_COMMAND);
223 }
224 }
225
226 #[test]
227 fn install_preserves_existing_cursor_hooks() {
228 let temp = tempfile::tempdir().unwrap();
229 let path = hooks_path(&temp);
230 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
231 std::fs::write(
232 &path,
233 r#"{"version":1,"hooks":{"preToolUse":[{"command":"other-tool","matcher":"Shell"}]},"custom":true}"#,
234 )
235 .unwrap();
236
237 install(Some(&path)).unwrap();
238
239 let config: serde_json::Value =
240 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
241 assert_eq!(config["custom"], true);
242 let entries = config["hooks"]["preToolUse"].as_array().unwrap();
243 assert!(entries.iter().any(|entry| entry["command"] == "other-tool"));
244 assert!(entries.iter().any(|entry| entry["command"] == HOOK_COMMAND));
245 }
246
247 #[test]
248 fn install_is_idempotent() {
249 let temp = tempfile::tempdir().unwrap();
250 let path = hooks_path(&temp);
251
252 install(Some(&path)).unwrap();
253 install(Some(&path)).unwrap();
254
255 let config: serde_json::Value =
256 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
257 for event in HOOK_EVENTS {
258 let count = config["hooks"][event]
259 .as_array()
260 .unwrap()
261 .iter()
262 .filter(|entry| entry["command"] == HOOK_COMMAND)
263 .count();
264 assert_eq!(count, 1, "event {event} should not duplicate edda");
265 }
266 }
267
268 #[test]
269 fn uninstall_removes_only_edda_entries() {
270 let temp = tempfile::tempdir().unwrap();
271 let path = hooks_path(&temp);
272 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
273 std::fs::write(
274 &path,
275 r#"{"version":1,"hooks":{"sessionStart":[{"command":"other-tool"}]}}"#,
276 )
277 .unwrap();
278 install(Some(&path)).unwrap();
279
280 uninstall(Some(&path)).unwrap();
281
282 let config: serde_json::Value =
283 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
284 let serialized = serde_json::to_string(&config).unwrap();
285 assert!(serialized.contains("other-tool"));
286 assert!(!serialized.contains(HOOK_COMMAND));
287 }
288
289 #[test]
290 fn hook_health_counts_configured_edda_events() {
291 let temp = tempfile::tempdir().unwrap();
292 let path = hooks_path(&temp);
293 install(Some(&path)).unwrap();
294
295 let health = inspect_hooks(&path).unwrap();
296
297 assert_eq!(health.configured_events, HOOK_EVENTS.len());
298 assert_eq!(health.expected_events, HOOK_EVENTS.len());
299 }
300}