openlatch_client/hooks/mod.rs
1/// Agent hook detection and installation.
2///
3/// Public API:
4/// - [`detect_agent`] — detect which (if any) AI agent is installed
5/// - [`install_hooks`] — write OpenLatch HTTP hook entries into the agent's config
6/// - [`remove_hooks`] — remove all OpenLatch-owned hook entries
7///
8/// # Module structure
9///
10/// - `claude_code` — path detection and hook entry building for Claude Code
11/// - `jsonc` — JSONC-preserving string surgery on `settings.json`
12pub mod claude_code;
13pub mod jsonc;
14
15use std::path::PathBuf;
16
17use crate::error::{OlError, ERR_HOOK_AGENT_NOT_FOUND, ERR_HOOK_CONFLICT, ERR_HOOK_WRITE_FAILED};
18
19// ---------------------------------------------------------------------------
20// Public types
21// ---------------------------------------------------------------------------
22
23/// A detected AI agent with all paths needed for hook installation.
24#[derive(Debug, Clone)]
25pub enum DetectedAgent {
26 /// Claude Code was found at the given directory.
27 ClaudeCode {
28 /// Path to the Claude Code config directory (e.g. `~/.claude/`).
29 claude_dir: PathBuf,
30 /// Path to `settings.json` inside the config directory.
31 settings_path: PathBuf,
32 },
33}
34
35/// The result of a successful [`install_hooks`] call.
36#[derive(Debug)]
37pub struct HookInstallResult {
38 /// Per-hook-event status showing whether the entry was added or replaced.
39 pub entries: Vec<HookEntryStatus>,
40}
41
42/// Status of a single hook event entry after installation.
43#[derive(Debug)]
44pub struct HookEntryStatus {
45 /// The hook event type (e.g. `"PreToolUse"`, `"UserPromptSubmit"`, `"Stop"`).
46 pub event_type: String,
47 /// Whether the entry was newly added or replaced an existing OpenLatch entry.
48 pub action: HookAction,
49}
50
51/// Whether a hook entry was newly created or replaced an existing one.
52#[derive(Debug, Clone, PartialEq)]
53pub enum HookAction {
54 /// A new hook entry was appended to the array.
55 Added,
56 /// An existing OpenLatch-owned entry was replaced (idempotent re-install).
57 Replaced,
58}
59
60// ---------------------------------------------------------------------------
61// Public API
62// ---------------------------------------------------------------------------
63
64/// Detect which AI agent (if any) is installed on this machine.
65///
66/// Currently supports Claude Code only. Additional agents will be added in M4.
67///
68/// # Errors
69///
70/// Returns `OL-1400` if no supported AI agent is detected.
71pub fn detect_agent() -> Result<DetectedAgent, OlError> {
72 if let Some(claude_dir) = claude_code::detect() {
73 let settings_path = claude_code::settings_json_path(&claude_dir);
74 return Ok(DetectedAgent::ClaudeCode {
75 claude_dir,
76 settings_path,
77 });
78 }
79
80 Err(
81 OlError::new(ERR_HOOK_AGENT_NOT_FOUND, "No AI agents detected")
82 .with_suggestion("Install Claude Code (https://claude.ai/download) and try again.")
83 .with_docs("https://docs.openlatch.ai/errors/OL-1400"),
84 )
85}
86
87/// Install OpenLatch HTTP hook entries into the detected agent's config.
88///
89/// The three hook events written are `PreToolUse`, `UserPromptSubmit`, and `Stop`.
90/// Re-running this function is idempotent: existing OpenLatch entries are replaced
91/// rather than duplicated. Hooks from other tools are never touched.
92///
93/// # Arguments
94///
95/// - `agent`: the agent returned by [`detect_agent`]
96/// - `port`: the daemon's listen port (written into each hook URL)
97/// - `token`: the bearer token value — the *env var name* `OPENLATCH_TOKEN` is
98/// written into settings.json; the actual token is stored separately
99///
100/// # Errors
101///
102/// - `OL-1401` if settings.json cannot be read or written.
103/// - `OL-1402` if settings.json contains malformed JSONC.
104pub fn install_hooks(
105 agent: &DetectedAgent,
106 port: u16,
107 token: &str,
108) -> Result<HookInstallResult, OlError> {
109 match agent {
110 DetectedAgent::ClaudeCode { settings_path, .. } => {
111 // Build all three hook entries.
112 const TOKEN_ENV_VAR: &str = "OPENLATCH_TOKEN";
113 const EVENT_TYPES: [&str; 3] = ["PreToolUse", "UserPromptSubmit", "Stop"];
114
115 let entries: Vec<(String, serde_json::Value)> = EVENT_TYPES
116 .iter()
117 .map(|&et| {
118 (
119 et.to_string(),
120 claude_code::build_hook_entry(et, port, TOKEN_ENV_VAR),
121 )
122 })
123 .collect();
124
125 // Read (or create) settings.json.
126 let raw_jsonc = jsonc::read_or_create_settings(settings_path)?;
127
128 // OL-1403: Warn (non-blocking) if non-OpenLatch hooks are present
129 if raw_jsonc.contains("\"hooks\"") && !raw_jsonc.contains("\"_openlatch\"") {
130 // Existing hooks from other tools — coexistence is fine, just log for awareness
131 tracing::info!(
132 code = ERR_HOOK_CONFLICT,
133 "Existing non-OpenLatch hooks detected in settings.json; coexistence is supported"
134 );
135 }
136
137 // Perform JSONC surgery: insert hooks + set OPENLATCH_TOKEN env var.
138 let (modified, actions) = jsonc::insert_hook_entries(&raw_jsonc, &entries)?;
139 let modified = jsonc::set_env_var(&modified, TOKEN_ENV_VAR, token)?;
140
141 // Write the modified JSONC back.
142 std::fs::write(settings_path, &modified).map_err(|e| {
143 OlError::new(
144 ERR_HOOK_WRITE_FAILED,
145 format!("Cannot write settings.json: {e}"),
146 )
147 .with_suggestion("Check file permissions.")
148 .with_docs("https://docs.openlatch.ai/errors/OL-1401")
149 })?;
150
151 let entries = EVENT_TYPES
152 .iter()
153 .zip(actions)
154 .map(|(&et, action)| HookEntryStatus {
155 event_type: et.to_string(),
156 action,
157 })
158 .collect();
159
160 Ok(HookInstallResult { entries })
161 }
162 }
163}
164
165/// Remove all OpenLatch-owned hook entries from the detected agent's config.
166///
167/// Only entries carrying `"_openlatch": true` are removed. Hooks from other
168/// tools are never touched.
169///
170/// # Errors
171///
172/// - `OL-1401` if settings.json cannot be read or written.
173/// - `OL-1402` if settings.json contains malformed JSONC.
174pub fn remove_hooks(agent: &DetectedAgent) -> Result<(), OlError> {
175 match agent {
176 DetectedAgent::ClaudeCode { settings_path, .. } => {
177 if !settings_path.exists() {
178 // Nothing to remove.
179 return Ok(());
180 }
181
182 let raw_jsonc = jsonc::read_or_create_settings(settings_path)?;
183 let modified = jsonc::remove_owned_entries(&raw_jsonc)?;
184
185 std::fs::write(settings_path, &modified).map_err(|e| {
186 OlError::new(
187 ERR_HOOK_WRITE_FAILED,
188 format!("Cannot write settings.json: {e}"),
189 )
190 .with_suggestion("Check file permissions.")
191 .with_docs("https://docs.openlatch.ai/errors/OL-1401")
192 })?;
193
194 Ok(())
195 }
196 }
197}
198
199// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[cfg(test)]
204mod tests {
205 /// Smoke-test detect_agent when ~/.claude/ does NOT exist.
206 ///
207 /// We override HOME so that dirs::home_dir() points to an empty tempdir.
208 #[test]
209 #[cfg(unix)]
210 fn test_detect_agent_returns_ol_1400_when_no_claude_dir() {
211 use super::detect_agent;
212 use crate::error::ERR_HOOK_AGENT_NOT_FOUND;
213
214 let dir = tempfile::tempdir().unwrap();
215 // Override HOME so ~/.claude/ does not exist.
216 std::env::set_var("HOME", dir.path());
217 let result = detect_agent();
218 std::env::remove_var("HOME");
219
220 let err = result.unwrap_err();
221 assert_eq!(
222 err.code, ERR_HOOK_AGENT_NOT_FOUND,
223 "Expected OL-1400, got {}",
224 err.code
225 );
226 }
227}