aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorudirom <uromano@fireblocks.com>2026-06-08 03:26:11 +0300
committerGitHub <noreply@github.com>2026-06-08 03:26:11 +0300
commit5fe527d1be5622e506244ee726209fa72b0c7fd3 (patch)
tree1e358807422b797313c3efa00de26b798e8a87f3
parent27a028aeae0b2ecdd747c90aa34605fd7e810ef1 (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>
-rw-r--r--docs/next/README.md7
-rw-r--r--docs/next/website/src/content/docs/agents.mdx1
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx24
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx1
-rw-r--r--src/agent_resume.rs19
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli/integration.rs9
-rw-r--r--src/integration/assets/cursor/herdr-agent-state.sh92
-rw-r--r--src/integration/mod.rs408
10 files changed, 553 insertions, 11 deletions
diff --git a/docs/next/README.md b/docs/next/README.md
index d6cc7ded..db3b43fb 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -151,7 +151,7 @@ states:
- 🔵 **done** — work finished, you have not looked at it yet
- 🟢 **idle** — done and seen
-detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, droid, and opencode integrations provide session restore identity; pi, omp, github copilot cli, kimi code cli, hermes, qodercli, and custom socket integrations can report their own state.
+detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, cursor agent cli, droid, and opencode integrations provide session restore identity; pi, omp, github copilot cli, kimi code cli, hermes, qodercli, and custom socket integrations can report their own state.
## lives in your terminal
@@ -198,7 +198,7 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe
### direct integrations
-official integrations have two roles. claude code, codex, droid, and opencode report session identity for native restore, while their state still comes from screen detection. pi, github copilot cli, and hermes report both semantic state and session identity. omp, kimi code cli, and qodercli report semantic state without native session restore. install with:
+official integrations have two roles. claude code, codex, cursor agent cli, droid, and opencode report session identity for native restore, while their state still comes from screen detection. pi, github copilot cli, and hermes report both semantic state and session identity. omp, kimi code cli, and qodercli report semantic state without native session restore. install with:
```bash
herdr integration install pi
@@ -211,6 +211,7 @@ herdr integration install kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
+herdr integration install cursor
```
see the [integrations docs](https://herdr.dev/docs/integrations/) for setup details.
@@ -257,7 +258,7 @@ In-app settings cover theme, sound, and toast preferences. Herdr writes logs und
- [install](https://herdr.dev/docs/install/) — install, update, Homebrew, mise, and Nix
- [session state](https://herdr.dev/docs/session-state/) — detach, restart restore, agent restore, and live handoff
- [configuration](https://herdr.dev/docs/configuration/) — keybindings, themes, notifications, environment variables
-- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, github copilot cli, droid, kimi code cli, opencode, hermes, qodercli integrations
+- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, cursor agent cli, github copilot cli, droid, kimi code cli, opencode, hermes, qodercli integrations
- [`SKILL.md`](./SKILL.md) — reusable agent skill
- [socket api](https://herdr.dev/docs/socket-api/) — socket protocol and cli reference
diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index 7d7bcf08..e2054bf1 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -61,6 +61,7 @@ herdr integration install omp
herdr integration install claude
herdr integration install codex
herdr integration install copilot
+herdr integration install cursor
herdr integration install droid
herdr integration install kimi
herdr integration install opencode
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index ebcdc2b4..aaa550dd 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -232,6 +232,7 @@ herdr integration install kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
+herdr integration install cursor
herdr integration uninstall pi
herdr integration uninstall omp
herdr integration uninstall claude
@@ -242,6 +243,7 @@ herdr integration uninstall kimi
herdr integration uninstall opencode
herdr integration uninstall hermes
herdr integration uninstall qodercli
+herdr integration uninstall cursor
herdr integration status [--outdated-only]
```
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 264e559e..efe20fbc 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -1,6 +1,6 @@
---
title: Integrations
-description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Hermes Agent, and Qoder CLI.
+description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Hermes Agent, Qoder CLI, and Cursor Agent CLI.
---
Herdr detects supported agents automatically. Official integrations can add native session identity for restore, semantic state reports, or both. Claude Code, Codex, Droid, and OpenCode state comes from screen detection even when their session identity integrations are installed.
@@ -22,6 +22,7 @@ herdr integration install kimi
herdr integration install opencode
herdr integration install hermes
herdr integration install qodercli
+herdr integration install cursor
```
## Uninstall integrations
@@ -37,6 +38,7 @@ herdr integration uninstall kimi
herdr integration uninstall opencode
herdr integration uninstall hermes
herdr integration uninstall qodercli
+herdr integration uninstall cursor
```
## How Herdr uses integrations
@@ -49,11 +51,11 @@ Herdr combines three signals:
| Screen heuristics | Infers visible native agent state such as `working` or `blocked`. |
| Integration events | Reports native session identity, semantic state, or both depending on the integration. |
-Claude Code, Codex, Droid, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP, Kimi Code CLI, and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can report state when they define state that is not visible in the native terminal UI.
+Claude Code, Codex, Cursor Agent CLI, Droid, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP, Kimi Code CLI, and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can report state when they define state that is not visible in the native terminal UI.
-Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, GitHub Copilot CLI, Droid, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
+Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Cursor Agent CLI, GitHub Copilot CLI, Droid, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
-Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, GitHub Copilot CLI version `1`, Droid version `1`, OpenCode version `4`, or Hermes Agent version `2`. OMP integration version `2`, Kimi Code CLI integration version `1`, and Qoder CLI integration version `1` report agent state only. Check installed versions with `herdr integration status`.
+Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, Cursor Agent CLI version `1`, GitHub Copilot CLI version `1`, Droid version `1`, OpenCode version `4`, or Hermes Agent version `2`. OMP integration version `2`, Kimi Code CLI integration version `1`, and Qoder CLI integration version `1` report agent state only. Check installed versions with `herdr integration status`.
## Pi
@@ -193,6 +195,20 @@ Herdr uses `~/.qoder` by default, or `QODER_CONFIG_DIR` when set. The Qoder conf
Native screen heuristics remain available when the hook is not installed.
+## Cursor Agent CLI
+
+Install the Cursor Agent CLI hook:
+
+```bash
+herdr integration install cursor
+```
+
+The hook reports session identity through Cursor's `sessionStart` hook while Cursor Agent CLI runs inside a Herdr pane. Cursor state comes from Herdr's screen detection.
+
+Herdr uses `~/.cursor` by default, or `CURSOR_CONFIG_DIR` when set. The Cursor config directory must already exist. Install writes `herdr-agent-state.sh` and adds a Herdr `sessionStart` entry to `hooks.json`. Uninstall removes the matching hook entry and deletes the hook script.
+
+After Cursor emits a session start event, Herdr can use the reported session id to resume the pane with `cursor-agent --resume <id>`. The `cursor-agent` command must be on `PATH` when Herdr restores the pane; Herdr does not launch the generic `agent` command.
+
## Custom status labels
Integrations can report a short visual label without changing the semantic state.
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index 53a6a42a..ceaf5bb0 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -58,6 +58,7 @@ Native session restore requires these Herdr integration versions or newer:
| Pi | `2` | `pi --session <path-or-id>` |
| Claude Code | `5` | `claude --resume <id>` |
| Codex | `5` | `codex resume <id>` |
+| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` |
| GitHub Copilot CLI | `1` | `copilot --resume=<id>` |
| Droid | `1` | `droid --resume <id>` |
| OpenCode | `4` | `opencode --session <id>` |
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 5c852dfa..d166aed2 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -74,6 +74,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool {
(source, agent),
("herdr:claude", "claude")
| ("herdr:codex", "codex")
+ | ("herdr:cursor", "cursor")
| ("herdr:droid", "droid")
| ("herdr:opencode", "opencode")
)
@@ -139,6 +140,13 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<
session_ref.value.clone(),
]
}
+ ("herdr:cursor", "cursor", AgentSessionRefKind::Id) => {
+ vec![
+ "cursor-agent".into(),
+ "--resume".into(),
+ session_ref.value.clone(),
+ ]
+ }
_ => return None,
};
@@ -166,6 +174,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool {
| ("herdr:pi", "pi")
| ("herdr:hermes", "hermes")
| ("herdr:opencode", "opencode")
+ | ("herdr:cursor", "cursor")
)
}
@@ -265,6 +274,16 @@ mod tests {
.argv,
vec!["opencode", "--session", "opencode-session"]
);
+ assert_eq!(
+ plan(
+ "herdr:cursor",
+ "cursor",
+ &AgentSessionRef::id("cursor-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["cursor-agent", "--resume", "cursor-session"]
+ );
}
#[test]
diff --git a/src/api/schema.rs b/src/api/schema.rs
index ce1ca9bd..28be9c55 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -449,6 +449,7 @@ pub enum IntegrationTarget {
Opencode,
Hermes,
Qodercli,
+ Cursor,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/src/cli/integration.rs b/src/cli/integration.rs
index 148085d2..a793e4de 100644
--- a/src/cli/integration.rs
+++ b/src/cli/integration.rs
@@ -103,13 +103,13 @@ fn parse_integration_target(
) -> std::io::Result<Option<IntegrationTarget>> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli|cursor>"
);
return Ok(None);
};
if args.len() != 1 {
eprintln!(
- "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli>"
+ "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli|cursor>"
);
return Ok(None);
}
@@ -125,10 +125,11 @@ fn parse_integration_target(
"opencode" => IntegrationTarget::Opencode,
"hermes" => IntegrationTarget::Hermes,
"qodercli" => IntegrationTarget::Qodercli,
+ "cursor" => IntegrationTarget::Cursor,
_ => {
eprintln!("unknown integration target: {target}");
eprintln!(
- "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, hermes, qodercli"
+ "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, hermes, qodercli, cursor"
);
return Ok(None);
}
@@ -149,6 +150,7 @@ fn print_integration_help() {
eprintln!(" herdr integration install opencode");
eprintln!(" herdr integration install hermes");
eprintln!(" herdr integration install qodercli");
+ eprintln!(" herdr integration install cursor");
eprintln!(" herdr integration uninstall pi");
eprintln!(" herdr integration uninstall omp");
eprintln!(" herdr integration uninstall claude");
@@ -159,5 +161,6 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall opencode");
eprintln!(" herdr integration uninstall hermes");
eprintln!(" herdr integration uninstall qodercli");
+ eprintln!(" herdr integration uninstall cursor");
eprintln!(" herdr integration status [--outdated-only]");
}
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);
+ }
}