pub mod atomic;
pub mod binding;
pub mod bindings;
pub mod claude_code;
pub mod jsonc;
use std::path::PathBuf;
use crate::core::hook_state::hmac::compute_entry_hmac;
use crate::core::hook_state::key::HmacKeyStore;
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::core::hook_state::{self, HookStateFile, StateEntry};
use crate::error::{OlError, ERR_HOOK_AGENT_NOT_FOUND};
#[derive(Debug, Clone)]
pub enum DetectedAgent {
ClaudeCode {
claude_dir: PathBuf,
settings_path: PathBuf,
},
}
#[derive(Debug)]
pub struct HookInstallResult {
pub entries: Vec<HookEntryStatus>,
}
#[derive(Debug)]
pub struct HookEntryStatus {
pub event_type: String,
pub action: HookAction,
}
#[derive(Debug, Clone, PartialEq)]
pub enum HookAction {
Added,
Replaced,
}
pub fn resolve_hook_binary_path() -> PathBuf {
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
if let Ok(override_path) = std::env::var("OPENLATCH_HOOK_BIN") {
if !override_path.is_empty() {
return PathBuf::from(override_path);
}
}
if let Some(home) = dirs::home_dir() {
let candidate = home.join(".openlatch").join("bin").join(bin_name);
if candidate.exists() {
return candidate;
}
}
if let Ok(current_exe) = std::env::current_exe() {
if let Some(dir) = current_exe.parent() {
let candidate = dir.join(bin_name);
if candidate.exists() {
return candidate;
}
}
}
PathBuf::from(bin_name)
}
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"),
)
}
pub fn install_hooks(
agent: &DetectedAgent,
port: u16,
token: &str,
) -> Result<HookInstallResult, OlError> {
match agent {
DetectedAgent::ClaudeCode { settings_path, .. } => {
const TOKEN_ENV_VAR: &str = "OPENLATCH_TOKEN";
const EVENT_TYPES: [&str; 9] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Notification",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
];
let openlatch_dir = crate::config::openlatch_dir();
let hook_bin = resolve_hook_binary_path();
let key_store = HmacKeyStore::new(&openlatch_dir);
let hmac_key = key_store.load_or_create()?;
let token_fp = crate::core::hook_state::key::key_fingerprint(token.as_bytes());
let settings_path_hash = hook_state::hash_settings_path(settings_path);
let mut entries_with_markers: Vec<(String, serde_json::Value, String)> = Vec::new();
for &et in &EVENT_TYPES {
let entry_id = uuid::Uuid::now_v7().to_string();
let mut marker = OpenlatchMarker::new(entry_id.clone());
let mut entry =
claude_code::build_hook_entry(et, port, TOKEN_ENV_VAR, &hook_bin, &marker);
let hmac_value = compute_entry_hmac(&entry, &hmac_key)?;
marker = marker.with_hmac(hmac_value.clone());
let marker_value =
serde_json::to_value(&marker).expect("OpenlatchMarker serializes");
entry["_openlatch"] = marker_value;
entries_with_markers.push((et.to_string(), entry, entry_id));
}
let jsonc_entries: Vec<(String, serde_json::Value)> = entries_with_markers
.iter()
.map(|(et, entry, _)| (et.clone(), entry.clone()))
.collect();
let token_owned = token.to_string();
let actions = std::cell::RefCell::new(Vec::new());
atomic::atomic_rewrite_jsonc(settings_path, |root| {
let a = jsonc::insert_hook_entries_cst(root, &jsonc_entries)?;
jsonc::set_env_var_cst(root, TOKEN_ENV_VAR, &token_owned)?;
*actions.borrow_mut() = a;
Ok(())
})?;
let actions = actions.into_inner();
let mut state = HookStateFile::load(&openlatch_dir)?
.unwrap_or_else(|| HookStateFile::new("kid-01".into()));
for (et, _, entry_id) in &entries_with_markers {
let hmac_val = entries_with_markers
.iter()
.find(|(e, _, _)| e == et)
.and_then(|(_, entry, _)| {
entry["_openlatch"]["hmac"].as_str().map(|s| s.to_string())
})
.unwrap_or_default();
state.upsert_entry(StateEntry {
id: entry_id.clone(),
agent: "claude-code".into(),
settings_path_hash: settings_path_hash.clone(),
hook_event: et.clone(),
expected_entry_hmac: hmac_val,
daemon_port_at_install: port,
daemon_token_fp: token_fp.clone(),
v: 1,
});
}
if let Err(e) = state.save(&openlatch_dir) {
tracing::warn!(
code = crate::error::ERR_STATE_FILE_WRITE_FAILED,
error = %e,
"failed to write hook state file — hooks installed but state file out of sync"
);
}
let entries = EVENT_TYPES
.iter()
.zip(actions)
.map(|(&et, action)| HookEntryStatus {
event_type: et.to_string(),
action,
})
.collect();
Ok(HookInstallResult { entries })
}
}
}
pub fn remove_hooks(agent: &DetectedAgent) -> Result<(), OlError> {
match agent {
DetectedAgent::ClaudeCode { settings_path, .. } => {
if !settings_path.exists() {
return Ok(());
}
atomic::atomic_rewrite_jsonc(settings_path, |root| {
jsonc::remove_owned_entries_cst(root)
})?;
Ok(())
}
}
}
#[cfg(test)]
mod tests {
#[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();
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
);
}
}