use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::model_relay::wire_format::WireFormat;
use super::super::binding::{
AgentBinding, BindingCapabilities, DaemonChannel, EndpointConvention, Enforces, FailureMode,
LivenessReport, ModelRelayWiring,
};
use crate::core::envelope::alias::{AliasTable, McpKeyRule, MultiValueRule};
pub static ALIASES: AliasTable = AliasTable {
agent: "claude-code",
binding: "claude_code_hook",
renders_binding: None,
tools: &[
("Bash", "Bash"),
("BashOutput", "BashOutput"),
("PowerShell", "PowerShell"),
("Read", "Read"),
("Write", "Write"),
("Edit", "Edit"),
("MultiEdit", "MultiEdit"),
("NotebookEdit", "NotebookEdit"),
("Glob", "Glob"),
("Grep", "Grep"),
("WebFetch", "WebFetch"),
("WebSearch", "WebSearch"),
("Task", "Task"),
("Agent", "Agent"),
("AskUserQuestion", "AskUserQuestion"),
("TodoWrite", "TodoWrite"),
("ExitPlanMode", "ExitPlanMode"),
],
event_tools: &[],
keep: &[],
inputs: &[],
creates: &[],
mcp_rule: McpKeyRule::NATIVE,
multi: MultiValueRule::NONE,
};
pub const EVENT_TYPES: [&str; 13] = [
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"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 aliases(&self) -> &'static AliasTable {
&ALIASES
}
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,
off: false,
pending: false,
}
}
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: true,
native_failure_mode: FailureMode::FailOpen,
admin_owned_settings: true,
declares_session_in_request: true,
notice: true,
observes_execution: true,
enforces: Enforces::All,
}
}
fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
Some(ModelRelayWiring {
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_thirteen_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",
"PostToolUseFailure",
"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);
}
#[test]
fn capabilities_are_the_pinned_values() {
let b = ClaudeCodeBinding {
claude_dir: PathBuf::from("/home/test/.claude"),
settings_path: PathBuf::from("/home/test/.claude/settings.json"),
};
let c = b.capabilities();
assert!(c.ask(), "ask is derived from `expressible`, which holds it");
assert!(c.notice && c.observes_execution);
assert_eq!(c.enforces, Enforces::All);
}
#[test]
fn aliases_are_identity_rows_and_an_unlisted_tool_is_unmapped() {
for tool in [
"Bash",
"BashOutput",
"Read",
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"Task",
"Agent",
] {
let call = ALIASES.resolve(tool, Some(serde_json::json!({"k": "v"})), None);
assert_eq!((call.name.as_str(), call.mapped), (tool, true), "{tool}");
assert_eq!(call.input, Some(serde_json::json!({"k": "v"})), "{tool}");
}
let mcp = ALIASES.resolve("mcp__github__create_issue", None, None);
assert_eq!(mcp.name, "mcp__github__create_issue");
assert!(mcp.mapped);
let other = ALIASES.resolve("KillShell", None, None);
assert!(!other.mapped);
assert_eq!(other.native, "KillShell");
assert_eq!(ALIASES.binding_id(false), "claude_code_hook");
assert_eq!(ALIASES.binding_id(true), "claude_code_hook");
}
}