From f352e3e27dc887fb96a6bd3cfe77a82aae7cc38a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:25:31 -0700 Subject: [PATCH] fix(cursor): emit Cursor-contract JSON from managed hooks Merge rebased conflict repair after exact-head tests, typecheck, lint, format, and all required GitHub checks passed. --- .github/workflows/pr.yml | 1 + config/tsconfig.cli.json | 2 + src/main/agent-hooks/installer-utils.test.ts | 11 ++ src/main/agent-hooks/installer-utils.ts | 10 +- .../remote-hook-service-installers.test.ts | 29 +-- src/main/cursor/hook-events.ts | 29 +++ src/main/cursor/hook-script.ts | 87 +++++++++ src/main/cursor/hook-service.test.ts | 168 ++++++++++++++++-- src/main/cursor/hook-service.ts | 41 ++--- 9 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 src/main/cursor/hook-events.ts create mode 100644 src/main/cursor/hook-script.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 21d43d3bee5..0862ac31fe7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -712,6 +712,7 @@ jobs: src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + src/main/cursor/hook-service.test.ts src/main/orca-profiles/profile-index-store.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index eb278257159..f4555ae4a48 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -82,6 +82,8 @@ "../src/main/copilot/copilot-managed-script.ts", "../src/main/copilot/copilot-remote-hook-install.ts", "../src/main/copilot/hook-service.ts", + "../src/main/cursor/hook-events.ts", + "../src/main/cursor/hook-script.ts", "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 8515f415fd2..88c73550da2 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -639,6 +639,17 @@ describe('wrapWindowsHookCommand', () => { ) }) + it('emits fallback stdout when the managed script is missing', () => { + const command = wrapWindowsHookCommand( + 'C:\\hooks\\cursor-hook.cmd', + {}, + { fallbackStdout: '{"permission":"allow"}' } + ) + expect(decodeWindowsHookCommand(command)).toContain( + 'Write-Output \'{"permission":"allow"}\'; exit 0' + ) + }) + // Why: a user profile path like `C:\Users\Jane Doe` is the regression from // #6078 — the raw path used to be split at the space. The wrapper must keep // the whole path inside the encoded command so shells do not split it. diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index b5a545e1048..8667c418492 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -118,14 +118,20 @@ export { export function wrapWindowsHookCommand( scriptPath: string, - env: Record = {} + env: Record = {}, + // Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462). + options: { fallbackStdout?: string } = {} ): string { // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script. const quoted = quotePowerShellString(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `) .join('') - const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + const fallback = + options.fallbackStdout === undefined + ? '' + : `Write-Output ${quotePowerShellString(options.fallbackStdout)}; ` + const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; ${fallback}exit 0` return wrapWindowsPowerShellEncodedCommand(command) } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 051b8bc48d1..bcb446b10ee 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => ({ import { CodexHookService, codexHookService } from '../codex/hook-service' import { DroidHookService, droidHookService } from '../droid/hook-service' import { CursorHookService, cursorHookService } from '../cursor/hook-service' +import { CURSOR_EVENTS, type CursorEvent } from '../cursor/hook-events' import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service' import { GeminiHookService, geminiHookService } from '../gemini/hook-service' import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service' @@ -35,6 +36,17 @@ type FakeFs = { failRenameTo: Set } +const EXPECTED_CURSOR_HOOK_RESPONSES = { + beforeSubmitPrompt: '{"continue":true}', + stop: '{}', + preToolUse: '{"permission":"allow"}', + postToolUse: '{}', + postToolUseFailure: '{}', + beforeShellExecution: '{"permission":"allow"}', + beforeMCPExecution: '{"permission":"allow"}', + afterAgentResponse: '{}' +} satisfies Record + function createFakeSftp(initialFiles: Record = {}): { sftp: SFTPWrapper fs: FakeFs @@ -366,19 +378,14 @@ describe('remote hook service installers', () => { hooks: Record } expect(cursorConfig.version).toBe(1) - for (const eventName of [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' - ]) { + for (const eventName of CURSOR_EVENTS) { const definition = cursorConfig.hooks[eventName]?.[0] - expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') + const command = definition?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh') expect(definition?.hooks).toBeUndefined() + const response = EXPECTED_CURSOR_HOOK_RESPONSES[eventName] + expect(command).toContain(`ORCA_CURSOR_HOOK_RESPONSE='${response}'`) + expect(command).toContain(`printf '%s\\n' '${response}'`) } const commandCodeConfig = JSON.parse( diff --git a/src/main/cursor/hook-events.ts b/src/main/cursor/hook-events.ts new file mode 100644 index 00000000000..fe840a41b72 --- /dev/null +++ b/src/main/cursor/hook-events.ts @@ -0,0 +1,29 @@ +// Subscribe only to Cursor hooks needed for spinner and turn detection. +// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. +export const CURSOR_EVENTS = [ + 'beforeSubmitPrompt', + 'stop', + 'preToolUse', + 'postToolUse', + 'postToolUseFailure', + 'beforeShellExecution', + 'beforeMCPExecution', + 'afterAgentResponse' +] as const + +export type CursorEvent = (typeof CURSOR_EVENTS)[number] + +const CURSOR_HOOK_RESPONSES = { + beforeSubmitPrompt: '{"continue":true}', + stop: '{}', + preToolUse: '{"permission":"allow"}', + postToolUse: '{}', + postToolUseFailure: '{}', + beforeShellExecution: '{"permission":"allow"}', + beforeMCPExecution: '{"permission":"allow"}', + afterAgentResponse: '{}' +} satisfies Record + +export function getCursorHookResponse(eventName: CursorEvent): string { + return CURSOR_HOOK_RESPONSES[eventName] +} diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts new file mode 100644 index 00000000000..94563337131 --- /dev/null +++ b/src/main/cursor/hook-script.ts @@ -0,0 +1,87 @@ +import { + buildWindowsAgentHookPostCommand, + wrapPosixHookCommand, + wrapWindowsHookCommand +} from '../agent-hooks/installer-utils' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { getCursorHookResponse, type CursorEvent } from './hook-events' + +const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE' + +export function getPosixManagedCommand(scriptPath: string, eventName: CursorEvent): string { + const response = getCursorHookResponse(eventName) + return wrapPosixHookCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) +} + +export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string { + const response = getCursorHookResponse(eventName) + return process.platform === 'win32' + ? wrapWindowsHookCommand( + scriptPath, + { [CURSOR_HOOK_RESPONSE_ENV]: response }, + { fallbackStdout: response } + ) + : getPosixManagedCommand(scriptPath, eventName) +} + +export function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462). + `if defined ${CURSOR_HOOK_RESPONSE_ENV} (echo %${CURSOR_HOOK_RESPONSE_ENV}%) else (echo {})`, + // Why: source current endpoint coordinates for PTYs surviving an Orca restart. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookPostCommand('cursor'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: Cursor permission hooks fail closed on empty/invalid stdout (#15462). + `if [ -n "$${CURSOR_HOOK_RESPONSE_ENV}" ]; then`, + ` printf '%s\\n' "$${CURSOR_HOOK_RESPONSE_ENV}"`, + 'else', + ' printf "{}\\n"', + 'fi', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), + // Why: refresh endpoint coordinates so surviving PTYs keep reporting. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. + 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index a260b74938f..36f6aa94b48 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() @@ -17,22 +19,66 @@ vi.mock('os', async () => { import { CursorHookService } from './hook-service' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' - -const CURSOR_EVENTS = [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' -] +import { CURSOR_EVENTS, type CursorEvent } from './hook-events' const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ +type InstalledCursorHooks = { + hooks: Record +} + +const EXPECTED_CURSOR_HOOK_STDOUT = { + beforeSubmitPrompt: { continue: true }, + stop: {}, + preToolUse: { permission: 'allow' }, + postToolUse: {}, + postToolUseFailure: {}, + beforeShellExecution: { permission: 'allow' }, + beforeMCPExecution: { permission: 'allow' }, + afterAgentResponse: {} +} satisfies Record> + +function readInstalledCursorHooks(homeDir: string): InstalledCursorHooks { + return JSON.parse( + readFileSync(join(homeDir, '.cursor', 'hooks.json'), 'utf8') + ) as InstalledCursorHooks +} + +function requireRegisteredCommand(config: InstalledCursorHooks, eventName: string): string { + const command = config.hooks[eventName]?.[0]?.command + expect(command, eventName).toEqual(expect.any(String)) + if (typeof command !== 'string') { + throw new Error(`missing Cursor hook command for ${eventName}`) + } + return command +} + +function runRegisteredCursorHook( + command: string, + input: string, + extraEnv: NodeJS.ProcessEnv = {} +): { stdout: string; stderr: string; status: number | null } { + const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh' + const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command] + const result = spawnSync(executable, args, { + encoding: 'utf8', + input, + timeout: 15_000, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_PANE_KEY: '', + ...extraEnv + } + }) + expect(result.error, result.stderr).toBeUndefined() + return { stdout: result.stdout, stderr: result.stderr, status: result.status } +} + describe('CursorHookService', () => { let homeDir: string @@ -156,4 +202,102 @@ describe('CursorHookService', () => { '/usr/local/bin/retired-user-hook' ]) }) + + // Why: installer-intent assertions missed empty stdout, which Cursor treats as + // invalid JSON and fails closed (#15462). This runs the registered command. + it('emits protocol-valid JSON on stdout for every managed event, including empty stdin (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + const payloads = [ + (eventName: string) => JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + () => '' + ] + + for (const eventName of CURSOR_EVENTS) { + const command = requireRegisteredCommand(config, eventName) + for (const payloadFor of payloads) { + const result = runRegisteredCursorHook(command, payloadFor(eventName)) + expect(result.status, `${eventName} exit`).toBe(0) + expect(result.stderr, `${eventName} stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + } + }) + + it('emits protocol-valid JSON when the managed Cursor script is missing (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME)) + + for (const eventName of CURSOR_EVENTS) { + const command = requireRegisteredCommand(config, eventName) + const result = runRegisteredCursorHook(command, '') + expect(result.status, `${eventName} missing-script exit`).toBe(0) + expect(result.stderr, `${eventName} missing-script stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} missing-script stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + }) + + it('keeps curl failure off stdout when the listener is unreachable (#15462)', () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + + for (const eventName of ['beforeSubmitPrompt', 'preToolUse', 'stop'] as const) { + const command = requireRegisteredCommand(config, eventName) + const result = runRegisteredCursorHook( + command, + JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + { + ORCA_AGENT_HOOK_PORT: '59999', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + } + ) + expect(result.status, `${eventName} dead-listener exit`).toBe(0) + expect(JSON.parse(result.stdout), `${eventName} dead-listener stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)', + () => { + expect(new CursorHookService().install().state).toBe('installed') + const config = readInstalledCursorHooks(homeDir) + const gitBash = findGitBash() + const shells = [ + { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c'] }, + { name: 'Git Bash', executable: gitBash, args: ['-c'] } + ] + for (const eventName of ['beforeSubmitPrompt', 'preToolUse'] as const) { + const command = requireRegisteredCommand(config, eventName) + for (const shell of shells) { + const result = spawnSync(shell.executable, [...shell.args, command], { + encoding: 'utf8', + input: JSON.stringify({ hook_event_name: eventName, tool_name: 'Write' }), + timeout: 15_000, + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '', + ORCA_AGENT_HOOK_TOKEN: '', + ORCA_PANE_KEY: '', + USERPROFILE: homeDir + } + }) + expect(result.error, `${eventName} ${shell.name}`).toBeUndefined() + expect(result.status, `${eventName} ${shell.name} exit`).toBe(0) + expect(result.stderr, `${eventName} ${shell.name} stderr`).toBe('') + expect(JSON.parse(result.stdout), `${eventName} ${shell.name} stdout`).toEqual( + EXPECTED_CURSOR_HOOK_STDOUT[eventName] + ) + } + } + } + ) }) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 310afad939c..b07639f108a 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -5,12 +5,9 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared import { buildManagedCommandDefinition, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, - wrapPosixHookCommand, - wrapWindowsHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -21,25 +18,8 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' -import { - buildPosixHookPayloadCapture, - buildPosixHookSpoolLines, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' - -// Subscribe only to Cursor hooks needed for spinner and turn detection. -// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. -const CURSOR_EVENTS = [ - 'beforeSubmitPrompt', - 'stop', - 'preToolUse', - 'postToolUse', - 'postToolUseFailure', - 'beforeShellExecution', - 'beforeMCPExecution', - 'afterAgentResponse' -] as const +import { CURSOR_EVENTS } from './hook-events' +import { getManagedCommand, getManagedScript, getPosixManagedCommand } from './hook-script' function getConfigPath(): string { return join(homedir(), '.cursor', 'hooks.json') @@ -53,6 +33,7 @@ function getManagedScriptPath(): string { return getSharedManagedScriptPath(getManagedScriptFileName()) } +/* function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? wrapWindowsHookCommand(scriptPath) @@ -103,7 +84,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '' ].join('\n') } - +*/ export class CursorHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) @@ -123,10 +104,10 @@ export class CursorHookService { } } - const command = getManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 for (const eventName of CURSOR_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] // Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes. const hasCommand = definitions.some( @@ -170,7 +151,6 @@ export class CursorHookService { } } - const command = getManagedCommand(scriptPath) // Why: config.hooks is undefined on a fresh file with no prior hook install. const nextHooks = { ...config.hooks } const managedEvents = new Set(CURSOR_EVENTS) @@ -189,7 +169,7 @@ export class CursorHookService { const cleaned = removeManagedCommands(definitions, isManagedCommand) // Also strip entries with the command at the top level (Cursor schema). const strippedCursorShape = cleaned.filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) if (strippedCursorShape.length === 0) { delete nextHooks[eventName] @@ -199,10 +179,11 @@ export class CursorHookService { } for (const eventName of CURSOR_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] // Sweep Claude- and Cursor-shaped variants so installs converge on one entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) // Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape. const definition: HookDefinition = buildManagedCommandDefinition(command) @@ -235,15 +216,15 @@ export class CursorHookService { } } - const command = wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh') for (const eventName of CURSOR_EVENTS) { + const command = getPosixManagedCommand(remoteScriptPath, eventName) const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] // Why: dual-shape sweep so repeated installs converge on a single managed entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) const definition: HookDefinition = buildManagedCommandDefinition(command) nextHooks[eventName] = [...cleaned, definition] @@ -297,7 +278,7 @@ export class CursorHookService { continue } const cleaned = removeManagedCommands(definitions, isManagedCommand).filter( - (definition) => !isManagedCommand(definition.command as string | undefined) + (definition) => !isManagedCommand(definition.command) ) if (cleaned.length === 0) { delete nextHooks[eventName]