Skip to main content

cove_cli/commands/
init.rs

1// ── Hook installation for Claude Code ──
2//
3// Adds Cove hook entries to ~/.claude/settings.json so Claude Code
4// calls `cove hook user-prompt` and `cove hook stop` on session events.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde_json::Value;
10
11// ── Helpers ──
12
13fn settings_path() -> PathBuf {
14    let home = std::env::var("HOME").unwrap_or_default();
15    PathBuf::from(home).join(".claude").join("settings.json")
16}
17
18fn cove_bin_path() -> String {
19    if let Ok(exe) = std::env::current_exe() {
20        if let Ok(canonical) = fs::canonicalize(exe) {
21            return canonical.to_string_lossy().to_string();
22        }
23    }
24    let home = std::env::var("HOME").unwrap_or_default();
25    format!("{home}/.local/bin/cove")
26}
27
28/// Check if Cove hooks are already installed in settings.json with the correct binary path.
29/// Returns false if hooks are missing OR if the binary path is stale.
30pub fn hooks_installed(path: &Path) -> bool {
31    let content = match fs::read_to_string(path) {
32        Ok(c) => c,
33        Err(_) => return false,
34    };
35    // Must have both the wildcard pre-tool hook AND the session-end hook
36    // (detects old installs missing newer hooks) AND point to the current
37    // binary (detects stale paths after rename/move).
38    let bin = cove_bin_path();
39    let pre_tool_cmd = format!("{bin} hook pre-tool");
40    let session_end_cmd = format!("{bin} hook session-end");
41    content.contains(&pre_tool_cmd) && content.contains(&session_end_cmd)
42}
43
44/// Install Cove hooks into settings.json.
45/// Appends to existing hook arrays — does not overwrite.
46pub fn install_hooks(path: &Path) -> Result<(), String> {
47    install_hooks_with_bin(path, &cove_bin_path())
48}
49
50/// Check if a hook array already contains an entry with the given matcher whose command includes `needle`.
51fn has_hook_entry(arr: &[Value], matcher: &str, needle: &str) -> bool {
52    arr.iter().any(|entry| {
53        let matcher_matches = entry["matcher"]
54            .as_str()
55            .map(|m| m == matcher)
56            .unwrap_or(false);
57        let cmd_matches = entry["hooks"]
58            .as_array()
59            .map(|hooks| {
60                hooks.iter().any(|h| {
61                    h["command"]
62                        .as_str()
63                        .map(|c| c.contains(needle))
64                        .unwrap_or(false)
65                })
66            })
67            .unwrap_or(false);
68        matcher_matches && cmd_matches
69    })
70}
71
72/// Remove any hook entries whose command contains `needle` from an array.
73/// Returns the number of entries removed.
74fn remove_hook_commands(arr: &mut Vec<Value>, needle: &str) -> usize {
75    let before = arr.len();
76    arr.retain(|entry| {
77        let is_cove = entry["hooks"]
78            .as_array()
79            .map(|hooks| {
80                hooks.iter().any(|h| {
81                    h["command"]
82                        .as_str()
83                        .map(|c| c.contains(needle))
84                        .unwrap_or(false)
85                })
86            })
87            .unwrap_or(false);
88        !is_cove
89    });
90    before - arr.len()
91}
92
93/// Check if settings.json has cove hooks pointing to a different binary path.
94pub fn has_stale_hooks(path: &Path, current_bin: &str) -> bool {
95    let content = match fs::read_to_string(path) {
96        Ok(c) => c,
97        Err(_) => return false,
98    };
99    // Has some cove hook commands but NOT with the current binary path
100    content.contains(" hook user-prompt") && !content.contains(current_bin)
101}
102
103fn install_hooks_with_bin(path: &Path, bin: &str) -> Result<(), String> {
104    let mut settings: Value = if path.exists() {
105        let content = fs::read_to_string(path).map_err(|e| format!("read settings: {e}"))?;
106        serde_json::from_str(&content).map_err(|e| format!("parse settings: {e}"))?
107    } else {
108        if let Some(parent) = path.parent() {
109            fs::create_dir_all(parent).map_err(|e| format!("create settings dir: {e}"))?;
110        }
111        serde_json::json!({})
112    };
113
114    let hooks = settings
115        .as_object_mut()
116        .ok_or("settings.json is not an object")?
117        .entry("hooks")
118        .or_insert_with(|| serde_json::json!({}));
119
120    let hooks_obj = hooks.as_object_mut().ok_or("hooks is not an object")?;
121
122    // Each entry: (hook_type, matcher, cove_command)
123    let entries: &[(&str, &str, &str)] = &[
124        ("UserPromptSubmit", "*", "hook user-prompt"),
125        ("Stop", "*", "hook stop"),
126        ("PreToolUse", "*", "hook pre-tool"),
127        ("PostToolUse", "*", "hook post-tool"),
128        ("SessionEnd", "*", "hook session-end"),
129    ];
130
131    // Remove stale cove hooks once per hook_type before adding new ones.
132    // (Doing it per-entry would remove hooks added by earlier entries of the same type.)
133    let mut cleaned_types: Vec<&str> = Vec::new();
134
135    for &(hook_type, matcher, cmd) in entries {
136        let arr = hooks_obj
137            .entry(hook_type)
138            .or_insert_with(|| serde_json::json!([]));
139        let arr = arr
140            .as_array_mut()
141            .ok_or(format!("{hook_type} is not an array"))?;
142
143        if !cleaned_types.contains(&hook_type) {
144            cleaned_types.push(hook_type);
145            remove_hook_commands(arr, "cove hook");
146        }
147
148        let full_cmd = format!("{bin} {cmd}");
149        if !has_hook_entry(arr, matcher, &full_cmd) {
150            arr.push(serde_json::json!({
151                "matcher": matcher,
152                "hooks": [{
153                    "type": "command",
154                    "command": full_cmd,
155                    "async": true,
156                    "timeout": 5
157                }]
158            }));
159        }
160    }
161
162    let output =
163        serde_json::to_string_pretty(&settings).map_err(|e| format!("serialize settings: {e}"))?;
164    fs::write(path, output).map_err(|e| format!("write settings: {e}"))?;
165
166    Ok(())
167}
168
169// ── Public API ──
170
171pub fn run() -> Result<(), String> {
172    let path = settings_path();
173
174    if hooks_installed(&path) {
175        println!("Cove hooks are already installed in ~/.claude/settings.json");
176        return Ok(());
177    }
178
179    let bin = cove_bin_path();
180    let stale = has_stale_hooks(&path, &bin);
181
182    install_hooks(&path)?;
183
184    if stale {
185        println!("Updated Cove hooks in ~/.claude/settings.json");
186        println!("  (old binary path was replaced with {bin})");
187    } else {
188        println!("Installed Cove hooks in ~/.claude/settings.json");
189    }
190    println!("  UserPromptSubmit  → cove hook user-prompt");
191    println!("  Stop              → cove hook stop");
192    println!("  PreToolUse(*)     → cove hook pre-tool");
193    println!("  PostToolUse(*)    → cove hook post-tool");
194    println!("  SessionEnd        → cove hook session-end");
195
196    Ok(())
197}
198
199// ── Tests ──
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_hooks_installed_no_file() {
207        assert!(!hooks_installed(Path::new("/nonexistent/settings.json")));
208    }
209
210    #[test]
211    fn test_hooks_installed_empty() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("settings.json");
214        fs::write(&path, "{}").unwrap();
215
216        assert!(!hooks_installed(&path));
217    }
218
219    #[test]
220    fn test_hooks_installed_only_old_hooks() {
221        let dir = tempfile::tempdir().unwrap();
222        let path = dir.path().join("settings.json");
223        // Old installation — has "cove hook stop" but not "cove hook pre-tool"
224        fs::write(
225            &path,
226            r#"{"hooks":{"Stop":[{"hooks":[{"command":"cove hook stop"}]}]}}"#,
227        )
228        .unwrap();
229
230        assert!(!hooks_installed(&path));
231    }
232
233    #[test]
234    fn test_hooks_installed_present() {
235        let dir = tempfile::tempdir().unwrap();
236        let path = dir.path().join("settings.json");
237        fs::write(&path, "{}").unwrap();
238
239        // Install hooks with the actual binary path, then verify detection
240        install_hooks(&path).unwrap();
241        assert!(hooks_installed(&path));
242    }
243
244    #[test]
245    fn test_install_hooks_fresh() {
246        let dir = tempfile::tempdir().unwrap();
247        let path = dir.path().join("settings.json");
248        fs::write(&path, "{}").unwrap();
249
250        install_hooks_with_bin(&path, "cove").unwrap();
251
252        let content = fs::read_to_string(&path).unwrap();
253        assert!(content.contains("cove hook user-prompt"));
254        assert!(content.contains("cove hook stop"));
255        assert!(content.contains("cove hook pre-tool"));
256        assert!(content.contains("cove hook post-tool"));
257        assert!(content.contains("cove hook session-end"));
258
259        let parsed: Value = serde_json::from_str(&content).unwrap();
260        let hooks = parsed["hooks"].as_object().unwrap();
261        assert_eq!(hooks["UserPromptSubmit"].as_array().unwrap().len(), 1);
262        assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1);
263        assert_eq!(hooks["PreToolUse"].as_array().unwrap().len(), 1);
264        assert_eq!(hooks["PostToolUse"].as_array().unwrap().len(), 1);
265        assert_eq!(hooks["SessionEnd"].as_array().unwrap().len(), 1);
266
267        // PreToolUse and PostToolUse should use wildcard matchers
268        let pre = hooks["PreToolUse"].as_array().unwrap();
269        assert_eq!(pre[0]["matcher"].as_str().unwrap(), "*");
270        let post = hooks["PostToolUse"].as_array().unwrap();
271        assert_eq!(post[0]["matcher"].as_str().unwrap(), "*");
272    }
273
274    #[test]
275    fn test_install_hooks_preserves_existing() {
276        let dir = tempfile::tempdir().unwrap();
277        let path = dir.path().join("settings.json");
278        fs::write(
279            &path,
280            r#"{"hooks":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"afplay sound.aiff"}]}]}}"#,
281        )
282        .unwrap();
283
284        install_hooks_with_bin(&path, "cove").unwrap();
285
286        let content = fs::read_to_string(&path).unwrap();
287        let parsed: Value = serde_json::from_str(&content).unwrap();
288
289        // Stop should have 2 entries: original + Cove
290        let stop = parsed["hooks"]["Stop"].as_array().unwrap();
291        assert_eq!(stop.len(), 2);
292        assert!(
293            stop[0]["hooks"][0]["command"]
294                .as_str()
295                .unwrap()
296                .contains("afplay")
297        );
298        assert!(
299            stop[1]["hooks"][0]["command"]
300                .as_str()
301                .unwrap()
302                .contains("cove hook stop")
303        );
304    }
305
306    #[test]
307    fn test_install_hooks_idempotent() {
308        let dir = tempfile::tempdir().unwrap();
309        let path = dir.path().join("settings.json");
310        fs::write(&path, "{}").unwrap();
311
312        install_hooks_with_bin(&path, "cove").unwrap();
313        install_hooks_with_bin(&path, "cove").unwrap();
314
315        let content = fs::read_to_string(&path).unwrap();
316        let parsed: Value = serde_json::from_str(&content).unwrap();
317        let hooks = parsed["hooks"].as_object().unwrap();
318
319        // Each hook type should still have the correct number of Cove entries
320        assert_eq!(hooks["UserPromptSubmit"].as_array().unwrap().len(), 1);
321        assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1);
322        assert_eq!(hooks["PreToolUse"].as_array().unwrap().len(), 1);
323        assert_eq!(hooks["PostToolUse"].as_array().unwrap().len(), 1);
324        assert_eq!(hooks["SessionEnd"].as_array().unwrap().len(), 1);
325    }
326
327    #[test]
328    fn test_install_hooks_creates_file() {
329        let dir = tempfile::tempdir().unwrap();
330        let path = dir.path().join("subdir").join("settings.json");
331
332        install_hooks_with_bin(&path, "cove").unwrap();
333
334        assert!(path.exists());
335        let content = fs::read_to_string(&path).unwrap();
336        assert!(content.contains("cove hook pre-tool"));
337    }
338
339    #[test]
340    fn test_install_hooks_upgrades_old_install() {
341        let dir = tempfile::tempdir().unwrap();
342        let path = dir.path().join("settings.json");
343        // Simulate old installation with only UserPromptSubmit + Stop
344        fs::write(
345            &path,
346            r#"{"hooks":{"UserPromptSubmit":[{"matcher":"*","hooks":[{"type":"command","command":"cove hook user-prompt","async":true,"timeout":5}]}],"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"cove hook stop","async":true,"timeout":5}]}]}}"#,
347        )
348        .unwrap();
349
350        install_hooks_with_bin(&path, "cove").unwrap();
351
352        let content = fs::read_to_string(&path).unwrap();
353        let parsed: Value = serde_json::from_str(&content).unwrap();
354        let hooks = parsed["hooks"].as_object().unwrap();
355
356        // Old hooks should not be duplicated
357        assert_eq!(hooks["UserPromptSubmit"].as_array().unwrap().len(), 1);
358        assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1);
359        // New wildcard hooks should be added (1 each)
360        assert_eq!(hooks["PreToolUse"].as_array().unwrap().len(), 1);
361        assert_eq!(hooks["PostToolUse"].as_array().unwrap().len(), 1);
362    }
363
364    #[test]
365    fn test_install_hooks_replaces_stale_binary_path() {
366        let dir = tempfile::tempdir().unwrap();
367        let path = dir.path().join("settings.json");
368        // Hooks with old binary path + a non-cove hook that should be preserved
369        fs::write(
370            &path,
371            r#"{"hooks":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"afplay sound.aiff"}]},{"matcher":"*","hooks":[{"type":"command","command":"/old/path/cove hook stop","async":true,"timeout":5}]}],"UserPromptSubmit":[{"matcher":"*","hooks":[{"type":"command","command":"/old/path/cove hook user-prompt","async":true,"timeout":5}]}]}}"#,
372        )
373        .unwrap();
374
375        assert!(has_stale_hooks(&path, "/new/path/cove"));
376
377        install_hooks_with_bin(&path, "/new/path/cove").unwrap();
378
379        let content = fs::read_to_string(&path).unwrap();
380        let parsed: Value = serde_json::from_str(&content).unwrap();
381        let hooks = parsed["hooks"].as_object().unwrap();
382
383        // Stop should have 2 entries: preserved afplay + new cove
384        let stop = hooks["Stop"].as_array().unwrap();
385        assert_eq!(stop.len(), 2);
386        assert!(
387            stop[0]["hooks"][0]["command"]
388                .as_str()
389                .unwrap()
390                .contains("afplay")
391        );
392        assert!(
393            stop[1]["hooks"][0]["command"]
394                .as_str()
395                .unwrap()
396                .contains("/new/path/cove hook stop")
397        );
398
399        // Old path should be gone
400        assert!(!content.contains("/old/path/cove"));
401
402        // UserPromptSubmit should have exactly 1 (replaced)
403        assert_eq!(hooks["UserPromptSubmit"].as_array().unwrap().len(), 1);
404    }
405
406    #[test]
407    fn test_hooks_installed_stale_path() {
408        let dir = tempfile::tempdir().unwrap();
409        let path = dir.path().join("settings.json");
410        // Has cove hooks but with a different binary path
411        fs::write(
412            &path,
413            r#"{"hooks":{"UserPromptSubmit":[{"hooks":[{"command":"/old/path/cove hook user-prompt"}]}],"PreToolUse":[{"hooks":[{"command":"/old/path/cove hook ask"}]}]}}"#,
414        )
415        .unwrap();
416
417        // hooks_installed should return false because binary path doesn't match
418        assert!(!hooks_installed(&path));
419        assert!(has_stale_hooks(&path, &cove_bin_path()));
420    }
421}