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};
#[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 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; 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();
let raw_jsonc = jsonc::read_or_create_settings(settings_path)?;
if raw_jsonc.contains("\"hooks\"") && !raw_jsonc.contains("\"_openlatch\"") {
tracing::info!(
code = ERR_HOOK_CONFLICT,
"Existing non-OpenLatch hooks detected in settings.json; coexistence is supported"
);
}
let (modified, actions) = jsonc::insert_hook_entries(&raw_jsonc, &entries)?;
let modified = jsonc::set_env_var(&modified, TOKEN_ENV_VAR, token)?;
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 })
}
}
}
pub fn remove_hooks(agent: &DetectedAgent) -> Result<(), OlError> {
match agent {
DetectedAgent::ClaudeCode { settings_path, .. } => {
if !settings_path.exists() {
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(())
}
}
}
#[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
);
}
}