Skip to main content

agent_hooks/
claude.rs

1//! Claude Code adapter.
2//!
3//! Registers hooks in `~/.claude/settings.json` under the `hooks` key.
4//! Claude Code uses PascalCase event names: Stop, UserPromptSubmit, Notification, etc.
5
6use std::path::{Path, PathBuf};
7
8use serde_json::{json, Value};
9
10use crate::detection;
11use crate::{AdapterError, Result, ToolAdapter};
12
13const SETTINGS_REL: &str = ".claude/settings.json";
14
15/// Claude Code hook event types.
16const EVENTS: &[&str] = &[
17    "Stop",
18    "Notification",
19    "UserPromptSubmit",
20    "SubagentStart",
21    "PreCompact",
22];
23
24pub struct ClaudeAdapter;
25
26impl ClaudeAdapter {
27    pub fn new() -> Self {
28        Self
29    }
30
31    fn settings_path(&self) -> Option<PathBuf> {
32        detection::home_path(SETTINGS_REL)
33    }
34
35    /// Register a hook command for a single event type.
36    fn register_event(&self, settings_path: &Path, bridge_cmd: &str, event: &str) -> Result<()> {
37        detection::ensure_parent_dir(settings_path)?;
38
39        let mut root: Value = if settings_path.exists() {
40            let content = std::fs::read_to_string(settings_path)?;
41            serde_json::from_str(&content)?
42        } else {
43            json!({})
44        };
45
46        let obj = root
47            .as_object_mut()
48            .ok_or_else(|| AdapterError::Config("Invalid root JSON".into()))?;
49
50        let hooks = obj.entry("hooks").or_insert_with(|| json!({}));
51        if !hooks.is_object() {
52            *hooks = json!({});
53        }
54        let hooks_obj = hooks
55            .as_object_mut()
56            .ok_or_else(|| AdapterError::Config("Invalid hooks format".into()))?;
57
58        let event_hooks = hooks_obj.entry(event).or_insert_with(|| json!([]));
59        if !event_hooks.is_array() {
60            *event_hooks = json!([]);
61        }
62        let arr = event_hooks
63            .as_array_mut()
64            .ok_or_else(|| AdapterError::Config("Invalid event hooks format".into()))?;
65
66        // Check if already registered
67        let already = arr.iter().any(|item| {
68            item.get("hooks")
69                .and_then(|h| h.as_array())
70                .map(|a| {
71                    a.iter().any(|h| {
72                        h.get("command")
73                            .and_then(|c| c.as_str())
74                            .is_some_and(|c| c == bridge_cmd)
75                    })
76                })
77                .unwrap_or(false)
78        });
79
80        if !already {
81            arr.push(json!({
82                "matcher": "",
83                "hooks": [{
84                    "type": "command",
85                    "command": bridge_cmd
86                }]
87            }));
88
89            detection::backup_file(settings_path);
90            let output = serde_json::to_string_pretty(&root)?;
91            std::fs::write(settings_path, output)?;
92        }
93
94        Ok(())
95    }
96
97    /// Remove agent-hand hook entries from a single event type.
98    fn unregister_event(
99        &self,
100        settings_path: &Path,
101        bridge_cmd: &str,
102        event: &str,
103    ) -> Result<()> {
104        if !settings_path.exists() {
105            return Ok(());
106        }
107
108        let content = std::fs::read_to_string(settings_path)?;
109        let mut root: Value = serde_json::from_str(&content)?;
110
111        let modified = if let Some(hooks) = root
112            .as_object_mut()
113            .and_then(|o| o.get_mut("hooks"))
114            .and_then(|h| h.as_object_mut())
115        {
116            if let Some(arr) = hooks.get_mut(event).and_then(|v| v.as_array_mut()) {
117                let before = arr.len();
118                arr.retain(|item| {
119                    !item
120                        .get("hooks")
121                        .and_then(|h| h.as_array())
122                        .map(|a| {
123                            a.iter().any(|h| {
124                                h.get("command")
125                                    .and_then(|c| c.as_str())
126                                    .is_some_and(|c| c == bridge_cmd)
127                            })
128                        })
129                        .unwrap_or(false)
130                });
131                arr.len() != before
132            } else {
133                false
134            }
135        } else {
136            false
137        };
138
139        if modified {
140            detection::backup_file(settings_path);
141            let output = serde_json::to_string_pretty(&root)?;
142            std::fs::write(settings_path, output)?;
143        }
144
145        Ok(())
146    }
147
148    /// Check if a specific bridge command is registered for any event.
149    fn has_bridge_hook(&self, settings_path: &Path) -> bool {
150        let Ok(content) = std::fs::read_to_string(settings_path) else {
151            return false;
152        };
153        let Ok(root) = serde_json::from_str::<Value>(&content) else {
154            return false;
155        };
156
157        let Some(hooks) = root.get("hooks").and_then(|h| h.as_object()) else {
158            return false;
159        };
160
161        // Check if any event has a hook command containing "hook_event_bridge"
162        for event in EVENTS {
163            if let Some(arr) = hooks.get(*event).and_then(|v| v.as_array()) {
164                for item in arr {
165                    if let Some(inner) = item.get("hooks").and_then(|h| h.as_array()) {
166                        for h in inner {
167                            if let Some(cmd) = h.get("command").and_then(|c| c.as_str()) {
168                                if cmd.contains("hook_event_bridge") {
169                                    return true;
170                                }
171                            }
172                        }
173                    }
174                }
175            }
176        }
177
178        false
179    }
180}
181
182impl ToolAdapter for ClaudeAdapter {
183    fn name(&self) -> &str {
184        "claude"
185    }
186
187    fn display_name(&self) -> &str {
188        "Claude Code"
189    }
190
191    fn is_installed(&self) -> bool {
192        detection::home_dir_exists(".claude") || detection::command_exists("claude")
193    }
194
195    fn hooks_registered(&self) -> bool {
196        self.settings_path()
197            .map(|p| self.has_bridge_hook(&p))
198            .unwrap_or(false)
199    }
200
201    fn register_hooks(&self, bridge_script: &Path) -> Result<()> {
202        let settings = self
203            .settings_path()
204            .ok_or(AdapterError::NoHomeDir)?;
205        let cmd = bridge_script.to_string_lossy().to_string();
206
207        for event in EVENTS {
208            self.register_event(&settings, &cmd, event)?;
209        }
210        Ok(())
211    }
212
213    fn unregister_hooks(&self) -> Result<()> {
214        let settings = self
215            .settings_path()
216            .ok_or(AdapterError::NoHomeDir)?;
217
218        // Find the bridge command to remove
219        let cmd = detection::home_path(".agent-hand/hooks/hook_event_bridge.sh")
220            .map(|p| p.to_string_lossy().to_string())
221            .unwrap_or_default();
222
223        if cmd.is_empty() {
224            return Ok(());
225        }
226
227        for event in EVENTS {
228            self.unregister_event(&settings, &cmd, event)?;
229        }
230        Ok(())
231    }
232
233    fn config_path(&self) -> Option<PathBuf> {
234        self.settings_path()
235    }
236
237    fn supported_events(&self) -> &[&str] {
238        EVENTS
239    }
240}