diff --git a/README.md b/README.md index 3ac820acc..4ff2d7da1 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ shared runtime for scopes, policy, plugins, and lifecycle events. | Goal | Start With... | |---|---| -| Observe Codex, Claude Code, Cursor, or Hermes locally via CLI | [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) | +| Observe Codex, Claude Code, or Hermes locally via CLI | [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) | | Instrument app-owned LLM or tool calls | [Quick Start Application](https://docs.nvidia.com/nemo/relay/getting-started/quick-start) | | Use LangChain, LangGraph, Deep Agents, or OpenClaw | [Supported Integrations](https://docs.nvidia.com/nemo/relay/supported-integrations/about) | | Build a framework or provider integration | [Integrate into Frameworks](https://docs.nvidia.com/nemo/relay/integrate-into-frameworks/about) | @@ -282,7 +282,6 @@ coverage. | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | | Codex | Yes | Yes | Partial | Hook activation is required; missing session-end behavior limits trajectory finalization and full optimization coverage. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | -| Cursor | Partial | Limited | No | Missing hooks under `cursor-agent` and manual gateway routing limit full feature coverage. | ### Public API Integrations diff --git a/crates/cli/README.md b/crates/cli/README.md index ee1847d8e..8f277af70 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -27,8 +27,8 @@ with the installed `nemo-relay` command rather than link against the crate. ## Why Use It? -- 🧭 **Observe existing coding agents**: Run Claude Code, Codex, Cursor, or - Hermes Agent through a local NeMo Relay gateway without changing the agent +- 🧭 **Observe existing coding agents**: Run Claude Code, Codex, or Hermes + Agent through a local NeMo Relay gateway without changing the agent itself. - 🛠️ **Configure hooks interactively**: Use the setup wizard to write project or user config and install the hook files needed by supported agents. @@ -43,8 +43,8 @@ with the installed `nemo-relay` command rather than link against the crate. Cargo package. - ✅ **First-run setup**: Bare `nemo-relay` launches setup when no config exists, then runs doctor once config is present. -- ✅ **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, - `nemo-relay cursor`, and `nemo-relay hermes` start observed agent runs. +- ✅ **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, and + `nemo-relay hermes` start observed agent runs. - ✅ **Config-driven launch**: `nemo-relay run` resolves config, environment, and CLI overrides for deterministic non-interactive use. - ✅ **Hook forwarding server**: A local gateway accepts agent hook events and diff --git a/crates/cli/src/adapters/cursor.rs b/crates/cli/src/adapters/cursor.rs deleted file mode 100644 index 72518baa2..000000000 --- a/crates/cli/src/adapters/cursor.rs +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use axum::http::HeaderMap; -use serde_json::{Value, json}; - -use crate::adapters::{AdapterOutcome, ClassificationRules, classify}; -use crate::model::{AgentKind, NormalizedEvent}; - -/// Normalizes Cursor hook payloads and returns Cursor-compatible continuation decisions. -/// -/// Cursor has separate shell and MCP hook names, both of which are collapsed into normal tool -/// start/end events. Tool starts are fail-open with an explicit `allow` permission response so -/// the gateway records activity without becoming a policy engine for Cursor executions. -pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { - let events = classify( - &payload, - headers, - &ClassificationRules { - kind: AgentKind::Cursor, - agent_start: &["sessionStart", "session_start"], - agent_end: &["sessionEnd", "session_end", "stop"], - subagent_start: &["subagentStart", "subagent_start"], - subagent_end: &["subagentStop", "subagentEnd", "subagent_stop"], - tool_start: &["preToolUse", "beforeShellExecution", "beforeMCPExecution"], - tool_end: &[ - "postToolUse", - "afterShellExecution", - "afterMCPExecution", - "postToolUseFailure", - ], - }, - ); - // Response shape is determined by the primary event (first in the vec). - let response = match events.first() { - Some(NormalizedEvent::ToolStarted(_)) => json!({ - "continue": true, - "permission": "allow" - }), - Some(NormalizedEvent::AgentEnded(_)) => json!({ "continue": true }), - _ => json!({ "continue": true }), - }; - AdapterOutcome { events, response } -} diff --git a/crates/cli/src/adapters/mod.rs b/crates/cli/src/adapters/mod.rs index dd684f4fe..17c77b228 100644 --- a/crates/cli/src/adapters/mod.rs +++ b/crates/cli/src/adapters/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod claude_code; pub(crate) mod codex; -pub(crate) mod cursor; pub(crate) mod hermes; use axum::http::HeaderMap; @@ -375,10 +374,9 @@ fn value_at(payload: &Value, path: &[&str]) -> Option { // closing the agent scope. Codex 0.129 has no `SessionEnd`-equivalent hook — without this dual // emission, codex transparent runs would never trigger an ATIF write. // -// If the primary event is already terminal (e.g., Cursor classifies `stop` as `AgentEnded`), -// the snapshot is skipped to avoid double-writing — `flush_observers` already writes ATIF on -// agent-end, and a follow-up `TurnEnded` on a removed session would recreate an empty session -// and overwrite the freshly-written ATIF with an empty trajectory. +// If the primary event is already terminal, the snapshot is skipped to avoid double-writing — +// `flush_observers` already writes ATIF on agent-end, and a follow-up `TurnEnded` on a removed +// session would recreate an empty session and overwrite the freshly-written ATIF. fn classify( payload: &Value, headers: &HeaderMap, diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index ca2b3c84a..8a7b39dda 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -58,18 +58,6 @@ pub(crate) enum Command { nemo-relay --openai-base-url https://inference-api.nvidia.com codex" )] Codex(EasyPathCommand), - /// Run Cursor with observability (setup on first use) - #[command( - long_about = "Run Cursor's `cursor-agent` CLI under an ephemeral NeMo Relay gateway. The \ - launcher temporarily patches `.cursor/hooks.json` in the project root \ - during the run and restores it on exit. Disable that via \ - `[agents.cursor] patch_restore_hooks = false` in config.toml if you \ - maintain `.cursor/hooks.json` yourself.", - after_help = "Examples:\n \ - nemo-relay cursor\n \ - nemo-relay cursor -- agent --resume " - )] - Cursor(EasyPathCommand), /// Run Hermes with observability (setup on first use) #[command( long_about = "Run NVIDIA's Hermes agent under a NeMo Relay gateway. Hermes reads hooks \ @@ -538,7 +526,6 @@ pub(crate) enum CodingAgent { #[value(name = "claude", alias = "claude-code")] ClaudeCode, Codex, - Cursor, Hermes, } @@ -626,7 +613,6 @@ impl ResolvedDynamicPluginConfig { pub(crate) struct AgentConfigs { pub(crate) claude: AgentCommandConfig, pub(crate) codex: AgentCommandConfig, - pub(crate) cursor: CursorAgentConfig, pub(crate) hermes: AgentCommandConfig, } @@ -638,24 +624,6 @@ pub(crate) struct AgentCommandConfig { pub(crate) hooks_path: Option, } -#[derive(Debug, Clone)] -pub(crate) struct CursorAgentConfig { - pub(crate) command: Option, - pub(crate) patch_restore_hooks: bool, -} - -impl Default for CursorAgentConfig { - // Keeps Cursor run-mode patching enabled unless a config file opts out. Cursor's CLI discovers - // hooks from project files, so the launcher needs permission to temporarily patch and restore - // `.cursor/hooks.json` by default. - fn default() -> Self { - Self { - command: None, - patch_restore_hooks: true, - } - } -} - // TOML file shape grouped by user intent. Sections map 1:1 onto fields already present on // `GatewayConfig` / `AgentConfigs`; plugin config is passed through to the runtime's generic // `PluginConfig` activation path. @@ -687,12 +655,11 @@ struct FilePluginsConfig { #[derive(Debug, Clone, Default, Deserialize)] struct FileAgentsConfig { - // Keys match the agent's CLI invocation name (`claude`, `codex`, `cursor`, `hermes`) — the + // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`) — the // word the user types at the shell — not the product name ("Claude Code") or the internal // `CodingAgent` enum kebab spelling. Same convention as the bare-agent shortcut in Phase 2. claude: Option, codex: Option, - cursor: Option, hermes: Option, } @@ -702,12 +669,6 @@ struct FileAgentCommandConfig { hooks_path: Option, } -#[derive(Debug, Clone, Default, Deserialize)] -struct FileCursorAgentConfig { - command: Option, - patch_restore_hooks: Option, -} - impl Default for GatewayConfig { // Supplies conservative local gateway defaults: bind only to loopback, route OpenAI and // Anthropic requests to their public bases, and leave plugins disabled until config, @@ -988,8 +949,7 @@ pub(crate) fn user_config_dir() -> Option { } // Applies the typed TOML config model to the resolved runtime config. Missing sections and fields -// are ignored, preserving defaults and prior merge layers; Cursor's patch-restore flag is only -// changed when explicitly present. +// are ignored, preserving defaults and prior merge layers. fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> { let config: FileConfig = value.try_into().map_err(|error| { CliError::Config(format!("invalid gateway configuration shape: {error}")) @@ -1266,9 +1226,7 @@ fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> Result<() Ok(()) } -// Applies configured agent commands and Cursor's temporary-hook behavior. Cursor's -// `patch_restore_hooks` flag is intentionally tri-state in file config so omitted values preserve -// the safe default while explicit `false` disables temporary hook mutation. +// Applies configured agent commands from the merged file configuration. fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option) { let Some(file_agents) = file_agents else { return; @@ -1279,12 +1237,6 @@ fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option "/hooks/claude-code", Self::Codex => "/hooks/codex", - Self::Cursor => "/hooks/cursor", Self::Hermes => "/hooks/hermes", } } @@ -1423,7 +1374,6 @@ impl CodingAgent { match self { Self::ClaudeCode => "claude", Self::Codex => "codex", - Self::Cursor => "cursor", Self::Hermes => "hermes", } } @@ -1438,7 +1388,6 @@ impl CodingAgent { match name { "claude" | "claude-code" => Some(Self::ClaudeCode), "codex" => Some(Self::Codex), - "cursor" | "cursor-agent" => Some(Self::Cursor), "hermes" | "hermes-agent" => Some(Self::Hermes), _ => None, } diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 5b1f1c32a..f488e82fa 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -295,7 +295,6 @@ async fn collect_agents( let supported = [ (CodingAgent::ClaudeCode, "claude", "claude"), (CodingAgent::Codex, "codex", "codex"), - (CodingAgent::Cursor, "cursor", "cursor-agent"), (CodingAgent::Hermes, "hermes", "hermes"), ]; let mut out = Vec::with_capacity(supported.len()); @@ -372,7 +371,6 @@ fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option match agent { CodingAgent::ClaudeCode => agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), - CodingAgent::Cursor => agents.cursor.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), } } @@ -386,7 +384,6 @@ fn configured_agent_names(agents: &AgentConfigs) -> Vec { [ (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), - (CodingAgent::Cursor, "cursor"), (CodingAgent::Hermes, "hermes"), ] .into_iter() @@ -422,15 +419,6 @@ fn hook_status( CodingAgent::ClaudeCode | CodingAgent::Codex => { (Status::Pass, "hooks: injected during run".into()) } - CodingAgent::Cursor if agents.cursor.patch_restore_hooks => { - (Status::Pass, "hooks: patched during run".into()) - } - CodingAgent::Cursor => hook_file_status( - cursor_hooks_path(), - CodingAgent::Cursor, - readiness_required, - "hooks: user-managed", - ), CodingAgent::Hermes => match agents.hermes.hooks_path.as_deref() { Some(path) => hook_file_status( Ok(path.to_path_buf()), @@ -463,9 +451,6 @@ fn hook_file_status( } }; match std::fs::read_to_string(&path) { - Ok(raw) if matches!(agent, CodingAgent::Cursor) => { - cursor_hook_file_status(&raw, &path, readiness_required, label) - } Ok(raw) if raw.contains(&format!("hook-forward {}", agent.as_arg())) => ( Status::Pass, format!("{label}: installed at {}", path.display()), @@ -491,115 +476,6 @@ fn hook_file_status( } } -fn cursor_hook_file_status( - raw: &str, - path: &Path, - readiness_required: bool, - label: &str, -) -> (Status, String) { - let has_nemo_hook = raw.contains("hook-forward cursor"); - if !has_nemo_hook { - if readiness_required { - return ( - Status::Fail, - format!("{label}: missing NeMo Relay hook in {}", path.display()), - ); - } - return ( - Status::Info, - format!("{label}: no NeMo Relay hook in {}", path.display()), - ); - } - - let parsed: Value = match serde_json::from_str(raw) { - Ok(parsed) => parsed, - Err(err) => { - return ( - Status::Fail, - format!( - "{label}: invalid Cursor hooks JSON in {}: {err}", - path.display() - ), - ); - } - }; - - if parsed.get("version").and_then(Value::as_u64) != Some(1) { - return ( - Status::Fail, - format!( - "{label}: Cursor hook file {} must set top-level `version` to 1", - path.display() - ), - ); - } - - let Some(hooks) = parsed.get("hooks").and_then(Value::as_object) else { - return ( - Status::Fail, - format!( - "{label}: Cursor hook file {} has no hooks object", - path.display() - ), - ); - }; - let has_direct_nemo_hook = hooks.values().any(cursor_event_has_direct_nemo_hook); - if has_nested_hook_group(&parsed) { - return ( - Status::Fail, - format!( - "{label}: Cursor hook file {} uses nested hook groups; Cursor CLI requires direct command entries", - path.display() - ), - ); - } - if !has_direct_nemo_hook { - return ( - Status::Fail, - format!( - "{label}: Cursor hook file {} has no direct NeMo Relay command entries", - path.display() - ), - ); - } - - ( - Status::Pass, - format!("{label}: installed at {}", path.display()), - ) -} - -fn cursor_event_has_direct_nemo_hook(event_hooks: &Value) -> bool { - event_hooks.as_array().is_some_and(|entries| { - entries.iter().any(|entry| { - entry - .get("command") - .and_then(Value::as_str) - .is_some_and(|command| command.contains("hook-forward cursor")) - }) - }) -} - -fn has_nested_hook_group(value: &Value) -> bool { - match value { - Value::Object(object) => { - let nested_here = object.get("hooks").is_some_and(Value::is_array); - nested_here || object.values().any(has_nested_hook_group) - } - Value::Array(items) => items.iter().any(has_nested_hook_group), - _ => false, - } -} - -fn cursor_hooks_path() -> Result { - let cwd = std::env::current_dir()?; - let project = cwd - .ancestors() - .find(|ancestor| ancestor.join(".cursor").is_dir()) - .unwrap_or(cwd.as_path()); - Ok(project.join(".cursor/hooks.json")) -} - async fn probe_version(binary: &Path) -> Option { // Spawn ` --version` and read the first line of stdout. Bounded by the network // timeout (re-used as a generic short timeout) so a misbehaving binary doesn't hang doctor. diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 9cdf98b09..211d84a3d 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::io::Read; -use std::path::Path; use std::time::Duration; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; @@ -32,23 +31,6 @@ const HOOK_EVENTS: &[&str] = &[ "SessionEnd", ]; -const CURSOR_HOOK_EVENTS: &[&str] = &[ - "sessionStart", - "beforeSubmitPrompt", - "preToolUse", - "beforeShellExecution", - "beforeMCPExecution", - "postToolUse", - "afterShellExecution", - "afterMCPExecution", - "subagentStart", - "subagentStop", - "afterAgentResponse", - "afterAgentThought", - "preCompact", - "stop", - "sessionEnd", -]; const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); const HERMES_HOOK_EVENTS: &[&str] = &[ @@ -216,12 +198,11 @@ fn resolve_hook_gateway_url( /// Generates native hook configuration for the selected agent. /// /// The returned value always has a top-level `hooks` object. Claude/Codex use command hook -/// groups with optional tool matchers, while Cursor and Hermes use direct command entries. +/// groups with optional tool matchers, while Hermes uses direct command entries. pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { match agent { CodingAgent::ClaudeCode => claude_hooks(command), CodingAgent::Codex => codex_hooks(command), - CodingAgent::Cursor => cursor_hooks(command), CodingAgent::Hermes => hermes_hooks(command), } } @@ -229,7 +210,7 @@ pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { // Returns the shell command a hook should run to forward an event to the gateway. Callers must // pass the executable they want hooks to invoke. Transparent-run callers should pass the absolute // path of the currently running gateway binary so spawned hook subprocesses do not depend on the -// user's `PATH` (which Codex/Claude/Cursor inherit but which typically does not include +// user's `PATH` (which Codex/Claude inherit but which typically does not include // `target/debug` or other dev locations); persistent-install callers can pass the bare name // `"nemo-relay"` because the user is expected to have the binary on `PATH` after install. pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> String { @@ -244,10 +225,6 @@ fn codex_hooks(command: &str) -> Value { hooks_for_events(HOOK_EVENTS, command, true) } -fn cursor_hooks(command: &str) -> Value { - direct_command_hooks_for_events(CURSOR_HOOK_EVENTS, command) -} - // Generates Hermes YAML-compatible hook groups. Hermes expects direct command entries rather than // the nested `type = command` group format used by Claude and Codex. pub(crate) fn hermes_hooks(command: &str) -> Value { @@ -294,29 +271,8 @@ fn hooks_for_events(events: &[&str], command: &str, matcher_for_tools: bool) -> json!({ "hooks": Value::Object(hooks) }) } -// Cursor CLI 2026.05 accepts direct command entries in `.cursor/hooks.json`; it does not execute -// the nested hook-group shape used by Claude Code and Codex. -fn direct_command_hooks_for_events(events: &[&str], command: &str) -> Value { - let hooks: serde_json::Map = events - .iter() - .map(|event| { - ( - (*event).to_string(), - json!([{ - "command": command, - "timeout": 30 - }]), - ) - }) - .collect(); - json!({ - "version": 1, - "hooks": Value::Object(hooks) - }) -} - // Identifies hook events that should receive wildcard tool matchers. The list includes current -// Claude/Codex spellings. Cursor uses direct command hooks and does not call this helper. +// Claude/Codex spellings. fn event_matches_tools(event: &str) -> bool { matches!( event, @@ -406,20 +362,6 @@ pub(crate) fn merge_hermes_config(existing: &str, generated: Value) -> Result Result { - match std::fs::read_to_string(path) { - Ok(raw) => serde_json::from_str(&raw).map_err(|error| { - CliError::Install(format!("invalid JSON in {}: {error}", path.display())) - }), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Value::Null), - Err(error) => Err(CliError::Io(error)), - } -} - // Validates optional JSON strings before they are embedded into hook-forward headers. Catches // quoting/config mistakes at hook-fire time rather than after the request reaches the gateway. fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index d27b054d5..d5be4eb2c 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -19,9 +19,7 @@ use crate::config::{ ServerArgs, any_config_file_exists, resolve_run_config, }; use crate::error::CliError; -use crate::installer::{ - generated_hooks, hook_forward_command, merge_hermes_config, merge_hooks, read_json_file, -}; +use crate::installer::{generated_hooks, hook_forward_command, merge_hermes_config}; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::server; @@ -231,7 +229,6 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { match agent { CodingAgent::ClaudeCode => "claude", CodingAgent::Codex => "codex", - CodingAgent::Cursor => "cursor-agent", CodingAgent::Hermes => "hermes", } } @@ -244,7 +241,7 @@ fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result Option agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), - CodingAgent::Cursor => agents.cursor.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), }?; let argv: Vec<_> = command.split_whitespace().map(ToOwned::to_owned).collect(); @@ -268,17 +264,10 @@ struct PreparedRun { argv: Vec, env: Vec<(String, String)>, temp_dirs: Vec, - cursor_restore: Option, hermes_restore: Option, notes: Vec, } -struct CursorRestore { - path: PathBuf, - backup_path: Option, - had_original: bool, -} - struct HermesRestore { path: PathBuf, backup_path: Option, @@ -336,7 +325,6 @@ impl PreparedRun { argv, env: vec![("NEMO_RELAY_GATEWAY_URL".into(), gateway_url.into())], temp_dirs: Vec::new(), - cursor_restore: None, hermes_restore: None, notes: Vec::new(), }; @@ -352,15 +340,6 @@ impl PreparedRun { } } CodingAgent::Codex => run.prepare_codex(gateway_url), - CodingAgent::Cursor => { - if resolved.agents.cursor.patch_restore_hooks { - if dry_run { - run.prepare_cursor_dry()?; - } else { - run.prepare_cursor()?; - } - } - } CodingAgent::Hermes => { if dry_run { run.prepare_hermes_dry(resolved.agents.hermes.hooks_path.as_deref())?; @@ -474,32 +453,6 @@ impl PreparedRun { insert_after_agent(&mut self.argv, CodingAgent::Codex, args); } - // Temporarily merges Cursor hooks into the nearest project `.cursor/hooks.json`, backing up the - // original if it exists. Cursor discovers hooks from files, so run mode patches and later - // restores project state rather than passing hook config on the command line. - fn prepare_cursor(&mut self) -> Result<(), CliError> { - let path = cursor_hooks_path()?; - let (had_original, backup_path) = backup_existing_cursor_hooks(&path)?; - write_merged_cursor_hooks(&path)?; - self.cursor_restore = Some(CursorRestore { - path, - backup_path, - had_original, - }); - Ok(()) - } - - // Records the Cursor hook file that would be patched during a real run without touching the - // filesystem, preserving dry-run as an inspection-only operation. - fn prepare_cursor_dry(&mut self) -> Result<(), CliError> { - let path = cursor_hooks_path()?; - self.notes.push(format!( - "would temporarily merge NeMo Relay hooks into {}", - path.display() - )); - Ok(()) - } - // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. For // transparent runs, temporarily merge gateway hook-forward entries into the configured Hermes // hook file, then restore it after the child exits. @@ -551,14 +504,6 @@ impl PreparedRun { let _ = std::fs::remove_dir_all(dir); } - if let Some(cursor) = &self.cursor_restore { - restore_hook_file( - &cursor.path, - cursor.backup_path.as_deref(), - cursor.had_original, - "Cursor", - )?; - } if let Some(hermes) = &self.hermes_restore { restore_hook_file( &hermes.path, @@ -662,9 +607,6 @@ impl PreparedRun { for (name, value) in &self.env { println!("env.{name} = {value}"); } - if let Some(cursor) = &self.cursor_restore { - println!("cursor_hooks = {}", cursor.path.display()); - } for note in &self.notes { println!("note = {note}"); } @@ -887,18 +829,6 @@ fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { Ok(()) } -// Backs up an existing Cursor hook file before run-mode patching. The return value records both the -// original-file state and backup path so restore can either copy back or remove the generated file. -fn backup_existing_cursor_hooks(path: &Path) -> Result<(bool, Option), CliError> { - let had_original = path.exists(); - if !had_original { - return Ok((false, None)); - } - let backup = path.with_extension(format!("json.nemo-relay-run.bak.{}", timestamp()?)); - std::fs::copy(path, &backup)?; - Ok((true, Some(backup))) -} - // Backs up an existing Hermes hook config before run-mode patching. fn backup_existing_hermes_hooks(path: &Path) -> Result<(bool, Option), CliError> { let had_original = path.exists(); @@ -910,28 +840,6 @@ fn backup_existing_hermes_hooks(path: &Path) -> Result<(bool, Option), Ok((true, Some(backup))) } -// Creates the Cursor hooks parent directory when needed, merges generated gateway hooks with any -// existing hook file, and writes the patched JSON used for this transparent run. -fn write_merged_cursor_hooks(path: &Path) -> Result<(), CliError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let mut merged = merge_hooks( - read_json_file(path)?, - generated_hooks( - CodingAgent::Cursor, - &hook_forward_command(&transparent_hook_executable(), CodingAgent::Cursor), - ), - )?; - if let Some(root) = merged.as_object_mut() { - root.insert("version".to_string(), json!(1)); - } - let contents = serde_json::to_string_pretty(&merged) - .map_err(|error| CliError::Launch(error.to_string()))?; - std::fs::write(path, contents)?; - Ok(()) -} - // Creates the Hermes config parent directory when needed, merges generated gateway hooks with any // existing YAML config, and writes the patched YAML used for this transparent run. fn write_merged_hermes_hooks(path: &Path) -> Result<(), CliError> { @@ -1033,17 +941,6 @@ fn temp_dir(prefix: &str) -> Result { Ok(path) } -// Locates Cursor's project hook file by walking up to the nearest ancestor that already contains a -// `.cursor` directory, falling back to the current directory for first-time project setup. -fn cursor_hooks_path() -> Result { - let cwd = std::env::current_dir()?; - let project = cwd - .ancestors() - .find(|ancestor| ancestor.join(".cursor").is_dir()) - .unwrap_or(cwd.as_path()); - Ok(project.join(".cursor/hooks.json")) -} - // Returns a monotonic-enough wall-clock nanosecond stamp for temp and backup names. System time // errors become launcher errors because paths cannot be safely generated without a timestamp. fn timestamp() -> Result { diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index fc3813018..9e8ec5002 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -77,9 +77,6 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Codex, command, Some(server)).await } - Command::Cursor(command) => { - launcher::easy_path(CodingAgent::Cursor, command, Some(server)).await - } Command::Hermes(command) => { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } diff --git a/crates/cli/src/model.rs b/crates/cli/src/model.rs index c6390e33f..4d7e8a792 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/model.rs @@ -7,7 +7,6 @@ use serde_json::Value; pub(crate) enum AgentKind { Codex, ClaudeCode, - Cursor, Hermes, Gateway, } @@ -19,7 +18,6 @@ impl AgentKind { match self { Self::Codex => "codex", Self::ClaudeCode => "claude-code", - Self::Cursor => "cursor", Self::Hermes => "hermes", Self::Gateway => "gateway", } @@ -31,7 +29,7 @@ pub(crate) enum NormalizedEvent { AgentStarted(SessionEvent), AgentEnded(SessionEvent), /// Conversation-turn boundary that the gateway uses to snapshot ATIF without closing the - /// agent scope. Emitted alongside `LlmHint` for `Stop` hooks (Claude/Codex/Cursor). + /// agent scope. Emitted alongside `LlmHint` for `Stop` hooks (Claude/Codex). /// Required for codex 0.129 transparent runs because codex has no `SessionEnd`-equivalent /// event — the last `Stop` of the session leaves an up-to-date ATIF on disk. Multi-turn /// sessions write progressively complete trajectories; the underlying `AtifExporter::export()` diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index dc6977917..584603bea 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -23,7 +23,7 @@ use serde_json::Value; use tokio::net::TcpListener; use tokio::sync::oneshot; -use crate::adapters::{claude_code, codex, cursor, hermes}; +use crate::adapters::{claude_code, codex, hermes}; use crate::config::GatewayConfig; use crate::error::CliError; use crate::gateway; @@ -169,7 +169,6 @@ fn router_with_state(state: AppState) -> Router { .route("/healthz", get(healthz)) .route("/hooks/codex", post(codex_hook)) .route("/hooks/claude-code", post(claude_code_hook)) - .route("/hooks/cursor", post(cursor_hook)) .route("/hooks/hermes", post(hermes_hook)) .route("/responses", post(gateway::passthrough)) .route("/chat/completions", post(gateway::passthrough)) @@ -372,23 +371,6 @@ async fn claude_code_hook( Ok(Json(outcome.response)) } -// Handles Cursor hook payloads and preserves Cursor's fail-open response shape. Shell and MCP hook -// names are already normalized by the adapter before session state is updated. -async fn cursor_hook( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, CliError> { - state.touch(); - let Json(payload) = payload.map_err(hook_payload_rejection)?; - let outcome = cursor::adapt(payload, &headers); - state - .sessions - .apply_events(&headers, outcome.events) - .await?; - Ok(Json(outcome.response)) -} - // Handles Hermes hook payloads from persistent shell integration. The adapter returns a minimal // body because hook-forward owns the fail-open/fail-closed behavior for Hermes command execution. async fn hermes_hook( diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 7b6efe709..627bb8126 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -60,7 +60,7 @@ pub(crate) fn prompt_user( } // Only print the detected-agents listing for the unscoped wizard (`nemo-relay config`), // where the user is about to pick from the multi-select. When the agent was already chosen - // via the easy-path shortcut (`nemo-relay codex`), listing the other three agents is noise. + // via the easy-path shortcut (`nemo-relay codex`), listing the other two agents is noise. if agent_hint.is_none() { println!(); print_detected_agents(detected_agents); @@ -182,8 +182,8 @@ fn ask_scope( ) -> Result { let options = [ConfigScope::Project, ConfigScope::Global, ConfigScope::Both]; let labels: Vec<&str> = options.iter().map(|s| s.label()).collect(); - // Cursor starts on the user's existing scope if there is one (so re-running the wizard - // doesn't accidentally relocate their config), else `Project` per the design default. + // Start on the user's existing scope if there is one (so re-running the wizard doesn't + // accidentally relocate their config), else `Project` per the design default. let default_idx = existing .and_then(|s| options.iter().position(|opt| *opt == s)) .unwrap_or(0); @@ -204,7 +204,6 @@ fn ask_agents( let all_supported = [ CodingAgent::ClaudeCode, CodingAgent::Codex, - CodingAgent::Cursor, CodingAgent::Hermes, ]; let labels: Vec = all_supported diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 108afd0a6..45b137518 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -60,7 +60,6 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> let candidates = [ (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), - (CodingAgent::Cursor, "cursor-agent"), (CodingAgent::Hermes, "hermes"), ]; candidates @@ -387,7 +386,6 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { let agent = match key { "claude" => Some(CodingAgent::ClaudeCode), "codex" => Some(CodingAgent::Codex), - "cursor" => Some(CodingAgent::Cursor), "hermes" => Some(CodingAgent::Hermes), _ => None, }; @@ -402,7 +400,6 @@ pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'stat match agent { CodingAgent::ClaudeCode => ("claude", "claude"), CodingAgent::Codex => ("codex", "codex"), - CodingAgent::Cursor => ("cursor", "cursor-agent"), CodingAgent::Hermes => ("hermes", "hermes"), } } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 291aab3e0..8309e2b61 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -1172,12 +1172,29 @@ fn cli_help_lists_easy_path_agent_shortcuts() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); let stdout = String::from_utf8_lossy(&output.stdout); - for agent in ["claude", "codex", "cursor", "hermes"] { + for agent in ["claude", "codex", "hermes"] { assert!( stdout.contains(&format!(" {agent}")), "expected `--help` to list `{agent}` subcommand, got:\n{stdout}" ); } + assert!(!stdout.contains(" cursor")); +} + +#[test] +fn cli_rejects_removed_cursor_entry_points() { + let output = Command::new(gateway_bin()).arg("cursor").output().unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("unrecognized subcommand 'cursor'")); + + let output = Command::new(gateway_bin()) + .args(["hook-forward", "cursor"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("invalid value 'cursor'")); } #[test] @@ -1626,7 +1643,7 @@ fn cli_hook_forward_reports_http_failure_when_fail_closed() { let mut child = Command::new(gateway_bin()) .args([ "hook-forward", - "cursor", + "hermes", "--gateway-url", &server_url, "--fail-closed", @@ -1641,7 +1658,7 @@ fn cli_hook_forward_reports_http_failure_when_fail_closed() { let request = received.recv().unwrap(); assert!(!output.status.success()); - assert!(request.contains("POST /hooks/cursor HTTP/1.1")); + assert!(request.contains("POST /hooks/hermes HTTP/1.1")); assert!(String::from_utf8_lossy(&output.stderr).contains("HTTP 503")); } diff --git a/crates/cli/tests/coverage/adapters_tests.rs b/crates/cli/tests/coverage/adapters_tests.rs index aa5208d70..8ca0acb8d 100644 --- a/crates/cli/tests/coverage/adapters_tests.rs +++ b/crates/cli/tests/coverage/adapters_tests.rs @@ -5,7 +5,7 @@ use axum::http::HeaderMap; use serde_json::json; use super::*; -use crate::adapters::{claude_code, codex, cursor, hermes}; +use crate::adapters::{claude_code, codex, hermes}; #[test] fn maps_claude_canonical_tool_payload() { @@ -161,9 +161,9 @@ fn maps_claude_stop_response_shape() { ); } -// Stop hook on Claude/Codex/Cursor (per-turn boundary) must yield a TurnEnded event so the -// session manager can snapshot ATIF without closing the agent scope. Codex needs this because -// it has no SessionEnd hook; Claude/Cursor get it for free for resilience. +// Stop hooks on Claude/Codex (per-turn boundary) must yield a TurnEnded event so the session +// manager can snapshot ATIF without closing the agent scope. Codex needs this because it has no +// SessionEnd hook; Claude gets it for free for resilience. #[test] fn stop_hook_emits_turn_ended_for_codex() { let outcome = codex::adapt( @@ -195,29 +195,6 @@ fn stop_hook_emits_turn_ended_for_claude() { ); } -// Cursor classifies `stop` as AgentEnded (its existing per-adapter rule). The TurnEnded path -// must NOT also fire there — flush_observers already writes ATIF on agent-end, and a follow-up -// snapshot on a removed session would recreate an empty session and overwrite the freshly -// written file with an empty trajectory. -#[test] -fn stop_hook_does_not_double_emit_for_cursor_agent_end() { - let outcome = cursor::adapt( - json!({ "session_id": "cursor-session", "hook_event_name": "stop" }), - &HeaderMap::new(), - ); - assert!( - matches!(outcome.events.first(), Some(NormalizedEvent::AgentEnded(_))), - "cursor stop must classify as AgentEnded" - ); - assert!( - !outcome - .events - .iter() - .any(|e| matches!(e, NormalizedEvent::TurnEnded(_))), - "cursor stop must NOT also produce TurnEnded — would double-write ATIF then wipe it" - ); -} - #[test] fn adapter_string_lookup_accepts_scalar_values_only() { let payload = json!({ @@ -231,36 +208,6 @@ fn adapter_string_lookup_accepts_scalar_values_only() { assert_eq!(string_at(&payload, &["object"]), None); } -#[test] -fn maps_cursor_subagent_and_permission_response() { - let headers = HeaderMap::new(); - let outcome = cursor::adapt( - json!({ - "session_id": "cursor-session", - "project_dir": "/repo", - "user_email": "dev@example.com", - "hook_event_name": "beforeShellExecution", - "subagent": { "id": "worker" }, - "tool_call_id": "shell-1", - "tool_name": "shell", - "input": { "command": "cargo test" } - }), - &headers, - ); - match &outcome.events[0] { - NormalizedEvent::ToolStarted(event) => { - assert_eq!(event.session_id, "cursor-session"); - assert_eq!(event.subagent_id.as_deref(), Some("worker")); - assert_eq!(event.metadata["project_dir"], json!("/repo")); - assert_eq!(event.metadata["user_email"], json!("dev@example.com")); - } - event => panic!("unexpected event: {event:?}"), - } - assert_eq!(outcome.response["permission"], json!("allow")); - assert!(outcome.response.get("user_message").is_none()); - assert!(outcome.response.get("agent_message").is_none()); -} - #[test] fn keeps_codex_response_unwrapped() { let headers = HeaderMap::new(); @@ -680,58 +627,6 @@ fn maps_hermes_null_request_as_lossy_summary() { } } -#[test] -fn normalizes_mark_style_events_and_header_session_ids() { - let mut headers = HeaderMap::new(); - headers.insert("x-nemo-relay-session-id", "header-session".parse().unwrap()); - headers.insert("x-nemo-relay-config-profile", "coverage".parse().unwrap()); - - for (event_name, expected) in [ - ("UserPromptSubmit", "prompt"), - ("afterAgentResponse", "response"), - ("PreCompact", "compact"), - ("Notification", "notification"), - ("Unrecognized.Event", "hook"), - ] { - let outcome = cursor::adapt( - json!({ - "eventName": event_name, - "model": "model-a", - "cwd": "/repo" - }), - &headers, - ); - let (session_id, metadata) = match &outcome.events[0] { - NormalizedEvent::PromptSubmitted(event) if expected == "prompt" => { - (event.session_id.as_str(), &event.metadata) - } - NormalizedEvent::LlmHint(event) if expected == "response" => { - (event.session_id.as_str(), &event.metadata) - } - NormalizedEvent::Compaction(event) if expected == "compact" => { - (event.session_id.as_str(), &event.metadata) - } - NormalizedEvent::Notification(event) if expected == "notification" => { - (event.session_id.as_str(), &event.metadata) - } - NormalizedEvent::HookMark(event) if expected == "hook" => { - (event.session_id.as_str(), &event.metadata) - } - event => panic!("unexpected event for {event_name}: {event:?}"), - }; - if expected == "prompt" { - assert!( - matches!(outcome.events.get(1), Some(NormalizedEvent::LlmHint(_))), - "prompt hooks should also emit a private LLM hint" - ); - } - assert_eq!(session_id, "header-session"); - assert_eq!(metadata["model"], json!("model-a")); - assert_eq!(metadata["cwd"], json!("/repo")); - assert_eq!(metadata["gateway_config_profile"], json!("coverage")); - } -} - #[test] fn maps_hermes_llm_hooks_to_private_hints() { let headers = HeaderMap::new(); @@ -830,14 +725,4 @@ fn stop_responses_preserve_vendor_shapes() { ); assert!(matches!(codex.events[0], NormalizedEvent::LlmHint(_))); assert_eq!(codex.response, json!({})); - - let cursor = cursor::adapt( - json!({ - "session_id": "cursor-session", - "hook_event_name": "stop" - }), - &headers, - ); - assert!(matches!(cursor.events[0], NormalizedEvent::AgentEnded(_))); - assert_eq!(cursor.response, json!({ "continue": true })); } diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d69c03c91..9ff1fb81f 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -216,7 +216,6 @@ fn session_config_uses_defaults_and_ignores_bad_json() { fn agent_and_gateway_mode_arguments_are_stable() { assert_eq!(CodingAgent::ClaudeCode.hook_path(), "/hooks/claude-code"); assert_eq!(CodingAgent::Codex.hook_path(), "/hooks/codex"); - assert_eq!(CodingAgent::Cursor.hook_path(), "/hooks/cursor"); assert_eq!(CodingAgent::Hermes.hook_path(), "/hooks/hermes"); assert_eq!(GatewayMode::HookOnly.as_arg(), "hook-only"); assert_eq!(GatewayMode::Passthrough.as_arg(), "passthrough"); @@ -230,10 +229,7 @@ fn agent_inference_uses_executable_basename() { Some(CodingAgent::ClaudeCode) ); assert_eq!(CodingAgent::infer("codex"), Some(CodingAgent::Codex)); - assert_eq!( - CodingAgent::infer("cursor-agent"), - Some(CodingAgent::Cursor) - ); + assert_eq!(CodingAgent::infer("cursor-agent"), None); assert_eq!(CodingAgent::infer("hermes"), Some(CodingAgent::Hermes)); assert_eq!(CodingAgent::infer("wrapper"), None); } @@ -262,10 +258,6 @@ command = "claude" [agents.codex] command = "codex --approval-mode never" -[agents.cursor] -command = "cursor-agent" -patch_restore_hooks = false - [agents.hermes] command = "hermes --yolo chat" "#, @@ -303,7 +295,6 @@ command = "hermes --yolo chat" resolved.agents.hermes.command.as_deref(), Some("hermes --yolo chat") ); - assert!(!resolved.agents.cursor.patch_restore_hooks); } #[test] diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 1c78093c2..e5ef9d997 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -451,12 +451,6 @@ fn agent_helper_statuses_cover_configured_target_and_hook_paths() { hook_status(CodingAgent::Codex, &agents, true), (Status::Pass, "hooks: injected during run".into()) ); - agents.cursor.patch_restore_hooks = true; - assert_eq!( - hook_status(CodingAgent::Cursor, &agents, true), - (Status::Pass, "hooks: patched during run".into()) - ); - let temp = tempfile::tempdir().unwrap(); let hook = temp.path().join("hooks.yaml"); std::fs::write(&hook, "cmd: nemo-relay hook-forward hermes\n").unwrap(); @@ -471,14 +465,13 @@ fn agent_helper_statuses_cover_configured_target_and_hook_paths() { let (status, _) = hook_file_status(Ok(hook), CodingAgent::Hermes, false, "hooks"); assert_eq!(status, Status::Info); - let mut agents = AgentConfigs::default(); + let agents = AgentConfigs::default(); let (status, details) = hook_status(CodingAgent::Hermes, &agents, true); assert_eq!(status, Status::Fail); assert!(details.contains("not installed")); let (status, details) = hook_status(CodingAgent::Hermes, &agents, false); assert_eq!(status, Status::Info); assert!(details.contains("not configured")); - agents.cursor.patch_restore_hooks = false; } #[test] @@ -685,7 +678,7 @@ fn check_directory_reports_pass_warn_and_fail() { } #[test] -fn hook_file_status_covers_resolution_missing_and_invalid_cursor_json() { +fn hook_file_status_covers_resolution_and_missing_paths() { let resolution_error = hook_file_status( Err(CliError::Config("bad path".into())), CodingAgent::Hermes, @@ -704,24 +697,6 @@ fn hook_file_status_covers_resolution_missing_and_invalid_cursor_json() { let (status, details) = hook_file_status(Ok(missing), CodingAgent::Hermes, false, "hooks"); assert_eq!(status, Status::Info); assert!(details.contains("missing")); - - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::write( - &hooks_path, - r#"{"version":1,"hooks":"hook-forward cursor"}"#, - ) - .unwrap(); - let (status, details) = hook_file_status( - Ok(hooks_path), - CodingAgent::Cursor, - true, - "hooks: user-managed", - ); - assert_eq!(status, Status::Fail); - assert!( - details.contains("invalid Cursor hooks JSON") || details.contains("has no hooks object") - ); } #[test] @@ -748,182 +723,6 @@ fn hook_file_status_covers_plain_files_and_read_errors() { assert!(details.contains("could not read")); } -#[test] -fn cursor_hook_status_rejects_grouped_entries() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::write( - &hooks_path, - r#"{ - "version": 1, - "hooks": { - "beforeShellExecution": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ] - } - ] - } - }"#, - ) - .unwrap(); - - let (status, details) = hook_file_status( - Ok(hooks_path), - CodingAgent::Cursor, - true, - "hooks: user-managed", - ); - - assert_eq!(status, Status::Fail); - assert!(details.contains("nested hook groups")); - assert!(details.contains("direct command entries")); -} - -#[test] -fn cursor_hook_status_rejects_any_grouped_entries_when_nemo_hook_is_direct() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::write( - &hooks_path, - r#"{ - "version": 1, - "hooks": { - "sessionStart": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "beforeShellExecution": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "existing-audit-hook", - "timeout": 30 - } - ] - } - ] - } - }"#, - ) - .unwrap(); - - let (status, details) = hook_file_status( - Ok(hooks_path), - CodingAgent::Cursor, - true, - "hooks: user-managed", - ); - - assert_eq!(status, Status::Fail); - assert!(details.contains("nested hook groups")); - assert!(details.contains("direct command entries")); -} - -#[test] -fn cursor_hook_status_requires_version_one() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::write( - &hooks_path, - r#"{ - "hooks": { - "beforeShellExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ] - } - }"#, - ) - .unwrap(); - - let (status, details) = hook_file_status( - Ok(hooks_path), - CodingAgent::Cursor, - true, - "hooks: user-managed", - ); - - assert_eq!(status, Status::Fail); - assert!(details.contains("version")); - assert!(details.contains("1")); -} - -#[test] -fn cursor_hook_status_rejects_non_one_version() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::write( - &hooks_path, - r#"{ - "version": 2, - "hooks": { - "beforeShellExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ] - } - }"#, - ) - .unwrap(); - - let (status, details) = hook_file_status( - Ok(hooks_path), - CodingAgent::Cursor, - true, - "hooks: user-managed", - ); - - assert_eq!(status, Status::Fail); - assert!(details.contains("version")); - assert!(details.contains("1")); -} - -#[test] -fn cursor_hook_status_accepts_direct_versioned_entries() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::write( - &hooks_path, - r#"{ - "version": 1, - "hooks": { - "beforeShellExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ] - } - }"#, - ) - .unwrap(); - - let (status, details) = hook_file_status( - Ok(hooks_path), - CodingAgent::Cursor, - true, - "hooks: user-managed", - ); - - assert_eq!(status, Status::Pass); - assert!(details.contains("installed")); -} - #[tokio::test] async fn collect_observability_warns_for_missing_atif_dir_without_creating_it() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index d181e8ad5..03b7515b6 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -124,7 +124,6 @@ fn generated_hook_dispatch_covers_all_agents() { for agent in [ CodingAgent::ClaudeCode, CodingAgent::Codex, - CodingAgent::Cursor, CodingAgent::Hermes, ] { assert!(generated_hooks(agent, "cmd")["hooks"].is_object()); @@ -139,21 +138,6 @@ fn generated_hook_dispatch_covers_all_agents() { ); } -#[test] -fn cursor_hooks_use_direct_command_entries() { - let hooks = cursor_hooks("nemo-relay hook-forward cursor"); - let before_shell = &hooks["hooks"]["beforeShellExecution"][0]; - - assert_eq!(hooks["version"], json!(1)); - assert_eq!( - before_shell["command"], - json!("nemo-relay hook-forward cursor") - ); - assert_eq!(before_shell["timeout"], json!(30)); - assert!(before_shell.get("hooks").is_none()); - assert!(before_shell.get("matcher").is_none()); -} - #[test] fn packaged_hook_configs_are_valid_json() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -163,7 +147,6 @@ fn packaged_hook_configs_are_valid_json() { root.join("../../.claude-plugin/marketplace.json"), root.join("claude-code/hooks/hooks.json"), root.join("codex/hooks/hooks.json"), - root.join("cursor/.cursor/hooks.json"), root.join("claude-code/.claude-plugin/plugin.json"), root.join("codex/.codex-plugin/plugin.json"), ] { diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index fefa81add..2aa7119f9 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::config::{AgentCommandConfig, CursorAgentConfig, GatewayConfig}; +use crate::config::{AgentCommandConfig, GatewayConfig}; use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; @@ -158,7 +158,7 @@ fn inference_failure_has_actionable_message() { fn missing_command_without_agent_errors() { // Bare `nemo-relay run` (no command, no --agent) errors — we have nothing to spawn and no // argv[0] to infer an agent from. With --agent set, we fall back to the agent's default - // binary name (e.g., `cursor-agent`), so that branch is exercised in the resolution test + // binary name (e.g., `hermes`), so that branch is exercised in the resolution test // below rather than here. let command = RunCommand { agent: None, @@ -181,10 +181,10 @@ fn missing_command_without_agent_errors() { #[test] fn agent_without_configured_command_falls_back_to_default_binary() { - // `--agent cursor` with no `[agents.cursor] command = "..."` override resolves to the - // default executable name on $PATH (`cursor-agent` for the Cursor agent). + // `--agent hermes` with no `[agents.hermes] command = "..."` override resolves to the + // default executable name on $PATH. let command = RunCommand { - agent: Some(CodingAgent::Cursor), + agent: Some(CodingAgent::Hermes), config: None, openai_base_url: None, anthropic_base_url: None, @@ -196,8 +196,8 @@ fn agent_without_configured_command_falls_back_to_default_binary() { }; let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); - assert_eq!(agent, CodingAgent::Cursor); - assert_eq!(argv, vec!["cursor-agent"]); + assert_eq!(agent, CodingAgent::Hermes); + assert_eq!(argv, vec!["hermes"]); } #[test] @@ -227,7 +227,6 @@ fn agent_with_passthrough_args_appends_to_configured_command() { fn default_and_configured_command_helpers_cover_empty_and_all_agents() { assert_eq!(default_command_for(CodingAgent::ClaudeCode), "claude"); assert_eq!(default_command_for(CodingAgent::Codex), "codex"); - assert_eq!(default_command_for(CodingAgent::Cursor), "cursor-agent"); assert_eq!(default_command_for(CodingAgent::Hermes), "hermes"); let agents = AgentConfigs { @@ -526,38 +525,6 @@ fn prepares_claude_dry_inserts_plugin_dir_after_last_agent_executable() { assert!(prepared.temp_dirs.is_empty()); } -#[test] -fn cursor_patching_can_be_disabled() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs { - cursor: CursorAgentConfig { - command: None, - patch_restore_hooks: false, - }, - ..AgentConfigs::default() - }, - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Cursor, - vec!["cursor-agent".into()], - "http://s", - &resolved, - false, - ) - .unwrap(); - - assert!(prepared.cursor_restore.is_none()); - assert!(!Path::new(".cursor/hooks.json").exists()); - std::env::set_current_dir(previous).unwrap(); -} - #[test] fn prepares_hermes_hook_environment() { let _guard = current_dir_lock().lock().unwrap(); @@ -753,166 +720,6 @@ fn prepares_claude_temp_plugin() { prepared.restore().unwrap(); } -#[test] -fn cursor_patch_restore_restores_original_file() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - std::fs::create_dir_all(".cursor").unwrap(); - std::fs::write(".cursor/hooks.json", r#"{"hooks":{"sessionStart":[]}}"#).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs { - cursor: CursorAgentConfig { - command: None, - patch_restore_hooks: true, - }, - ..AgentConfigs::default() - }, - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Cursor, - vec!["cursor-agent".into()], - "http://s", - &resolved, - false, - ) - .unwrap(); - assert!( - std::fs::read_to_string(".cursor/hooks.json") - .unwrap() - .contains("hook-forward cursor") - ); - let patched: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(".cursor/hooks.json").unwrap()).unwrap(); - assert_eq!(patched["version"], json!(1)); - prepared.restore().unwrap(); - assert_eq!( - std::fs::read_to_string(".cursor/hooks.json").unwrap(), - r#"{"hooks":{"sessionStart":[]}}"# - ); - std::env::set_current_dir(previous).unwrap(); -} - -#[test] -fn cursor_patch_restore_uses_nearest_project_cursor_dir() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::fs::create_dir_all(temp.path().join(".cursor")).unwrap(); - std::fs::create_dir_all(temp.path().join("nested")).unwrap(); - std::fs::write( - temp.path().join(".cursor/hooks.json"), - r#"{"hooks":{"sessionStart":[]}}"#, - ) - .unwrap(); - std::env::set_current_dir(temp.path().join("nested")).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Cursor, - vec!["cursor-agent".into()], - "http://s", - &resolved, - false, - ) - .unwrap(); - - assert!( - std::fs::read_to_string(temp.path().join(".cursor/hooks.json")) - .unwrap() - .contains("hook-forward cursor") - ); - let patched: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(temp.path().join(".cursor/hooks.json")).unwrap(), - ) - .unwrap(); - assert_eq!(patched["version"], json!(1)); - assert!(!Path::new(".cursor/hooks.json").exists()); - prepared.restore().unwrap(); - std::env::set_current_dir(previous).unwrap(); -} - -#[test] -fn cursor_patch_restore_removes_temporary_file() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Cursor, - vec!["cursor-agent".into()], - "http://s", - &resolved, - false, - ) - .unwrap(); - assert!(Path::new(".cursor/hooks.json").exists()); - let patched: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(".cursor/hooks.json").unwrap()).unwrap(); - assert_eq!(patched["version"], json!(1)); - prepared.restore().unwrap(); - assert!(!Path::new(".cursor/hooks.json").exists()); - std::env::set_current_dir(previous).unwrap(); -} - -#[test] -fn cursor_restore_reports_failed_backup_restore() { - let temp = tempfile::tempdir().unwrap(); - let prepared = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - cursor_restore: Some(CursorRestore { - path: temp.path().join("hooks.json"), - backup_path: Some(temp.path().join("missing-backup.json")), - had_original: true, - }), - hermes_restore: None, - notes: vec![], - }; - - let error = prepared.restore().unwrap_err().to_string(); - - assert!(error.contains("failed to restore Cursor hooks")); -} - -#[test] -fn cursor_restore_reports_failed_temporary_hook_removal() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hooks.json"); - std::fs::create_dir(&hooks_path).unwrap(); - let prepared = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - cursor_restore: Some(CursorRestore { - path: hooks_path, - backup_path: None, - had_original: false, - }), - hermes_restore: None, - notes: vec![], - }; - - let error = prepared.restore().unwrap_err().to_string(); - - assert!(error.contains("failed to remove temporary Cursor hooks")); -} - #[test] fn hermes_restore_reports_restore_and_temporary_removal_failures() { let temp = tempfile::tempdir().unwrap(); @@ -920,7 +727,6 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { argv: vec![], env: vec![], temp_dirs: vec![], - cursor_restore: None, hermes_restore: Some(HermesRestore { path: temp.path().join("config.yaml"), backup_path: Some(temp.path().join("missing-backup.yaml")), @@ -938,7 +744,6 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { argv: vec![], env: vec![], temp_dirs: vec![], - cursor_restore: None, hermes_restore: Some(HermesRestore { path: hooks_path, backup_path: None, @@ -951,39 +756,9 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { assert!(error.contains("failed to remove temporary Hermes hooks")); } -#[test] -fn cursor_restore_noops_when_original_was_declared_without_backup() { - let prepared = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - cursor_restore: Some(CursorRestore { - path: PathBuf::from("unused"), - backup_path: None, - had_original: true, - }), - hermes_restore: None, - notes: vec![], - }; - - prepared.restore().unwrap(); -} - #[test] fn hook_backup_and_write_helpers_cover_missing_existing_and_toml_escaping() { let temp = tempfile::tempdir().unwrap(); - let missing_cursor = temp.path().join("missing-hooks.json"); - assert_eq!( - backup_existing_cursor_hooks(&missing_cursor).unwrap(), - (false, None) - ); - - let cursor_hooks = temp.path().join("hooks.json"); - std::fs::write(&cursor_hooks, "{}").unwrap(); - let (had_original, cursor_backup) = backup_existing_cursor_hooks(&cursor_hooks).unwrap(); - assert!(had_original); - assert!(cursor_backup.as_ref().unwrap().exists()); - let missing_hermes = temp.path().join("missing-config.yaml"); assert_eq!( backup_existing_hermes_hooks(&missing_hermes).unwrap(), @@ -1035,32 +810,6 @@ fn exit_code_preserves_normal_and_shell_wrapped_codes() { assert_eq!(exit_code(status), ExitCode::from(44)); } -#[test] -fn cursor_dry_run_does_not_write_hooks() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Cursor, - vec!["cursor-agent".into()], - "http://s", - &resolved, - true, - ) - .unwrap(); - - assert!(!Path::new(".cursor/hooks.json").exists()); - assert!(prepared.notes[0].contains("would temporarily merge")); - std::env::set_current_dir(previous).unwrap(); -} - // This e2e test relies on argv[0] being a script literally named after a known agent (so // `CodingAgent::infer` recognises the basename without an explicit `--agent`). On Windows the // only practical way to invoke a `.cmd` / `.bat` shim is via `cmd.exe /C script.cmd`, which diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index 9572dfa2d..c7bd2ffa2 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -212,7 +212,7 @@ async fn run_command_dispatches_safe_plugin_and_install_paths() { .to_string(); assert!(error.contains("plugin install supports codex")); - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "uninstall", "cursor"]).unwrap(); + let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "uninstall", "hermes"]).unwrap(); let error = run_command(cli.command.unwrap(), &cli.server) .await .unwrap_err() diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index a72046b44..4ebbcbe19 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -2030,12 +2030,12 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { assert_eq!(codex_report["checks"]["codex_provider_alias"], json!(false)); assert_eq!(codex_report["checks"]["codex_hooks"], json!(false)); assert!( - doctor_plugin_json(CodingAgent::Cursor, DEFAULT_URL) + doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin(CodingAgent::Cursor, DEFAULT_URL) + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) .unwrap_err() .contains("supports claude and codex") ); @@ -2070,7 +2070,7 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { .contains("supports claude") ); assert!( - post_hook(CodingAgent::Cursor, DEFAULT_URL, b"{}") + post_hook(CodingAgent::Hermes, DEFAULT_URL, b"{}") .unwrap_err() .contains("supports claude and codex") ); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 1b32b050e..255671146 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -1831,38 +1831,6 @@ async fn claude_code_hook_returns_continue_shape() { assert_eq!(body["continue"], json!(true)); } -#[tokio::test] -async fn cursor_hook_returns_cursor_permission_fields() { - let app = router(test_config()); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/hooks/cursor") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "session_id": "cursor-1", - "hook_event_name": "beforeShellExecution", - "tool_call_id": "shell-1", - "tool_name": "shell", - "input": { "command": "pwd" } - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let body: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(body["continue"], json!(true)); - assert_eq!(body["permission"], json!("allow")); - assert!(body.get("user_message").is_none()); - assert!(body.get("agent_message").is_none()); -} - #[tokio::test] async fn pre_tool_hook_rejects_when_conditional_guardrail_blocks() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index cdab66c4c..74bdbe171 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -3424,7 +3424,7 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { vec![ NormalizedEvent::SubagentEnded(SubagentEvent { session_id: "out-of-order".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "subagentStop".into(), subagent_id: "missing".into(), payload: json!({ "reason": "missing-start" }), @@ -3432,7 +3432,7 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { }), NormalizedEvent::ToolEnded(ToolEvent { session_id: "out-of-order".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "postToolUse".into(), tool_call_id: "tool-without-start".into(), tool_name: "Shell".into(), @@ -3445,7 +3445,7 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { }), NormalizedEvent::AgentEnded(SessionEvent { session_id: "out-of-order".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "sessionEnd".into(), payload: json!({}), metadata: json!({}), @@ -4269,14 +4269,14 @@ async fn multiple_llm_hints_resolve_by_generation_id() { vec![ NormalizedEvent::AgentStarted(SessionEvent { session_id: "multi-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "sessionStart".into(), payload: json!({}), metadata: json!({}), }), NormalizedEvent::SubagentStarted(SubagentEvent { session_id: "multi-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "subagentStart".into(), subagent_id: "worker-1".into(), payload: json!({}), @@ -4284,7 +4284,7 @@ async fn multiple_llm_hints_resolve_by_generation_id() { }), NormalizedEvent::SubagentStarted(SubagentEvent { session_id: "multi-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "subagentStart".into(), subagent_id: "worker-2".into(), payload: json!({}), @@ -4292,7 +4292,7 @@ async fn multiple_llm_hints_resolve_by_generation_id() { }), NormalizedEvent::LlmHint(LlmHintEvent { session_id: "multi-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "afterAgentThought".into(), subagent_id: Some("worker-1".into()), agent_id: None, @@ -4306,7 +4306,7 @@ async fn multiple_llm_hints_resolve_by_generation_id() { }), NormalizedEvent::LlmHint(LlmHintEvent { session_id: "multi-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "afterAgentThought".into(), subagent_id: Some("worker-2".into()), agent_id: None, @@ -4385,14 +4385,14 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { vec![ NormalizedEvent::AgentStarted(SessionEvent { session_id: "ambiguous-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "sessionStart".into(), payload: json!({}), metadata: json!({}), }), NormalizedEvent::LlmHint(LlmHintEvent { session_id: "ambiguous-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "afterAgentThought".into(), subagent_id: None, agent_id: None, @@ -4406,7 +4406,7 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { }), NormalizedEvent::LlmHint(LlmHintEvent { session_id: "ambiguous-session".into(), - agent_kind: AgentKind::Cursor, + agent_kind: AgentKind::ClaudeCode, event_name: "afterAgentResponse".into(), subagent_id: None, agent_id: None, diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index b156b61c4..f1242104f 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -91,9 +91,9 @@ impl Drop for EnvScope { fn detect_installed_agents_finds_binaries_on_path() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().unwrap(); - // Drop stub binaries for two of the four supported agents — confirming detection picks up + // Drop stub binaries for two of the three supported agents — confirming detection picks up // only the ones present and ignores the others. - for exec in ["claude", "cursor-agent"] { + for exec in ["claude", "hermes"] { let path = temp.path().join(exec); std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -104,9 +104,8 @@ fn detect_installed_agents_finds_binaries_on_path() { // race with every other test that reads the environment. let detected = detect_installed_agents_in(Some(temp.path().as_os_str())); assert!(detected.contains(&CodingAgent::ClaudeCode)); - assert!(detected.contains(&CodingAgent::Cursor)); + assert!(detected.contains(&CodingAgent::Hermes)); assert!(!detected.contains(&CodingAgent::Codex)); - assert!(!detected.contains(&CodingAgent::Hermes)); } #[test] diff --git a/docs/about-nemo-relay/overview.mdx b/docs/about-nemo-relay/overview.mdx index c1b2e55e3..6beb85f7e 100644 --- a/docs/about-nemo-relay/overview.mdx +++ b/docs/about-nemo-relay/overview.mdx @@ -41,7 +41,7 @@ Pick the row closest to what you are trying to do. | Goal | Start With | Why | |---|---|---| -| Observe Codex, Claude Code, Cursor, or Hermes locally | [NeMo Relay CLI](/nemo-relay-cli/about) and [Basic Usage](/nemo-relay-cli/basic-usage) | Relay runs as a local sidecar, forwards hooks, routes provider traffic when configured, and writes observability artifacts without changing application code. | +| Observe Codex, Claude Code, or Hermes locally | [NeMo Relay CLI](/nemo-relay-cli/about) and [Basic Usage](/nemo-relay-cli/basic-usage) | Relay runs as a local sidecar, forwards hooks, routes provider traffic when configured, and writes observability artifacts without changing application code. | | Run the smallest binding-specific example | [Quick Start](/getting-started/quick-start) | Use this when you want a minimal Rust, Python, or Node.js workflow before adding Relay to real application code. | | Instrument application-owned LLM or tool calls | [Instrument Applications](/instrument-applications/about) | Direct SDK instrumentation gives Relay full managed-call semantics around callbacks your code owns. | | Use LangChain, LangGraph, Deep Agents, or OpenClaw | [Supported Integrations](/supported-integrations/about) | Maintained integrations use public framework or plugin APIs where they preserve enough lifecycle fidelity. | diff --git a/docs/getting-started/agent-runtime-primer.mdx b/docs/getting-started/agent-runtime-primer.mdx index 107e201cb..9752b7319 100644 --- a/docs/getting-started/agent-runtime-primer.mdx +++ b/docs/getting-started/agent-runtime-primer.mdx @@ -109,7 +109,7 @@ configuration, and validation steps for that path. - **New framework, orchestration, SDK, or provider integration:** The framework or adapter owns scheduling, retries, callbacks, and provider payloads. Start with [Integrate into Frameworks](/integrate-into-frameworks/about). -- **Local Claude Code, Codex, Cursor, or Hermes runs:** The coding-agent harness +- **Local Claude Code, Codex, or Hermes runs:** The coding-agent harness owns invocation while Relay observes hooks, gateway-routed model traffic, and exporter output. Start with [NeMo Relay CLI](/nemo-relay-cli/about). - **Reusable runtime behavior across services or teams:** Runtime plugin diff --git a/docs/getting-started/quick-start/index.mdx b/docs/getting-started/quick-start/index.mdx index 017d691f3..a9b490875 100644 --- a/docs/getting-started/quick-start/index.mdx +++ b/docs/getting-started/quick-start/index.mdx @@ -38,7 +38,7 @@ not yet know which guide owns the working path. ## Local Coding-Agent Runs Use the NVIDIA NeMo Relay CLI when you want to observe a local Codex, Claude -Code, Cursor, or Hermes Agent session without changing application code. +Code, or Hermes Agent session without changing application code. ` from stdin. Inside the wrapper the gateway URL comes from `NEMO_RELAY_GATEWAY_URL` injected on every run; outside the wrapper (Hermes standalone, IDE-launched Claude/Codex) the hook @@ -385,7 +372,6 @@ application-mode caveats. - [Claude Code](/nemo-relay-cli/claude-code) - [Codex](/nemo-relay-cli/codex) - [Plugin Installation](/nemo-relay-cli/plugin-installation) -- [Cursor](/nemo-relay-cli/cursor) - [Hermes Agent](/nemo-relay-cli/hermes) Each guide covers transparent run setup, gateway routing, hook smoke tests, diff --git a/docs/nemo-relay-cli/cursor.mdx b/docs/nemo-relay-cli/cursor.mdx deleted file mode 100644 index 381893f12..000000000 --- a/docs/nemo-relay-cli/cursor.mdx +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: "Cursor" -description: "" -position: 6 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Use this guide to observe Cursor hook lifecycle events with NeMo Relay. The -repository ships a Cursor hook bundle under `integrations/coding-agents/cursor/` -because this integration does not assume an official Cursor plugin package -format. - - -Cursor support is highly experimental and limited. NeMo Relay can install or -temporarily patch Cursor hooks, but it cannot automatically route Cursor model -traffic through the gateway. Complete LLM observability requires manual Cursor -provider/proxy configuration, including any required API keys, and that -configuration is outside NeMo Relay's control. - -Cursor subagents may choose or inherit models independently from the top-level -session. If those subagent calls bypass the NeMo Relay gateway, their LLM -requests and responses will not appear in NeMo Relay events even when hook -events are present. - - - -Cursor GUI or IDE sessions can provide agent, subagent, tool, shell, MCP, file, -and response lifecycle events through `.cursor/hooks.json`. Complete LLM -lifecycle observability additionally requires Cursor model traffic to route -through the gateway if your Cursor build exposes that configuration. - -Cursor CLI support must be verified separately with `cursor-agent`. Current -Cursor CLI builds require `.cursor/hooks.json` to set top-level `"version": 1` -and use direct command entries such as -`{"command": "nemo-relay hook-forward cursor", "timeout": 30}`. The nested -`{"matcher": "*", "hooks": [...]}` group shape used by Claude Code and Codex -does not fire in Cursor CLI. If CLI hooks still do not fire with direct -versioned entries, treat that Cursor CLI version as hook-limited and -gateway-only where model routing is configurable. - - -Cursor CLI hook coverage is not the same as Cursor IDE hook coverage. Current -headless CLI builds can emit fewer hook events than Cursor IDE sessions. Treat -missing CLI hook events as a Cursor CLI limitation after `nemo-relay doctor -cursor` confirms the hook file uses the direct versioned shape. - - - -## Transparent Run - -Use the wrapper for no-install local observability: - -```bash -nemo-relay cursor -``` - -Pass Cursor arguments after `--`: - -```bash -nemo-relay cursor -- agent --resume -``` - -This shortcut is equivalent to `nemo-relay run -- cursor-agent`. The wrapper -starts a gateway on a dynamic `127.0.0.1` port, temporarily merges NeMo Relay -hook entries into the project `.cursor/hooks.json`, launches Cursor, and -restores the original hook file after the agent exits. The temporary Cursor hook -file is written with top-level `"version": 1` and direct command entries. - -Inspect what would be launched without starting Cursor: - -```bash -nemo-relay run \ - --dry-run \ - --print \ - -- cursor-agent -``` - -## Shared Config - -Create `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: - -```toml -[agents.cursor] -command = "cursor-agent" -patch_restore_hooks = true -``` - -Then configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: - -```toml -version = 1 - -[[components]] -kind = "observability" -enabled = true - -[components.config.atif] -enabled = true -output_directory = ".nemo-relay/atif" -``` - -Run `nemo-relay run --agent cursor` to use the configured command and plugin -config. User config takes priority over project and system config. - -## Standalone Gateway - -Use the long-running gateway only when you want Cursor running outside the -wrapper (e.g., the Cursor GUI). Start the gateway manually: - -```bash -nemo-relay --bind 127.0.0.1:4040 -``` - -Then point Cursor provider traffic at `http://127.0.0.1:4040` wherever Cursor -exposes provider base URL configuration. Without the wrapper, hook events are -not captured — Cursor GUI mode only emits LLM lifecycle as traffic passes -through the gateway. Missing LLM spans are expected when Cursor sends model -traffic directly to the provider or through a remote service. - -## Captured Events - -Generated Cursor hooks include `sessionStart`, `sessionEnd`, `subagentStart`, -`subagentStop`, `preToolUse`, `postToolUse`, `beforeShellExecution`, -`afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `preCompact`, -and `stop` for scope, tool, and mark events. `beforeSubmitPrompt`, -`afterAgentResponse`, and `afterAgentThought` are retained as private LLM -correlation hints and are not emitted as standalone NeMo Relay events. - -Tool events preserve Cursor shell and MCP payloads in metadata and use the -active `subagent.id`, `subagent_id`, or `x-nemo-relay-subagent-id` when present. -The transparent wrapper backs up the project hook file, merges NeMo Relay hook -entries for the run, and restores or removes the temporary file when the agent -exits. - -## Smoke Test - -Run a small Cursor GUI session that starts an agent and uses one simple tool. -Then check hook forwarding directly: - -```bash -curl -f http://127.0.0.1:4040/healthz -printf '{"session_id":"smoke-cursor","hook_event_name":"sessionStart"}' \ - | NEMO_RELAY_GATEWAY_URL=http://127.0.0.1:4040 nemo-relay hook-forward cursor --fail-closed -``` - -For Cursor CLI, run an equivalent `cursor-agent` session and verify the gateway -receives hook requests. If no hook requests arrive, confirm `.cursor/hooks.json` -uses top-level `"version": 1` with direct command entries, then document that -CLI version as hook-limited and rely only on gateway observability where -provider routing is available. - -## Verify Export - -End the Cursor session and confirm Agent Trajectory Interchange Format (ATIF) -exists: - -```bash -ls .nemo-relay/atif -``` - -The gateway writes `.atif.json` on session end. If the file is -missing, confirm Cursor loaded `.cursor/hooks.json`, the gateway binary is on -`PATH`, `--atif-dir` or `NEMO_RELAY_ATIF_DIR` is configured, `plugins.toml` -enables the ATIF exporter with a writable `output_directory`, and user-managed -Cursor hooks pass `nemo-relay doctor cursor`. - -## Troubleshoot LLM Lifecycle - -If Cursor hook events appear but LLM spans are missing, provider traffic is not -routed through the gateway. Confirm the active Cursor GUI or CLI mode supports -provider base URL configuration for the model path being used. - -If LLM spans exist but attach to the session instead of a subagent, pass -`x-nemo-relay-subagent-id` on gateway requests or include shared -`conversation_id`, `generation_id`, or `request_id` values in both hook payloads -and provider requests. diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 56bbbef34..2487ca66f 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -31,8 +31,6 @@ environment variables, or shared TOML config. uses hook-supervised lazy sidecar startup only, with no wrapper, user-level daemon, login item, launchd agent, systemd user service, scheduled task, or persistent supervisor. -- `cursor/` installs a Cursor `.cursor/hooks.json` bundle targeting - `POST /hooks/cursor`. - Hermes does not require a static bundle in this directory. The setup wizard (`nemo-relay config`) merges hook commands into `.hermes/config.yaml` when hermes is selected. @@ -48,11 +46,10 @@ down when the agent exits. ```bash nemo-relay run -- claude nemo-relay run -- codex -nemo-relay run -- cursor-agent nemo-relay run -- hermes ``` -Use `--agent claude|codex|cursor|hermes` when a wrapper hides the agent +Use `--agent claude|codex|hermes` when a wrapper hides the agent command name. Use `--dry-run --print` to inspect generated config without launching. diff --git a/integrations/coding-agents/cursor/.cursor/hooks.json b/integrations/coding-agents/cursor/.cursor/hooks.json deleted file mode 100644 index 07b463696..000000000 --- a/integrations/coding-agents/cursor/.cursor/hooks.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "SPDX-License-Identifier": "Apache-2.0", - "version": 1, - "hooks": { - "sessionStart": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "beforeSubmitPrompt": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "preToolUse": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "beforeShellExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "beforeMCPExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "postToolUse": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "afterShellExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "afterMCPExecution": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "subagentStart": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "subagentStop": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "afterAgentResponse": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "afterAgentThought": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "preCompact": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "stop": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ], - "sessionEnd": [ - { - "command": "nemo-relay hook-forward cursor", - "timeout": 30 - } - ] - } -} diff --git a/integrations/coding-agents/cursor/README.md b/integrations/coding-agents/cursor/README.md deleted file mode 100644 index 96e93bfd9..000000000 --- a/integrations/coding-agents/cursor/README.md +++ /dev/null @@ -1,154 +0,0 @@ - - -# NeMo Relay Cursor Observability - -This package is a Cursor hook bundle, not an official Cursor plugin package. It -contains `.cursor/hooks.json` entries that forward canonical Cursor hook JSON to -`nemo-relay` at `/hooks/cursor`. - -> [!CAUTION] -> Cursor support is highly experimental and limited. NeMo Relay can install or -> temporarily patch Cursor hooks, but it cannot automatically route Cursor model -> traffic through the gateway. Complete LLM observability requires manual Cursor -> provider/proxy configuration, including any required API keys, and that -> configuration is outside NeMo Relay's control. -> -> Cursor subagents may choose or inherit models independently from the top-level -> session. If those subagent calls bypass the NeMo Relay gateway, their LLM -> requests and responses will not appear in NeMo Relay events even when hook -> events are present. - -Cursor GUI or IDE sessions can provide agent, subagent, tool, shell, MCP, file, -and response lifecycle events through `.cursor/hooks.json`. Complete LLM -lifecycle observability additionally requires Cursor model traffic to route -through the gateway if the active Cursor build exposes provider base URL -configuration. - -Cursor CLI builds require `.cursor/hooks.json` to set top-level `"version": 1` -and use direct command entries such as -`{"command": "nemo-relay hook-forward cursor", "timeout": 30}`. The nested -`{"matcher": "*", "hooks": [...]}` group shape used by Claude Code and Codex -does not fire in Cursor CLI. - -> [!WARNING] -> Cursor CLI hook coverage is narrower than Cursor IDE hook coverage. Current -> headless CLI builds can emit fewer hook events than Cursor IDE sessions. Treat -> missing CLI hook events as a Cursor CLI limitation after `nemo-relay doctor -> cursor` confirms the hook file uses the direct versioned shape. - -## Files - -- `.cursor/hooks.json` contains hook entries that run - `nemo-relay hook-forward cursor`. - -## Captured Events - -The bundle forwards `sessionStart`, `sessionEnd`, `subagentStart`, -`subagentStop`, `preToolUse`, `postToolUse`, `beforeShellExecution`, -`afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `preCompact`, -and `stop` as scope, tool, or mark events. `beforeSubmitPrompt`, -`afterAgentResponse`, and `afterAgentThought` provide private LLM correlation -hints for gateway requests. - -Tool events preserve shell and MCP payloads in metadata and attach to -`subagent.id`, `subagent_id`, or `x-nemo-relay-subagent-id` when one is present. - -## Transparent Setup - -Build or install the gateway binary so `nemo-relay` is on `PATH`. - -Run Cursor through the wrapper: - -```bash -nemo-relay run -- cursor-agent -``` - -The wrapper starts a per-invocation gateway on a dynamic localhost port, -temporarily merges NeMo Relay hooks into project `.cursor/hooks.json`, launches -Cursor, and restores or removes the temporary hook file when Cursor exits. The -temporary Cursor hook file is written with top-level `"version": 1` and direct -command entries. - -Inspect the launch without starting Cursor: - -```bash -nemo-relay run \ - --dry-run \ - --print \ - -- cursor-agent -``` - -## Shared Config - -Use `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: - -```toml -[agents.cursor] -command = "cursor-agent" -patch_restore_hooks = true -``` - -Configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: - -```toml -version = 1 - -[[components]] -kind = "observability" -enabled = true - -[components.config.atif] -enabled = true -output_directory = ".nemo-relay/atif" -``` - -Then run: - -```bash -nemo-relay run --agent cursor -``` - -## Standalone Gateway - -Use the long-running gateway only when you do not want to launch Cursor -through the wrapper (e.g., the Cursor GUI). Start the gateway manually: - -```bash -nemo-relay --bind 127.0.0.1:4040 -``` - -Then point Cursor provider traffic at `http://127.0.0.1:4040` where Cursor -exposes provider base URL configuration. Hook events are only captured when -running through the wrapper. - -## Verify - -Run a Cursor session that starts, uses one simple tool, and ends. Confirm that -ATIF was written: - -```bash -ls .nemo-relay/atif -``` - -For a direct endpoint smoke test against a manually started gateway: - -```bash -curl -f http://127.0.0.1:4040/healthz -printf '{"session_id":"smoke-cursor","hook_event_name":"sessionStart"}' \ - | NEMO_RELAY_GATEWAY_URL=http://127.0.0.1:4040 nemo-relay hook-forward cursor --fail-closed -``` - -If Cursor CLI hooks do not fire for the active `cursor-agent` version, treat -that CLI mode as hook-limited after confirming `.cursor/hooks.json` uses direct -versioned entries. User-managed Cursor hook files can be checked with -`nemo-relay doctor cursor`. - -If LLM spans are present but attached to the top-level agent instead of a -subagent, include `x-nemo-relay-subagent-id` on gateway requests or share -`conversation_id`, `generation_id`, or `request_id` values between hook payloads -and provider requests.