Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions crates/cli/src/adapters/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
103 changes: 103 additions & 0 deletions crates/cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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<PathBuf, CliError> {
let cwd = std::env::current_dir()?;
let project = cwd
Expand Down
44 changes: 28 additions & 16 deletions crates/cli/src/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<String, Value> = HERMES_HOOK_EVENTS
.iter()
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String, Value> = 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"
)
}

Expand Down
10 changes: 7 additions & 3 deletions crates/cli/src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let contents = serde_json::to_string_pretty(&merged)
.map_err(|error| CliError::Launch(error.to_string()))?;
std::fs::write(path, contents)?;
Ok(())
}
Expand Down
2 changes: 2 additions & 0 deletions crates/cli/tests/coverage/adapters_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading