Skip to main content

claude_hooks/
lib.rs

1//! Programmatic management of Claude Code hooks
2//!
3//! This crate provides a library API to install, uninstall, and list hooks
4//! in Claude Code's settings.json with atomic safety guarantees and
5//! ownership tracking.
6//!
7//! # Platform Support
8//!
9//! - macOS: Supported
10//! - Linux: Supported
11//! - Windows: Not supported in v0.1 (deferred to v0.2+)
12//!
13//! # Scope Limitations (v0.1)
14//!
15//! - User scope only (`~/.claude/settings.json`)
16//! - No multi-scope support (user/project/local)
17//! - Library-only (no CLI binary)
18//!
19//! # Examples
20//!
21//! ```ignore
22//! use claude_hooks::{HookEvent, HookHandler, install};
23//!
24//! let handler = HookHandler {
25//!     r#type: "command".to_string(),
26//!     command: "/path/to/stop.sh".to_string(),
27//!     timeout: Some(600),
28//!     r#async: None,
29//!     status_message: None,
30//! };
31//!
32//! install(HookEvent::Stop, handler, None, "acd")?;
33//! ```
34
35#![warn(missing_docs)]
36
37mod error;
38mod registry;
39mod settings;
40mod types;
41
42// Re-export all public types
43pub use error::{Error, HookError, RegistryError, Result, SettingsError};
44pub use types::{HookEvent, HookHandler, ListEntry, MatcherGroup, RegistryEntry, RegistryMetadata};
45
46/// Install a hook for the specified event.
47///
48/// # Arguments
49/// * `event` - Hook event (Stop, PreToolUse, etc.)
50/// * `handler` - Hook handler configuration (command, timeout, etc.)
51/// * `matcher` - Optional matcher regex (e.g., "Bash" for PreToolUse hooks)
52/// * `installed_by` - Free-form string identifying installer (e.g., "acd")
53///
54/// # Errors
55/// * `HookError::AlreadyExists` - Hook already exists (in registry or settings)
56/// * `SettingsError` - Failed to read or write settings.json
57/// * `RegistryError` - Failed to read registry (write failure is logged but not returned)
58///
59/// # Example
60/// ```ignore
61/// use claude_hooks::{HookEvent, HookHandler, install};
62///
63/// let handler = HookHandler {
64///     r#type: "command".to_string(),
65///     command: "/path/to/stop.sh".to_string(),
66///     timeout: Some(600),
67///     r#async: None,
68///     status_message: None,
69/// };
70///
71/// install(HookEvent::Stop, handler, None, "acd")?;
72/// ```
73pub fn install(
74    event: HookEvent,
75    handler: HookHandler,
76    matcher: Option<String>,
77    installed_by: &str,
78) -> Result<()> {
79    use chrono::Local;
80
81    // 1. Read registry
82    let registry_entries = registry::read_registry()?;
83
84    // 2. Check if hook exists in registry
85    if registry_entries
86        .iter()
87        .any(|e| e.matches(event, &handler.command))
88    {
89        return Err(HookError::AlreadyExists {
90            event,
91            command: handler.command.clone(),
92        }
93        .into());
94    }
95
96    // 3. Read settings
97    let settings_value = settings::read_settings()?;
98
99    // 4. Check if hook exists in settings.json using list_hooks
100    let existing_hooks = settings::list_hooks(&settings_value);
101    for (hook_event, _, hook_handler) in &existing_hooks {
102        if *hook_event == event && hook_handler.command == handler.command {
103            return Err(HookError::AlreadyExists {
104                event,
105                command: handler.command.clone(),
106            }
107            .into());
108        }
109    }
110
111    // 5. Add hook to settings
112    let updated_settings = settings::add_hook(settings_value, event, handler.clone(), matcher.clone());
113
114    // 6. Write settings atomically
115    settings::write_settings_atomic(updated_settings)?;
116
117    // 7. Create registry entry
118    let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
119    let entry = RegistryEntry {
120        event,
121        matcher,
122        r#type: handler.r#type.clone(),
123        command: handler.command.clone(),
124        timeout: handler.timeout,
125        r#async: handler.r#async,
126        scope: "user".to_string(),
127        enabled: true,
128        added_at: timestamp,
129        installed_by: installed_by.to_string(),
130        description: None,
131        reason: None,
132        optional: None,
133    };
134
135    // 8. Add entry to registry
136    let updated_registry = registry::add_entry(registry_entries, entry);
137
138    // 9. Write registry (log warning on failure, don't fail operation)
139    if let Err(e) = registry::write_registry(updated_registry) {
140        log::warn!("Failed to write registry after successful settings write: {}", e);
141        log::warn!(
142            "Hook installed but not tracked. Remove manually from settings.json if needed."
143        );
144    }
145
146    Ok(())
147}
148
149/// Uninstall a hook for the specified event and command.
150///
151/// Only removes hooks installed via this crate (matched via registry).
152///
153/// # Arguments
154/// * `event` - Hook event
155/// * `command` - Exact command string
156///
157/// # Errors
158/// * `HookError::NotManaged` - Hook not found in registry (not managed by us)
159/// * `SettingsError` - Failed to read or write settings.json
160/// * `RegistryError` - Failed to read registry (write failure is logged but not returned)
161///
162/// # Example
163/// ```ignore
164/// use claude_hooks::{HookEvent, uninstall};
165///
166/// uninstall(HookEvent::Stop, "/path/to/stop.sh")?;
167/// ```
168pub fn uninstall(event: HookEvent, command: &str) -> Result<()> {
169    // 1. Read registry
170    let registry_entries = registry::read_registry()?;
171
172    // 2. Check if hook exists in registry
173    if !registry_entries.iter().any(|e| e.matches(event, command)) {
174        return Err(HookError::NotManaged {
175            event,
176            command: command.to_string(),
177        }
178        .into());
179    }
180
181    // 3. Read settings
182    let settings_value = settings::read_settings()?;
183
184    // 4. Check if hook exists in settings.json using list_hooks
185    let existing_hooks = settings::list_hooks(&settings_value);
186    let hook_in_settings = existing_hooks
187        .iter()
188        .any(|(e, _, h)| *e == event && h.command == command);
189
190    if !hook_in_settings {
191        log::warn!(
192            "Hook in registry but not in settings.json: {:?} - {}",
193            event,
194            command
195        );
196        log::warn!("Removing from registry anyway (user may have manually deleted)");
197    }
198
199    // 5. Remove hook from settings (if exists)
200    let updated_settings = settings::remove_hook(settings_value, event, command);
201
202    // 6. Write settings atomically
203    settings::write_settings_atomic(updated_settings)?;
204
205    // 7. Remove entry from registry
206    let updated_registry = registry::remove_entry(registry_entries, event, command);
207
208    // 8. Write registry (log warning on failure, don't fail operation)
209    if let Err(e) = registry::write_registry(updated_registry) {
210        log::warn!("Failed to write registry after successful settings write: {}", e);
211        log::warn!("Hook removed but registry dirty. May show as managed until registry fixed.");
212    }
213
214    Ok(())
215}
216
217/// List all hooks from settings.json with management status.
218///
219/// Returns all hooks (managed and unmanaged). Managed hooks include metadata.
220///
221/// # Errors
222/// * `SettingsError` - Failed to read or parse settings.json
223/// * `RegistryError` - Failed to read or parse registry
224///
225/// # Example
226/// ```ignore
227/// use claude_hooks::list;
228///
229/// for entry in list()? {
230///     if entry.managed {
231///         println!("Managed: {:?} - {}", entry.event, entry.handler.command);
232///     } else {
233///         println!("Unmanaged: {:?} - {}", entry.event, entry.handler.command);
234///     }
235/// }
236/// ```
237pub fn list() -> Result<Vec<ListEntry>> {
238    // 1. Read registry
239    let registry_entries = registry::read_registry()?;
240
241    // 2. Read settings
242    let settings_value = settings::read_settings()?;
243
244    // 3. Parse hooks from settings.json using list_hooks
245    let hooks = settings::list_hooks(&settings_value);
246
247    let mut results = Vec::new();
248
249    for (event, _matcher, handler) in hooks {
250        // Check if hook exists in registry
251        let registry_entry = registry_entries
252            .iter()
253            .find(|e| e.matches(event, &handler.command));
254
255        let (managed, metadata) = if let Some(entry) = registry_entry {
256            let metadata = RegistryMetadata {
257                added_at: entry.added_at.clone(),
258                installed_by: entry.installed_by.clone(),
259                description: entry.description.clone(),
260                reason: entry.reason.clone(),
261                optional: entry.optional,
262            };
263            (true, Some(metadata))
264        } else {
265            (false, None)
266        };
267
268        results.push(ListEntry {
269            event,
270            handler,
271            managed,
272            metadata,
273        });
274    }
275
276    Ok(results)
277}
278
279#[cfg(test)]
280mod integration_tests {
281    use super::*;
282    use serial_test::serial;
283    use std::env;
284    use std::fs;
285    use tempfile::tempdir;
286
287    /// Setup test environment with temp directory
288    fn setup_test_env() -> tempfile::TempDir {
289        let dir = tempdir().expect("Failed to create temp directory");
290
291        // Override HOME to point to temp directory
292        env::set_var("HOME", dir.path());
293
294        // Create .claude directory
295        let claude_dir = dir.path().join(".claude");
296        fs::create_dir_all(&claude_dir).expect("Failed to create .claude directory");
297
298        // Create empty settings.json with hooks object (not array)
299        let settings = serde_json::json!({
300            "hooks": {},
301            "cleanupPeriodDays": 7
302        });
303        let settings_path = claude_dir.join("settings.json");
304        fs::write(
305            &settings_path,
306            serde_json::to_string_pretty(&settings).expect("Failed to serialize settings"),
307        )
308        .expect("Failed to write settings.json");
309
310        dir
311    }
312
313    #[test]
314    #[serial(home)]
315    fn test_install_list_uninstall_workflow() {
316        let _dir = setup_test_env();
317
318        // Install hook
319        let handler = HookHandler {
320            r#type: "command".to_string(),
321            command: "/path/to/stop.sh".to_string(),
322            timeout: Some(600),
323            r#async: None,
324            status_message: None,
325        };
326
327        let result = install(HookEvent::Stop, handler.clone(), None, "test");
328        assert!(result.is_ok(), "Install should succeed: {:?}", result.err());
329
330        // List hooks - should show as managed
331        let entries = list().expect("List should succeed");
332        assert_eq!(entries.len(), 1, "Should have exactly 1 hook");
333        assert!(entries[0].managed, "Hook should be managed");
334        assert_eq!(entries[0].event, HookEvent::Stop);
335        assert_eq!(entries[0].handler.command, "/path/to/stop.sh");
336        assert!(
337            entries[0].metadata.is_some(),
338            "Managed hook should have metadata"
339        );
340
341        // Uninstall hook
342        let result = uninstall(HookEvent::Stop, "/path/to/stop.sh");
343        assert!(
344            result.is_ok(),
345            "Uninstall should succeed: {:?}",
346            result.err()
347        );
348
349        // List hooks - should be empty
350        let entries = list().expect("List should succeed");
351        assert_eq!(entries.len(), 0, "Should have no hooks after uninstall");
352    }
353
354    #[test]
355    #[serial(home)]
356    fn test_install_duplicate_fails() {
357        let _dir = setup_test_env();
358
359        let handler = HookHandler {
360            r#type: "command".to_string(),
361            command: "/path/to/stop.sh".to_string(),
362            timeout: Some(600),
363            r#async: None,
364            status_message: None,
365        };
366
367        // First install should succeed
368        let result = install(HookEvent::Stop, handler.clone(), None, "test");
369        assert!(result.is_ok(), "First install should succeed");
370
371        // Second install should fail with AlreadyExists
372        let result = install(HookEvent::Stop, handler, None, "test");
373        assert!(result.is_err(), "Second install should fail");
374
375        match result.unwrap_err() {
376            Error::Hook(HookError::AlreadyExists { event, command }) => {
377                assert_eq!(event, HookEvent::Stop);
378                assert_eq!(command, "/path/to/stop.sh");
379            }
380            e => panic!("Expected AlreadyExists error, got: {:?}", e),
381        }
382    }
383
384    #[test]
385    #[serial(home)]
386    fn test_uninstall_unmanaged_fails() {
387        let _dir = setup_test_env();
388
389        // Try to uninstall hook that doesn't exist
390        let result = uninstall(HookEvent::Stop, "/unmanaged/hook.sh");
391        assert!(result.is_err(), "Uninstall of unmanaged hook should fail");
392
393        match result.unwrap_err() {
394            Error::Hook(HookError::NotManaged { event, command }) => {
395                assert_eq!(event, HookEvent::Stop);
396                assert_eq!(command, "/unmanaged/hook.sh");
397            }
398            e => panic!("Expected NotManaged error, got: {:?}", e),
399        }
400    }
401
402    #[test]
403    #[serial(home)]
404    fn test_list_shows_unmanaged_hooks() {
405        let _dir = setup_test_env();
406
407        // Manually add hook to settings.json (not via install)
408        let settings = settings::read_settings().expect("Failed to read settings");
409        let handler = HookHandler {
410            r#type: "command".to_string(),
411            command: "/unmanaged/hook.sh".to_string(),
412            timeout: None,
413            r#async: None,
414            status_message: None,
415        };
416        let updated = settings::add_hook(settings, HookEvent::SessionStart, handler, None);
417        settings::write_settings_atomic(updated).expect("Failed to write settings");
418
419        // List should show hook as unmanaged
420        let entries = list().expect("List should succeed");
421        assert_eq!(entries.len(), 1, "Should have 1 hook");
422        assert!(!entries[0].managed, "Hook should be unmanaged");
423        assert_eq!(entries[0].event, HookEvent::SessionStart);
424        assert!(
425            entries[0].metadata.is_none(),
426            "Unmanaged hook should not have metadata"
427        );
428    }
429
430    #[test]
431    #[serial(home)]
432    fn test_install_multiple_hooks() {
433        let _dir = setup_test_env();
434
435        // Install Stop hook
436        let stop_handler = HookHandler {
437            r#type: "command".to_string(),
438            command: "/path/to/stop.sh".to_string(),
439            timeout: Some(600),
440            r#async: None,
441            status_message: None,
442        };
443        install(HookEvent::Stop, stop_handler, None, "test").expect("Stop install should succeed");
444
445        // Install SessionStart hook
446        let start_handler = HookHandler {
447            r#type: "command".to_string(),
448            command: "/path/to/start.sh".to_string(),
449            timeout: Some(300),
450            r#async: None,
451            status_message: None,
452        };
453        install(HookEvent::SessionStart, start_handler, None, "test").expect("SessionStart install should succeed");
454
455        // List should show both hooks
456        let entries = list().expect("List should succeed");
457        assert_eq!(entries.len(), 2, "Should have 2 hooks");
458        assert!(entries.iter().all(|e| e.managed), "All hooks should be managed");
459
460        // Verify both events are present
461        let events: Vec<HookEvent> = entries.iter().map(|e| e.event).collect();
462        assert!(events.contains(&HookEvent::Stop));
463        assert!(events.contains(&HookEvent::SessionStart));
464    }
465
466    #[test]
467    #[serial(home)]
468    fn test_uninstall_preserves_other_hooks() {
469        let _dir = setup_test_env();
470
471        // Install two hooks
472        let stop_handler = HookHandler {
473            r#type: "command".to_string(),
474            command: "/path/to/stop.sh".to_string(),
475            timeout: Some(600),
476            r#async: None,
477            status_message: None,
478        };
479        install(HookEvent::Stop, stop_handler, None, "test").expect("Stop install should succeed");
480
481        let start_handler = HookHandler {
482            r#type: "command".to_string(),
483            command: "/path/to/start.sh".to_string(),
484            timeout: Some(300),
485            r#async: None,
486            status_message: None,
487        };
488        install(HookEvent::SessionStart, start_handler, None, "test").expect("SessionStart install should succeed");
489
490        // Uninstall Stop hook
491        uninstall(HookEvent::Stop, "/path/to/stop.sh").expect("Uninstall should succeed");
492
493        // List should show only SessionStart hook
494        let entries = list().expect("List should succeed");
495        assert_eq!(entries.len(), 1, "Should have 1 hook remaining");
496        assert_eq!(entries[0].event, HookEvent::SessionStart);
497        assert_eq!(entries[0].handler.command, "/path/to/start.sh");
498    }
499
500    #[test]
501    #[serial(home)]
502    fn test_install_with_optional_fields() {
503        let _dir = setup_test_env();
504
505        // Install hook with all optional fields
506        let handler = HookHandler {
507            r#type: "command".to_string(),
508            command: "/path/to/async.sh".to_string(),
509            timeout: Some(900),
510            r#async: Some(true),
511            status_message: Some("Running...".to_string()),
512        };
513
514        install(HookEvent::PostToolUse, handler, None, "test").expect("Install should succeed");
515
516        // List and verify optional fields are preserved
517        let entries = list().expect("List should succeed");
518        assert_eq!(entries.len(), 1);
519        assert_eq!(entries[0].handler.timeout, Some(900));
520        assert_eq!(entries[0].handler.r#async, Some(true));
521    }
522
523    #[test]
524    #[serial(home)]
525    fn test_metadata_is_preserved() {
526        let _dir = setup_test_env();
527
528        // Install hook
529        let handler = HookHandler {
530            r#type: "command".to_string(),
531            command: "/path/to/test.sh".to_string(),
532            timeout: None,
533            r#async: None,
534            status_message: None,
535        };
536
537        install(HookEvent::Stop, handler, None, "test-installer").expect("Install should succeed");
538
539        // List and verify metadata
540        let entries = list().expect("List should succeed");
541        assert_eq!(entries.len(), 1);
542
543        let metadata = entries[0].metadata.as_ref().expect("Should have metadata");
544        assert_eq!(metadata.installed_by, "test-installer");
545        assert!(!metadata.added_at.is_empty(), "Should have timestamp");
546    }
547
548    #[test]
549    #[serial(home)]
550    fn test_hook_in_registry_but_not_settings() {
551        let _dir = setup_test_env();
552
553        // Install hook
554        let handler = HookHandler {
555            r#type: "command".to_string(),
556            command: "/path/to/test.sh".to_string(),
557            timeout: None,
558            r#async: None,
559            status_message: None,
560        };
561
562        install(HookEvent::Stop, handler, None, "test").expect("Install should succeed");
563
564        // Manually remove from settings.json (simulate user deletion)
565        let settings = settings::read_settings().expect("Failed to read settings");
566        let updated = settings::remove_hook(settings, HookEvent::Stop, "/path/to/test.sh");
567        settings::write_settings_atomic(updated).expect("Failed to write settings");
568
569        // Uninstall should still succeed (cleans up registry)
570        let result = uninstall(HookEvent::Stop, "/path/to/test.sh");
571        assert!(
572            result.is_ok(),
573            "Uninstall should succeed even if hook not in settings"
574        );
575
576        // List should be empty
577        let entries = list().expect("List should succeed");
578        assert_eq!(entries.len(), 0, "Should have no hooks");
579    }
580
581    #[test]
582    #[serial(home)]
583    fn test_different_commands_same_event() {
584        let _dir = setup_test_env();
585
586        // Install two hooks for same event but different commands
587        let handler1 = HookHandler {
588            r#type: "command".to_string(),
589            command: "/path/to/stop1.sh".to_string(),
590            timeout: None,
591            r#async: None,
592            status_message: None,
593        };
594        install(HookEvent::Stop, handler1, None, "test").expect("First install should succeed");
595
596        let handler2 = HookHandler {
597            r#type: "command".to_string(),
598            command: "/path/to/stop2.sh".to_string(),
599            timeout: None,
600            r#async: None,
601            status_message: None,
602        };
603        install(HookEvent::Stop, handler2, None, "test").expect("Second install should succeed");
604
605        // List should show both hooks
606        let entries = list().expect("List should succeed");
607        assert_eq!(entries.len(), 2, "Should have 2 hooks");
608
609        // Both should be for Stop event
610        assert!(entries.iter().all(|e| e.event == HookEvent::Stop));
611
612        // Commands should be different
613        let commands: Vec<&str> = entries.iter().map(|e| e.handler.command.as_str()).collect();
614        assert!(commands.contains(&"/path/to/stop1.sh"));
615        assert!(commands.contains(&"/path/to/stop2.sh"));
616
617        // Uninstall first hook
618        uninstall(HookEvent::Stop, "/path/to/stop1.sh").expect("Uninstall should succeed");
619
620        // List should show only second hook
621        let entries = list().expect("List should succeed");
622        assert_eq!(entries.len(), 1, "Should have 1 hook remaining");
623        assert_eq!(entries[0].handler.command, "/path/to/stop2.sh");
624    }
625
626    #[test]
627    #[serial(home)]
628    fn test_install_with_matcher() {
629        let _dir = setup_test_env();
630
631        // Install PreToolUse hook with Bash matcher
632        let handler = HookHandler {
633            r#type: "command".to_string(),
634            command: "/path/to/pre-bash.sh".to_string(),
635            timeout: Some(10),
636            r#async: None,
637            status_message: None,
638        };
639
640        install(HookEvent::PreToolUse, handler, Some("Bash".to_string()), "test")
641            .expect("Install should succeed");
642
643        // List should show the hook
644        let entries = list().expect("List should succeed");
645        assert_eq!(entries.len(), 1);
646        assert_eq!(entries[0].event, HookEvent::PreToolUse);
647        assert_eq!(entries[0].handler.command, "/path/to/pre-bash.sh");
648    }
649}