diff options
| author | udirom <uromano@fireblocks.com> | 2026-06-08 03:26:11 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-08 03:26:11 +0300 |
| commit | 5fe527d1be5622e506244ee726209fa72b0c7fd3 (patch) | |
| tree | 1e358807422b797313c3efa00de26b798e8a87f3 /src/integration | |
| parent | 27a028aeae0b2ecdd747c90aa34605fd7e810ef1 (diff) | |
feat: add cursor agent cli integration for session restore (#506)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/cursor/herdr-agent-state.sh | 92 | ||||
| -rw-r--r-- | src/integration/mod.rs | 408 |
2 files changed, 499 insertions, 1 deletions
diff --git a/src/integration/assets/cursor/herdr-agent-state.sh b/src/integration/assets/cursor/herdr-agent-state.sh new file mode 100644 index 00000000..941c6ad7 --- /dev/null +++ b/src/integration/assets/cursor/herdr-agent-state.sh @@ -0,0 +1,92 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=cursor +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +action="${1:-}" +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-cursor-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true + +case "$action" in + session) ;; + *) exit 0 ;; +esac + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +import json +import os +import random +import socket +import time + +source = "herdr:cursor" +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") + +if not pane_id or not socket_path: + raise SystemExit(0) + +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + +def first_text(*keys): + for key in keys: + value = hook_input.get(key) + if isinstance(value, str) and value: + return value + return None + +hook_event_name = first_text("hook_event_name", "hookEventName") +if hook_event_name not in (None, "sessionStart"): + raise SystemExit(0) + +request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() +session_id = first_text("session_id", "sessionId", "conversation_id", "conversationId") +agent_session_id = session_id if isinstance(session_id, str) and session_id else None +if agent_session_id: + request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "cursor", + "seq": report_seq, + "agent_session_id": agent_session_id, + }, + } +else: + raise SystemExit(0) + +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode()) + try: + client.recv(4096) + except Exception: + pass + client.close() +except Exception: + pass +PY diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 08018aad..07318213 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -112,6 +112,10 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { }; const QODERCLI_INTEGRATION_VERSION: u32 = 1; const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; +const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh"); +const CURSOR_INTEGRATION_VERSION: u32 = 1; +const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR"; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; #[derive(Debug)] @@ -171,6 +175,20 @@ pub(crate) struct QodercliInstallPaths { } #[derive(Debug)] +pub(crate) struct CursorInstallPaths { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct CursorUninstallResult { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub removed_hook_file: bool, + pub updated_hooks: bool, +} + +#[derive(Debug)] pub(crate) struct QodercliUninstallResult { pub hook_path: PathBuf, pub settings_path: PathBuf, @@ -431,6 +449,16 @@ pub(crate) fn install_target( ), ] } + crate::api::schema::IntegrationTarget::Cursor => { + let installed = install_cursor()?; + vec![ + format!( + "installed cursor integration hook to {}", + installed.hook_path.display() + ), + format!("updated cursor hooks at {}", installed.hooks_path.display()), + ] + } }; crate::logging::integration_action("install", integration_target_label(target), "ok"); @@ -687,6 +715,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Cursor => { + let result = uninstall_cursor()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed cursor hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no cursor hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr cursor hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr cursor hook entries found in {}", + result.hooks_path.display() + )); + } + messages + } }; crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); @@ -707,6 +762,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", + crate::api::schema::IntegrationTarget::Cursor => "cursor", } } @@ -728,9 +784,14 @@ fn integration_target_command_names( crate::api::schema::IntegrationTarget::Opencode => &["opencode"], crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), + crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), } } +fn cursor_command_names() -> &'static [&'static str] { + &["cursor-agent"] +} + fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool { #[cfg(windows)] { @@ -928,7 +989,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 10] { +); 11] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -980,6 +1041,11 @@ fn integration_specs() -> [( qodercli_dir().map(|dir| dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)), QODERCLI_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Cursor, + cursor_dir().map(|dir| dir.join(CURSOR_HOOK_INSTALL_NAME)), + CURSOR_INTEGRATION_VERSION, + ), ] } @@ -1890,6 +1956,63 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { }) } +pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> { + let dir = cursor_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "cursor config directory not found at {}. install cursor agent cli first", + dir.display() + ))); + } + + let hook_path = dir.join(CURSOR_HOOK_INSTALL_NAME); + fs::write(&hook_path, CURSOR_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let hooks_path = dir.join("hooks.json"); + let mut hooks_file = if hooks_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({ "version": 1 }) + }; + + if hooks_file.get("version").is_none() { + hooks_file + .as_object_mut() + .ok_or_else(|| { + io::Error::other(format!( + "cursor hooks file at {} must be a JSON object", + hooks_path.display() + )) + })? + .insert("version".to_string(), json!(1)); + } + + let hooks = ensure_hooks_object( + &mut hooks_file, + &hooks_path, + "cursor hooks file", + "cursor hooks file hooks", + )?; + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + let session_command = format!("bash {quoted_hook_path} session"); + remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?; + remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?; + remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?; + remove_simple_command_hook(hooks, "stop", &session_command)?; + remove_simple_command_hook(hooks, "sessionEnd", &session_command)?; + ensure_simple_command_hook(hooks, "sessionStart", session_command)?; + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + Ok(CursorInstallPaths { + hook_path, + hooks_path, + }) +} + pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { let hook_path = qodercli_dir()? .join("hooks") @@ -1941,6 +2064,52 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { }) } +pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> { + let cursor_home = cursor_dir()?; + let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME); + let hooks_path = cursor_home.join("hooks.json"); + let mut updated_hooks = false; + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "cursor hooks file", + "cursor hooks file hooks", + )? { + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + let session_command = format!("bash {quoted_hook_path} session"); + updated_hooks |= remove_simple_command_hook(hooks, "sessionStart", &session_command)?; + updated_hooks |= + remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?; + updated_hooks |= + remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?; + updated_hooks |= + remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?; + updated_hooks |= remove_simple_command_hook(hooks, "stop", &session_command)?; + updated_hooks |= remove_simple_command_hook(hooks, "sessionEnd", &session_command)?; + } + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(CursorUninstallResult { + hook_path, + hooks_path, + removed_hook_file, + updated_hooks, + }) +} + fn ensure_hooks_object<'a>( settings: &'a mut Value, settings_path: &Path, @@ -2151,6 +2320,53 @@ fn remove_direct_command_hook( Ok(removed) } +// Cursor hooks.json uses the minimal shape `{ "command": "..." }` documented at +// https://cursor.com/docs/hooks. Keep this separate from the nested codex and +// flat copilot helpers so install/uninstall does not rewrite unrelated hooks. +fn ensure_simple_command_hook( + hooks: &mut Map<String, Value>, + event: &str, + command: String, +) -> io::Result<()> { + let entries = hooks + .entry(event.to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + + if entries + .iter() + .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command.as_str())) + { + return Ok(()); + } + + entries.push(json!({ "command": command })); + Ok(()) +} + +fn remove_simple_command_hook( + hooks: &mut Map<String, Value>, + event: &str, + command: &str, +) -> io::Result<bool> { + let Some(entries_value) = hooks.get_mut(event) else { + return Ok(false); + }; + + let entries = entries_value + .as_array_mut() + .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + + let before = entries.len(); + entries.retain(|entry| entry.get("command").and_then(Value::as_str) != Some(command)); + let removed = entries.len() != before; + if entries.is_empty() { + hooks.remove(event); + } + Ok(removed) +} + fn remove_hook_commands( hooks: &mut Map<String, Value>, event: &str, @@ -2859,6 +3075,10 @@ fn qodercli_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"]) } +fn cursor_dir() -> io::Result<PathBuf> { + config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) +} + fn home_dir() -> io::Result<PathBuf> { if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) { return Ok(PathBuf::from(home)); @@ -2901,6 +3121,7 @@ mod tests { std::env::remove_var(COPILOT_HOME_ENV_VAR); std::env::remove_var(KIMI_CODE_HOME_ENV_VAR); std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); + std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); } fn kimi_hook_command(hook_path: &Path, action: &str) -> String { @@ -2972,6 +3193,7 @@ mod tests { assert!(!integration_target_supported(IntegrationTarget::Omp)); assert!(!integration_target_supported(IntegrationTarget::Opencode)); assert!(!integration_target_supported(IntegrationTarget::Hermes)); + assert!(!integration_target_supported(IntegrationTarget::Cursor)); assert!(integration_target_supported(IntegrationTarget::Claude)); assert!(integration_target_supported(IntegrationTarget::Codex)); @@ -2997,11 +3219,13 @@ mod tests { fs::write(bin.join("omp.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("opencode.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("hermes.exe"), "").unwrap(); + fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap(); assert!(!integration_target_available(IntegrationTarget::Pi)); assert!(!integration_target_available(IntegrationTarget::Omp)); assert!(!integration_target_available(IntegrationTarget::Opencode)); assert!(!integration_target_available(IntegrationTarget::Hermes)); + assert!(!integration_target_available(IntegrationTarget::Cursor)); if let Some(path) = original_path { std::env::set_var("PATH", path); @@ -5021,6 +5245,16 @@ mod tests { assert!(QODERCLI_HOOK_ASSET.contains("hook_event_name")); assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id")); assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT")); + assert!(CURSOR_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=cursor")); + assert!(CURSOR_HOOK_ASSET.contains("conversation_id")); + assert!(CURSOR_HOOK_ASSET.contains("conversationId")); + assert!(CURSOR_HOOK_ASSET.contains("sessionId")); + assert!(CURSOR_HOOK_ASSET.contains("agent_session_id")); + assert!(CURSOR_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(CURSOR_HOOK_ASSET.contains("hook_event_name")); + assert!(CURSOR_HOOK_ASSET.contains("sessionStart")); + assert!(!CURSOR_HOOK_ASSET.contains("\"state\":")); + assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent")); } #[test] @@ -5168,4 +5402,176 @@ mod tests { std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); let _ = fs::remove_dir_all(base); } + + #[test] + fn install_cursor_writes_hook_and_updates_hooks_json() { + let _lock = integration_env_lock(); + let base = unique_base(); + let cursor_dir = base.join(".cursor"); + fs::create_dir_all(&cursor_dir).unwrap(); + fs::write( + cursor_dir.join("hooks.json"), + r#"{"version":1,"hooks":{"stop":[{"command":"echo keep-me"}]}}"#, + ) + .unwrap(); + std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir); + + let installed = install_cursor().unwrap(); + + assert_eq!( + installed.hook_path, + cursor_dir.join(CURSOR_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, cursor_dir.join("hooks.json")); + assert_eq!( + fs::read_to_string(&installed.hook_path).unwrap(), + CURSOR_HOOK_ASSET + ); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()) + .unwrap(); + let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap(); + let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap(); + assert_eq!(session_start.len(), 1); + assert!(session_start[0] + .get("command") + .and_then(Value::as_str) + .is_some_and(|command| { + command.starts_with("bash ") + && command.contains("herdr-agent-state.sh") + && command.ends_with(" session") + })); + assert!(hooks.get("beforeSubmitPrompt").is_none()); + assert!(hooks.get("beforeShellExecution").is_none()); + let stop = hooks.get("stop").and_then(Value::as_array).unwrap(); + assert_eq!(stop.len(), 1); + assert_eq!( + stop[0].get("command").and_then(Value::as_str), + Some("echo keep-me") + ); + + std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_cursor_is_idempotent_for_hook_entries() { + let _lock = integration_env_lock(); + let base = unique_base(); + let cursor_dir = base.join(".cursor"); + fs::create_dir_all(&cursor_dir).unwrap(); + std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir); + + install_cursor().unwrap(); + install_cursor().unwrap(); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()) + .unwrap(); + let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap(); + let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap(); + assert_eq!(session_start.len(), 1); + + std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_cursor_removes_herdr_hooks_and_preserves_others() { + let _lock = integration_env_lock(); + let base = unique_base(); + let cursor_dir = base.join(".cursor"); + fs::create_dir_all(&cursor_dir).unwrap(); + std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir); + + install_cursor().unwrap(); + let mut hooks_file: Value = + serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()) + .unwrap(); + hooks_file["hooks"]["beforeSubmitPrompt"] = json!([{ "command": "echo user-defined" }]); + fs::write( + cursor_dir.join("hooks.json"), + serde_json::to_string_pretty(&hooks_file).unwrap(), + ) + .unwrap(); + + let result = uninstall_cursor().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_hooks); + assert!(!cursor_dir.join(CURSOR_HOOK_INSTALL_NAME).is_file()); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(cursor_dir.join("hooks.json")).unwrap()) + .unwrap(); + let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap(); + assert!(!hooks.contains_key("sessionStart")); + assert!(hooks.contains_key("beforeSubmitPrompt")); + + std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_cursor_uses_cursor_config_dir_env() { + let _lock = integration_env_lock(); + let base = unique_base(); + let cursor_dir = base.join("custom-cursor"); + fs::create_dir_all(&cursor_dir).unwrap(); + std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir); + + let installed = install_cursor().unwrap(); + + assert_eq!( + installed.hook_path, + cursor_dir.join(CURSOR_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, cursor_dir.join("hooks.json")); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn cursor_v1_integration_status_is_current() { + let _lock = integration_env_lock(); + let base = unique_base(); + let cursor_dir = base.join(".cursor"); + fs::create_dir_all(&cursor_dir).unwrap(); + let hook_path = cursor_dir.join(CURSOR_HOOK_INSTALL_NAME); + fs::write( + &hook_path, + "#!/bin/sh\n# HERDR_INTEGRATION_ID=cursor\n# HERDR_INTEGRATION_VERSION=1\n", + ) + .unwrap(); + std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &cursor_dir); + + let statuses = installed_integration_statuses(); + let cursor = statuses + .iter() + .find(|status| status.target == crate::api::schema::IntegrationTarget::Cursor) + .expect("cursor integration status"); + assert_eq!(cursor.state, IntegrationStatusKind::Current); + assert_eq!(cursor.installed_version, Some(CURSOR_INTEGRATION_VERSION)); + + clear_integration_path_env(); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_cursor_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let missing = base.join(".cursor"); + std::env::set_var(CURSOR_CONFIG_DIR_ENV_VAR, &missing); + + let err = install_cursor().unwrap_err().to_string(); + assert!( + err.contains("cursor config directory not found"), + "unexpected error: {err}" + ); + + std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } } |
