openlatch-client 0.0.1

The open-source security layer for AI agents — client forwarder
Documentation
/// Agent hook detection and installation.
///
/// Public API:
/// - [`detect_agent`] — detect which (if any) AI agent is installed
/// - [`install_hooks`] — write OpenLatch HTTP hook entries into the agent's config
/// - [`remove_hooks`] — remove all OpenLatch-owned hook entries
///
/// # Module structure
///
/// - `claude_code` — path detection and hook entry building for Claude Code
/// - `jsonc` — JSONC-preserving string surgery on `settings.json`
pub mod claude_code;
pub mod jsonc;

use std::path::PathBuf;

use crate::error::{OlError, ERR_HOOK_AGENT_NOT_FOUND, ERR_HOOK_CONFLICT, ERR_HOOK_WRITE_FAILED};

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

/// A detected AI agent with all paths needed for hook installation.
#[derive(Debug, Clone)]
pub enum DetectedAgent {
    /// Claude Code was found at the given directory.
    ClaudeCode {
        /// Path to the Claude Code config directory (e.g. `~/.claude/`).
        claude_dir: PathBuf,
        /// Path to `settings.json` inside the config directory.
        settings_path: PathBuf,
    },
}

/// The result of a successful [`install_hooks`] call.
#[derive(Debug)]
pub struct HookInstallResult {
    /// Per-hook-event status showing whether the entry was added or replaced.
    pub entries: Vec<HookEntryStatus>,
}

/// Status of a single hook event entry after installation.
#[derive(Debug)]
pub struct HookEntryStatus {
    /// The hook event type (e.g. `"PreToolUse"`, `"UserPromptSubmit"`, `"Stop"`).
    pub event_type: String,
    /// Whether the entry was newly added or replaced an existing OpenLatch entry.
    pub action: HookAction,
}

/// Whether a hook entry was newly created or replaced an existing one.
#[derive(Debug, Clone, PartialEq)]
pub enum HookAction {
    /// A new hook entry was appended to the array.
    Added,
    /// An existing OpenLatch-owned entry was replaced (idempotent re-install).
    Replaced,
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Detect which AI agent (if any) is installed on this machine.
///
/// Currently supports Claude Code only. Additional agents will be added in M4.
///
/// # Errors
///
/// Returns `OL-1400` if no supported AI agent is detected.
pub fn detect_agent() -> Result<DetectedAgent, OlError> {
    if let Some(claude_dir) = claude_code::detect() {
        let settings_path = claude_code::settings_json_path(&claude_dir);
        return Ok(DetectedAgent::ClaudeCode {
            claude_dir,
            settings_path,
        });
    }

    Err(
        OlError::new(ERR_HOOK_AGENT_NOT_FOUND, "No AI agents detected")
            .with_suggestion("Install Claude Code (https://claude.ai/download) and try again.")
            .with_docs("https://docs.openlatch.ai/errors/OL-1400"),
    )
}

/// Install OpenLatch HTTP hook entries into the detected agent's config.
///
/// The three hook events written are `PreToolUse`, `UserPromptSubmit`, and `Stop`.
/// Re-running this function is idempotent: existing OpenLatch entries are replaced
/// rather than duplicated. Hooks from other tools are never touched.
///
/// # Arguments
///
/// - `agent`: the agent returned by [`detect_agent`]
/// - `port`: the daemon's listen port (written into each hook URL)
/// - `token`: the bearer token value — the *env var name* `OPENLATCH_TOKEN` is
///   written into settings.json; the actual token is stored separately
///
/// # Errors
///
/// - `OL-1401` if settings.json cannot be read or written.
/// - `OL-1402` if settings.json contains malformed JSONC.
pub fn install_hooks(
    agent: &DetectedAgent,
    port: u16,
    token: &str,
) -> Result<HookInstallResult, OlError> {
    match agent {
        DetectedAgent::ClaudeCode { settings_path, .. } => {
            // Build all three hook entries.
            const TOKEN_ENV_VAR: &str = "OPENLATCH_TOKEN";
            const EVENT_TYPES: [&str; 3] = ["PreToolUse", "UserPromptSubmit", "Stop"];

            let entries: Vec<(String, serde_json::Value)> = EVENT_TYPES
                .iter()
                .map(|&et| {
                    (
                        et.to_string(),
                        claude_code::build_hook_entry(et, port, TOKEN_ENV_VAR),
                    )
                })
                .collect();

            // Read (or create) settings.json.
            let raw_jsonc = jsonc::read_or_create_settings(settings_path)?;

            // OL-1403: Warn (non-blocking) if non-OpenLatch hooks are present
            if raw_jsonc.contains("\"hooks\"") && !raw_jsonc.contains("\"_openlatch\"") {
                // Existing hooks from other tools — coexistence is fine, just log for awareness
                tracing::info!(
                    code = ERR_HOOK_CONFLICT,
                    "Existing non-OpenLatch hooks detected in settings.json; coexistence is supported"
                );
            }

            // Perform JSONC surgery: insert hooks + set OPENLATCH_TOKEN env var.
            let (modified, actions) = jsonc::insert_hook_entries(&raw_jsonc, &entries)?;
            let modified = jsonc::set_env_var(&modified, TOKEN_ENV_VAR, token)?;

            // Write the modified JSONC back.
            std::fs::write(settings_path, &modified).map_err(|e| {
                OlError::new(
                    ERR_HOOK_WRITE_FAILED,
                    format!("Cannot write settings.json: {e}"),
                )
                .with_suggestion("Check file permissions.")
                .with_docs("https://docs.openlatch.ai/errors/OL-1401")
            })?;

            let entries = EVENT_TYPES
                .iter()
                .zip(actions)
                .map(|(&et, action)| HookEntryStatus {
                    event_type: et.to_string(),
                    action,
                })
                .collect();

            Ok(HookInstallResult { entries })
        }
    }
}

/// Remove all OpenLatch-owned hook entries from the detected agent's config.
///
/// Only entries carrying `"_openlatch": true` are removed. Hooks from other
/// tools are never touched.
///
/// # Errors
///
/// - `OL-1401` if settings.json cannot be read or written.
/// - `OL-1402` if settings.json contains malformed JSONC.
pub fn remove_hooks(agent: &DetectedAgent) -> Result<(), OlError> {
    match agent {
        DetectedAgent::ClaudeCode { settings_path, .. } => {
            if !settings_path.exists() {
                // Nothing to remove.
                return Ok(());
            }

            let raw_jsonc = jsonc::read_or_create_settings(settings_path)?;
            let modified = jsonc::remove_owned_entries(&raw_jsonc)?;

            std::fs::write(settings_path, &modified).map_err(|e| {
                OlError::new(
                    ERR_HOOK_WRITE_FAILED,
                    format!("Cannot write settings.json: {e}"),
                )
                .with_suggestion("Check file permissions.")
                .with_docs("https://docs.openlatch.ai/errors/OL-1401")
            })?;

            Ok(())
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    /// Smoke-test detect_agent when ~/.claude/ does NOT exist.
    ///
    /// We override HOME so that dirs::home_dir() points to an empty tempdir.
    #[test]
    #[cfg(unix)]
    fn test_detect_agent_returns_ol_1400_when_no_claude_dir() {
        use super::detect_agent;
        use crate::error::ERR_HOOK_AGENT_NOT_FOUND;

        let dir = tempfile::tempdir().unwrap();
        // Override HOME so ~/.claude/ does not exist.
        std::env::set_var("HOME", dir.path());
        let result = detect_agent();
        std::env::remove_var("HOME");

        let err = result.unwrap_err();
        assert_eq!(
            err.code, ERR_HOOK_AGENT_NOT_FOUND,
            "Expected OL-1400, got {}",
            err.code
        );
    }
}