Skip to main content

edda_bridge_cursor/
admin.rs

1use 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}