1use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde_json::Value;
10
11fn 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
28pub 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 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
44pub fn install_hooks(path: &Path) -> Result<(), String> {
47 install_hooks_with_bin(path, &cove_bin_path())
48}
49
50fn 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
72fn 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
93pub 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 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 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 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
169pub 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#[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 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(&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 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 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 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 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 assert_eq!(hooks["UserPromptSubmit"].as_array().unwrap().len(), 1);
358 assert_eq!(hooks["Stop"].as_array().unwrap().len(), 1);
359 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 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 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 assert!(!content.contains("/old/path/cove"));
401
402 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 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 assert!(!hooks_installed(&path));
419 assert!(has_stale_hooks(&path, &cove_bin_path()));
420 }
421}