use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::core::hook_state::marker::OpenlatchMarker;
use super::super::binding::{
AgentBinding, BindingCapabilities, DaemonChannel, FailureMode, LivenessReport, MarkerPlacement,
ModelRelayWiring,
};
const EVENT_TYPES: &[&str] = &[
"sessionStart",
"sessionEnd",
"postToolUse",
"postToolUseFailure",
"subagentStop",
"preCompact",
"stop",
];
const DECIDING: &[&str] = &[
"preToolUse",
"beforeShellExecution",
"beforeMCPExecution",
"beforeSubmitPrompt",
];
pub struct CursorBinding {
pub root: PathBuf,
pub hooks_path: PathBuf,
}
impl CursorBinding {
pub fn detect() -> Option<Self> {
let root = crate::hooks::cursor::detect()?;
Some(Self {
hooks_path: crate::hooks::cursor::hooks_json_path(&root),
root,
})
}
}
fn cursor_wire_event(event: &str) -> &'static str {
match event {
"preToolUse" | "beforeShellExecution" | "beforeMCPExecution" => "pre_tool_use",
"beforeSubmitPrompt" => "user_prompt_submit",
"sessionStart" => "session_start",
"sessionEnd" => "session_end",
"postToolUse" => "post_tool_use",
"postToolUseFailure" => "post_tool_use_failure",
"subagentStop" => "subagent_stop",
"preCompact" => "pre_compact",
"stop" => "stop",
_ => "unknown",
}
}
impl AgentBinding for CursorBinding {
fn agent_type(&self) -> &'static str {
"cursor"
}
fn display_name(&self) -> &'static str {
"Cursor"
}
fn config_dir(&self) -> PathBuf {
self.root.clone()
}
fn hook_config_path(&self) -> PathBuf {
self.hooks_path.clone()
}
fn hook_event_types(&self) -> &'static [&'static str] {
EVENT_TYPES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
&["sessionStart"]
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::OpenlatchDirArg
}
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 wire = cursor_wire_event(event);
let openlatch_dir = crate::config::openlatch_dir();
let command = format!(
r#""{}" --agent cursor --event {wire} --openlatch-dir "{}""#,
binary.display(),
openlatch_dir.display()
);
let timeout = if DECIDING.contains(&event) { 900 } else { 10 };
json!({ "type": "command", "command": command, "timeout": timeout })
}
fn config_is_machine_global(&self) -> bool {
crate::hooks::cursor::config_is_machine_global()
}
fn capabilities(&self) -> BindingCapabilities {
BindingCapabilities {
expressible: &["allow", "deny"],
can_mutate_arguments: false,
native_failure_mode: FailureMode::FailOpen,
admin_owned_settings: false,
declares_session_in_request: true,
}
}
fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
None
}
fn request_plane_pending(&self) -> bool {
true
}
fn marker_placement(&self) -> MarkerPlacement {
MarkerPlacement::Sidecar
}
fn file_root_defaults(&self) -> &'static [(&'static str, i64)] {
&[("version", 1)]
}
fn byte_identical_restore(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::envelope::known_types::KNOWN_HOOK_EVENT_TYPES;
fn binding() -> CursorBinding {
CursorBinding {
root: PathBuf::from("/home/test/.cursor"),
hooks_path: PathBuf::from("/home/test/.cursor/hooks.json"),
}
}
#[test]
fn every_event_maps_to_a_known_wire_value() {
for event in EVENT_TYPES.iter().chain(DECIDING) {
let wire = cursor_wire_event(event);
assert_ne!(wire, "unknown", "{event} has no wire value");
assert!(
KNOWN_HOOK_EVENT_TYPES.contains(&wire),
"{event} maps to {wire}, which is not a known hook event type"
);
}
}
#[test]
fn no_deciding_event_is_installed_yet() {
for event in DECIDING {
assert!(
!EVENT_TYPES.contains(event),
"{event} must not be installed before the cursor translator exists"
);
}
assert!(binding()
.load_bearing_events()
.iter()
.all(|e| EVENT_TYPES.contains(e)));
}
#[test]
fn entry_is_flat_with_only_documented_keys() {
let marker = OpenlatchMarker::new("id".into());
for event in EVENT_TYPES.iter().chain(DECIDING) {
let entry =
binding().build_hook_entry(event, Path::new("/bin/openlatch-hook"), 7443, &marker);
let keys: Vec<&String> = entry.as_object().expect("an object").keys().collect();
assert!(
keys.iter()
.all(|k| ["type", "command", "timeout"].contains(&k.as_str())),
"{event}: undocumented key in {entry}"
);
assert_eq!(entry["type"], "command");
let expected = if DECIDING.contains(event) { 900 } else { 10 };
assert_eq!(entry["timeout"], expected, "{event}");
}
}
#[test]
fn command_quotes_both_paths() {
let _dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let marker = OpenlatchMarker::new("id".into());
let bin = Path::new("/Applications/Open Latch/openlatch-hook");
let entry = binding().build_hook_entry("stop", bin, 7443, &marker);
let command = entry["command"].as_str().expect("a command");
let dir = crate::config::openlatch_dir();
assert_eq!(
command,
format!(
r#""{}" --agent cursor --event stop --openlatch-dir "{}""#,
bin.display(),
dir.display()
)
);
assert!(crate::hooks::entry_shape::is_sidecar_owned(
command, "cursor"
));
}
#[test]
fn cursor_declares_the_sidecar_and_byte_identical_restore() {
let b = binding();
assert_eq!(b.marker_placement(), MarkerPlacement::Sidecar);
assert_eq!(b.file_root_defaults(), &[("version", 1)]);
assert!(b.byte_identical_restore());
assert!(matches!(b.daemon_channel(), DaemonChannel::OpenlatchDirArg));
}
}