Skip to main content

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}