use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::boundary::wire_format::WireFormat;
use crate::core::hook_state::marker::OpenlatchMarker;
use super::super::binding::{
AgentBinding, BindingCapabilities, BoundaryWiring, DaemonChannel, EndpointConvention,
FailureMode, LivenessReport,
};
pub const EVENT_TYPES: [&str; 12] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Notification",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
"ConfigChange",
"InstructionsLoaded",
"FileChanged",
];
pub const LOAD_BEARING_EVENTS: [&str; 3] = ["PreToolUse", "UserPromptSubmit", "Stop"];
pub struct ClaudeCodeBinding {
pub claude_dir: PathBuf,
pub settings_path: PathBuf,
}
impl ClaudeCodeBinding {
pub fn detect() -> Option<Self> {
let claude_dir = crate::hooks::claude_code::detect()?;
let settings_path = crate::hooks::claude_code::settings_json_path(&claude_dir);
Some(Self {
claude_dir,
settings_path,
})
}
}
impl AgentBinding for ClaudeCodeBinding {
fn agent_type(&self) -> &'static str {
"claude-code"
}
fn display_name(&self) -> &'static str {
"Claude Code"
}
fn config_dir(&self) -> PathBuf {
self.claude_dir.clone()
}
fn hook_config_path(&self) -> PathBuf {
self.settings_path.clone()
}
fn hook_event_types(&self) -> &'static [&'static str] {
&EVENT_TYPES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
&LOAD_BEARING_EVENTS
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::EnvVars {
token: crate::hooks::OPENLATCH_TOKEN_ENV,
port: crate::hooks::OPENLATCH_PORT_ENV,
}
}
fn liveness(&self) -> LivenessReport {
LivenessReport {
armed: None,
detail: None,
remedy: None,
code: None,
}
}
fn build_hook_entry(
&self,
event: &str,
binary: &Path,
port: u16,
marker: &OpenlatchMarker,
) -> Value {
let token_env_var = match self.daemon_channel() {
DaemonChannel::EnvVars { token, .. } => token,
DaemonChannel::OpenlatchDirArg => crate::hooks::OPENLATCH_TOKEN_ENV,
};
crate::hooks::claude_code::build_hook_entry(event, port, token_env_var, binary, marker)
}
fn config_is_machine_global(&self) -> bool {
crate::hooks::claude_code::config_is_machine_global()
}
fn capabilities(&self) -> BindingCapabilities {
BindingCapabilities {
expressible: &["allow", "ask", "deny"],
can_mutate_arguments: false,
native_failure_mode: FailureMode::FailOpen,
admin_owned_settings: true,
declares_session_in_request: true,
}
}
fn boundary_wiring(&self) -> Option<BoundaryWiring> {
Some(BoundaryWiring {
wire_format: WireFormat::AnthropicMessages,
endpoint: EndpointConvention::EnvVars {
base_url: crate::hooks::ANTHROPIC_BASE_URL_ENV,
headers: crate::hooks::ANTHROPIC_CUSTOM_HEADERS_ENV,
},
install_id_header: "x-openlatch-install-id",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn claude_code_agent_type() {
let b = ClaudeCodeBinding {
claude_dir: PathBuf::from("/home/test/.claude"),
settings_path: PathBuf::from("/home/test/.claude/settings.json"),
};
assert_eq!(b.agent_type(), "claude-code");
}
#[test]
fn binding_event_list_is_the_twelve_install_writes() {
let b = ClaudeCodeBinding {
claude_dir: PathBuf::from("/home/test/.claude"),
settings_path: PathBuf::from("/home/test/.claude/settings.json"),
};
assert_eq!(
b.hook_event_types(),
[
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Notification",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
"ConfigChange",
"InstructionsLoaded",
"FileChanged",
],
"the one list install writes — the three config-plane events included"
);
}
#[test]
fn claude_load_bearing_events_are_the_same_three() {
let b = ClaudeCodeBinding {
claude_dir: PathBuf::from("/home/test/.claude"),
settings_path: PathBuf::from("/home/test/.claude/settings.json"),
};
assert_eq!(
b.load_bearing_events(),
["PreToolUse", "UserPromptSubmit", "Stop"]
);
}
#[test]
fn claude_liveness_is_none_not_true() {
let b = ClaudeCodeBinding {
claude_dir: PathBuf::from("/home/test/.claude"),
settings_path: PathBuf::from("/home/test/.claude/settings.json"),
};
let report = b.liveness();
assert_eq!(
report.armed, None,
"installed is armed — there is nothing to prove"
);
assert!(report.detail.is_none());
assert!(report.remedy.is_none());
assert!(report.code.is_none());
}
#[test]
fn claude_config_dir_relocates_the_settings_path() {
let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
let env = crate::daemon::identity::test_support::EnvGuard::clear();
let dir = tempfile::tempdir().expect("temp dir");
env.set("CLAUDE_CONFIG_DIR", dir.path());
let b = ClaudeCodeBinding::detect().expect("relocated dir exists, so detect must succeed");
assert_eq!(b.claude_dir, dir.path());
assert_eq!(b.hook_config_path(), dir.path().join("settings.json"));
env.set("CLAUDE_CONFIG_DIR", "");
if let Some(b) = ClaudeCodeBinding::detect() {
assert_ne!(b.claude_dir, std::path::Path::new(""));
}
}
#[test]
fn claude_code_binding_is_arc_dyn_compatible() {
let b = ClaudeCodeBinding {
claude_dir: PathBuf::from("/tmp/.claude"),
settings_path: PathBuf::from("/tmp/.claude/settings.json"),
};
let _arc: Arc<dyn AgentBinding> = Arc::new(b);
}
}