From 4d93bdecac0323bd0ffa0e2d58abd4dc7549c7f1 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 14 May 2026 10:37:35 -0700 Subject: [PATCH] fix: support Cursor CLI hook config Signed-off-by: Ajay Thorve --- crates/cli/src/adapters/cursor.rs | 4 +- crates/cli/src/doctor.rs | 103 ++++++++++ crates/cli/src/installer.rs | 44 +++-- crates/cli/src/launcher.rs | 10 +- crates/cli/tests/coverage/adapters_tests.rs | 2 + crates/cli/tests/coverage/doctor_tests.rs | 176 ++++++++++++++++++ crates/cli/tests/coverage/installer_tests.rs | 15 ++ crates/cli/tests/coverage/launcher_tests.rs | 11 ++ crates/cli/tests/coverage/server_tests.rs | 2 + .../coding-agent-cursor.md | 33 +++- .../coding-agents/cursor/.cursor/hooks.json | 142 +++----------- integrations/coding-agents/cursor/README.md | 21 ++- 12 files changed, 418 insertions(+), 145 deletions(-) diff --git a/crates/cli/src/adapters/cursor.rs b/crates/cli/src/adapters/cursor.rs index 3c1e39c0c..72518baa2 100644 --- a/crates/cli/src/adapters/cursor.rs +++ b/crates/cli/src/adapters/cursor.rs @@ -35,9 +35,7 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { let response = match events.first() { Some(NormalizedEvent::ToolStarted(_)) => json!({ "continue": true, - "permission": "allow", - "user_message": null, - "agent_message": null + "permission": "allow" }), Some(NormalizedEvent::AgentEnded(_)) => json!({ "continue": true }), _ => json!({ "continue": true }), diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 11a3e9812..27203f87b 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -406,6 +406,9 @@ 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()), @@ -431,6 +434,106 @@ 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 Flow hook in {}", path.display()), + ); + } + return ( + Status::Info, + format!("{label}: no NeMo Flow 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 Flow 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 diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 6e60af4eb..3d6d50694 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -198,8 +198,8 @@ fn resolve_hook_gateway_url( /// Generates native hook configuration for the selected agent. /// -/// The returned value always has a top-level `hooks` object, but Hermes uses its simpler command -/// group shape while Claude/Codex/Cursor use command hook groups with optional tool matchers. +/// 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. pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { match agent { CodingAgent::ClaudeCode => claude_hooks(command), @@ -228,11 +228,11 @@ fn codex_hooks(command: &str) -> Value { } fn cursor_hooks(command: &str) -> Value { - hooks_for_events(CURSOR_HOOK_EVENTS, command, true) + 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, Codex, and Cursor. +// the nested `type = command` group format used by Claude and Codex. pub(crate) fn hermes_hooks(command: &str) -> Value { let hooks: serde_json::Map = HERMES_HOOK_EVENTS .iter() @@ -249,7 +249,7 @@ pub(crate) fn hermes_hooks(command: &str) -> Value { json!({ "hooks": Value::Object(hooks) }) } -// Generates hook groups for all requested events and adds a wildcard matcher to tool events when +// Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when // the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire // for the full lifecycle. fn hooks_for_events(events: &[&str], command: &str, matcher_for_tools: bool) -> Value { @@ -277,21 +277,33 @@ 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 plus Cursor shell/MCP names so generated config stays agent-compatible. +// Claude/Codex spellings. Cursor uses direct command hooks and does not call this helper. fn event_matches_tools(event: &str) -> bool { matches!( event, - "PreToolUse" - | "PostToolUse" - | "PostToolUseFailure" - | "PermissionRequest" - | "preToolUse" - | "postToolUse" - | "beforeShellExecution" - | "afterShellExecution" - | "beforeMCPExecution" - | "afterMCPExecution" + "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" ) } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 32b4b70fe..6ff4b5e7e 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -823,14 +823,18 @@ fn write_merged_cursor_hooks(path: &Path) -> Result<(), CliError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let contents = serde_json::to_string_pretty(&merge_hooks( + let mut merged = merge_hooks( read_json_file(path)?, generated_hooks( CodingAgent::Cursor, &hook_forward_command(&transparent_hook_executable(), CodingAgent::Cursor), ), - )?) - .map_err(|error| CliError::Launch(error.to_string()))?; + )?; + 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(()) } diff --git a/crates/cli/tests/coverage/adapters_tests.rs b/crates/cli/tests/coverage/adapters_tests.rs index cd23816bb..507c26f1f 100644 --- a/crates/cli/tests/coverage/adapters_tests.rs +++ b/crates/cli/tests/coverage/adapters_tests.rs @@ -257,6 +257,8 @@ fn maps_cursor_subagent_and_permission_response() { 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] diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 0927a940f..05f192262 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -412,6 +412,182 @@ fn check_directory_reports_pass_warn_and_fail() { assert_eq!(fail.status, Status::Fail); } +#[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-flow 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-flow 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-flow 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-flow 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-flow 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 712a6b5e4..a3262b767 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -139,6 +139,21 @@ fn generated_hook_dispatch_covers_all_agents() { ); } +#[test] +fn cursor_hooks_use_direct_command_entries() { + let hooks = cursor_hooks("nemo-flow hook-forward cursor"); + let before_shell = &hooks["hooks"]["beforeShellExecution"][0]; + + assert_eq!(hooks["version"], json!(1)); + assert_eq!( + before_shell["command"], + json!("nemo-flow 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")) diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index b8727fac6..1f9ef1298 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -455,6 +455,9 @@ fn cursor_patch_restore_restores_original_file() { .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(), @@ -495,6 +498,11 @@ fn cursor_patch_restore_uses_nearest_project_cursor_dir() { .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(); @@ -520,6 +528,9 @@ fn cursor_patch_restore_removes_temporary_file() { ) .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(); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index d49b1efe0..a2a8c142c 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -464,6 +464,8 @@ async fn cursor_hook_returns_cursor_permission_fields() { 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] diff --git a/docs/integrate-frameworks/coding-agent-cursor.md b/docs/integrate-frameworks/coding-agent-cursor.md index 67117d346..56b20b2db 100644 --- a/docs/integrate-frameworks/coding-agent-cursor.md +++ b/docs/integrate-frameworks/coding-agent-cursor.md @@ -15,9 +15,21 @@ 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`. If CLI hooks -do not fire, treat Cursor CLI support as hook-limited and gateway-only where -model routing is configurable. +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-flow 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. + +```{warning} +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-flow doctor +cursor` confirms the hook file uses the direct versioned shape. +``` ## Transparent Run @@ -36,7 +48,8 @@ nemo-flow cursor -- agent --resume This shortcut is equivalent to `nemo-flow run -- cursor-agent`. The wrapper starts a gateway on a dynamic `127.0.0.1` port, temporarily merges NeMo Flow hook entries into the project `.cursor/hooks.json`, launches Cursor, and -restores the original hook file after the agent exits. +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: @@ -118,9 +131,10 @@ printf '{"session_id":"smoke-cursor","hook_event_name":"sessionStart"}' \ ``` For Cursor CLI, run an equivalent `cursor-agent` session and verify the gateway -receives hook requests. If no hook requests arrive, document that CLI version as -hook-limited and rely only on gateway observability where provider routing is -available. +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 @@ -132,8 +146,9 @@ ls .nemo-flow/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`, and `plugins.toml` enables the ATIF exporter with a writable -`output_directory`. +`PATH`, `--atif-dir` or `NEMO_FLOW_ATIF_DIR` is configured, `plugins.toml` +enables the ATIF exporter with a writable `output_directory`, and user-managed +Cursor hooks pass `nemo-flow doctor cursor`. ## Troubleshoot LLM Lifecycle diff --git a/integrations/coding-agents/cursor/.cursor/hooks.json b/integrations/coding-agents/cursor/.cursor/hooks.json index 0196f7daf..c2dd52edf 100644 --- a/integrations/coding-agents/cursor/.cursor/hooks.json +++ b/integrations/coding-agents/cursor/.cursor/hooks.json @@ -1,175 +1,95 @@ { "SPDX-License-Identifier": "Apache-2.0", + "version": 1, "hooks": { "sessionStart": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "beforeSubmitPrompt": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "preToolUse": [ { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "beforeShellExecution": [ { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "beforeMCPExecution": [ { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "postToolUse": [ { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "afterShellExecution": [ { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "afterMCPExecution": [ { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "subagentStart": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "subagentStop": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "afterAgentResponse": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "afterAgentThought": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "preCompact": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "stop": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ], "sessionEnd": [ { - "hooks": [ - { - "type": "command", - "command": "nemo-flow-sidecar hook-forward cursor", - "timeout": 30 - } - ] + "command": "nemo-flow hook-forward cursor", + "timeout": 30 } ] } diff --git a/integrations/coding-agents/cursor/README.md b/integrations/coding-agents/cursor/README.md index 560ce063e..edaf5d3bb 100644 --- a/integrations/coding-agents/cursor/README.md +++ b/integrations/coding-agents/cursor/README.md @@ -15,6 +15,18 @@ 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-flow 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-flow doctor +> cursor` confirms the hook file uses the direct versioned shape. + ## Files - `.cursor/hooks.json` contains hook entries that run @@ -44,7 +56,9 @@ nemo-flow run -- cursor-agent The wrapper starts a per-invocation gateway on a dynamic localhost port, temporarily merges NeMo Flow hooks into project `.cursor/hooks.json`, launches -Cursor, and restores or removes the temporary hook file when Cursor exits. +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: @@ -118,8 +132,9 @@ printf '{"session_id":"smoke-cursor","hook_event_name":"sessionStart"}' \ ``` If Cursor CLI hooks do not fire for the active `cursor-agent` version, treat -that CLI mode as hook-limited and rely on gateway observability where provider -routing is available. +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-flow doctor cursor`. If LLM spans are present but attached to the top-level agent instead of a subagent, include `x-nemo-flow-subagent-id` on gateway requests or share