Skip to main content

agent_hooks/
lib.rs

1//! Unified hook adapter for AI CLI tools.
2//!
3//! Provides the [`ToolAdapter`] trait and implementations for registering
4//! agent-hand hook scripts into various AI coding assistants (Claude Code,
5//! Cursor, Copilot/Codex, Windsurf, Kiro, OpenCode, Gemini CLI).
6
7mod detection;
8
9mod claude;
10mod codex;
11mod cursor;
12mod gemini;
13mod kiro;
14mod opencode;
15mod windsurf;
16
17pub use claude::ClaudeAdapter;
18pub use codex::CodexAdapter;
19pub use cursor::CursorAdapter;
20pub use gemini::GeminiAdapter;
21pub use kiro::KiroAdapter;
22pub use opencode::OpenCodeAdapter;
23pub use windsurf::WindsurfAdapter;
24
25use std::path::{Path, PathBuf};
26
27/// Errors from tool adapter operations.
28#[derive(Debug, thiserror::Error)]
29pub enum AdapterError {
30    #[error("IO error: {0}")]
31    Io(#[from] std::io::Error),
32    #[error("JSON error: {0}")]
33    Json(#[from] serde_json::Error),
34    #[error("TOML deserialize error: {0}")]
35    TomlDe(#[from] toml::de::Error),
36    #[error("TOML serialize error: {0}")]
37    TomlSer(#[from] toml::ser::Error),
38    #[error("Config error: {0}")]
39    Config(String),
40    #[error("Home directory not found")]
41    NoHomeDir,
42}
43
44pub type Result<T> = std::result::Result<T, AdapterError>;
45
46/// Status of an AI CLI tool on this system.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ToolStatus {
49    /// Tool is not installed on this system.
50    NotInstalled,
51    /// Tool is installed but hooks are not registered.
52    Detected,
53    /// Tool is installed and hooks are registered.
54    HooksRegistered,
55}
56
57impl ToolStatus {
58    pub fn symbol(&self) -> &'static str {
59        match self {
60            Self::NotInstalled => "\u{2717}", // ✗
61            Self::Detected => "\u{25cf}",     // ●
62            Self::HooksRegistered => "\u{2713}", // ✓
63        }
64    }
65
66    pub fn label(&self) -> &'static str {
67        match self {
68            Self::NotInstalled => "not found",
69            Self::Detected => "detected",
70            Self::HooksRegistered => "registered",
71        }
72    }
73}
74
75/// Adapter for an AI CLI tool's hook system.
76///
77/// Each implementation knows how to detect whether a tool is installed,
78/// check if agent-hand hooks are registered, and register/unregister them.
79pub trait ToolAdapter: Send + Sync {
80    /// Internal identifier (e.g. "claude", "cursor").
81    fn name(&self) -> &str;
82
83    /// Human-readable display name (e.g. "Claude Code", "Cursor").
84    fn display_name(&self) -> &str;
85
86    /// Check if this tool is installed on the system.
87    fn is_installed(&self) -> bool;
88
89    /// Check if agent-hand hooks are registered in this tool's config.
90    fn hooks_registered(&self) -> bool;
91
92    /// Register agent-hand hooks into this tool's config.
93    ///
94    /// `bridge_script` is the absolute path to `hook_event_bridge.sh`.
95    fn register_hooks(&self, bridge_script: &Path) -> Result<()>;
96
97    /// Remove agent-hand hooks from this tool's config.
98    fn unregister_hooks(&self) -> Result<()>;
99
100    /// Path to this tool's configuration file (for display purposes).
101    fn config_path(&self) -> Option<PathBuf>;
102
103    /// Event types this tool supports.
104    fn supported_events(&self) -> &[&str];
105
106    /// Get the current status of this tool.
107    fn status(&self) -> ToolStatus {
108        if !self.is_installed() {
109            ToolStatus::NotInstalled
110        } else if self.hooks_registered() {
111            ToolStatus::HooksRegistered
112        } else {
113            ToolStatus::Detected
114        }
115    }
116}
117
118/// Information about a tool adapter's status, for UI display.
119#[derive(Debug, Clone)]
120pub struct ToolInfo {
121    pub name: String,
122    pub display_name: String,
123    pub status: ToolStatus,
124    pub config_path: Option<PathBuf>,
125}
126
127/// Return all known tool adapters.
128pub fn all_adapters() -> Vec<Box<dyn ToolAdapter>> {
129    vec![
130        Box::new(ClaudeAdapter::new()),
131        Box::new(CursorAdapter::new()),
132        Box::new(CodexAdapter::new()),
133        Box::new(WindsurfAdapter::new()),
134        Box::new(KiroAdapter::new()),
135        Box::new(OpenCodeAdapter::new()),
136        Box::new(GeminiAdapter::new()),
137    ]
138}
139
140/// Detect all tools and return their status info.
141pub fn detect_all() -> Vec<ToolInfo> {
142    all_adapters()
143        .iter()
144        .map(|a| ToolInfo {
145            name: a.name().to_string(),
146            display_name: a.display_name().to_string(),
147            status: a.status(),
148            config_path: a.config_path(),
149        })
150        .collect()
151}
152
153/// Auto-register hooks for all detected (but unregistered) tools.
154///
155/// Returns the names of tools that were successfully registered.
156pub fn auto_register_all(bridge_script: &Path) -> Vec<String> {
157    let mut registered = Vec::new();
158    for adapter in all_adapters() {
159        if adapter.is_installed() && !adapter.hooks_registered() {
160            if adapter.register_hooks(bridge_script).is_ok() {
161                registered.push(adapter.display_name().to_string());
162            }
163        }
164    }
165    registered
166}
167
168/// Get a specific adapter by name.
169pub fn get_adapter(name: &str) -> Option<Box<dyn ToolAdapter>> {
170    all_adapters().into_iter().find(|a| a.name() == name)
171}