mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -118,14 +118,20 @@ export {
|
||||
|
||||
export function wrapWindowsHookCommand(
|
||||
scriptPath: string,
|
||||
env: Record<string, string> = {}
|
||||
env: Record<string, string> = {},
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
const EXPECTED_CURSOR_HOOK_RESPONSES = {
|
||||
beforeSubmitPrompt: '{"continue":true}',
|
||||
stop: '{}',
|
||||
preToolUse: '{"permission":"allow"}',
|
||||
postToolUse: '{}',
|
||||
postToolUseFailure: '{}',
|
||||
beforeShellExecution: '{"permission":"allow"}',
|
||||
beforeMCPExecution: '{"permission":"allow"}',
|
||||
afterAgentResponse: '{}'
|
||||
} satisfies Record<CursorEvent, string>
|
||||
|
||||
function createFakeSftp(initialFiles: Record<string, string> = {}): {
|
||||
sftp: SFTPWrapper
|
||||
fs: FakeFs
|
||||
@@ -366,19 +378,14 @@ describe('remote hook service installers', () => {
|
||||
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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<CursorEvent, string>
|
||||
|
||||
export function getCursorHookResponse(eventName: CursorEvent): string {
|
||||
return CURSOR_HOOK_RESPONSES[eventName]
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string, { command?: string }[]>
|
||||
}
|
||||
|
||||
const EXPECTED_CURSOR_HOOK_STDOUT = {
|
||||
beforeSubmitPrompt: { continue: true },
|
||||
stop: {},
|
||||
preToolUse: { permission: 'allow' },
|
||||
postToolUse: {},
|
||||
postToolUseFailure: {},
|
||||
beforeShellExecution: { permission: 'allow' },
|
||||
beforeMCPExecution: { permission: 'allow' },
|
||||
afterAgentResponse: {}
|
||||
} satisfies Record<CursorEvent, Record<string, unknown>>
|
||||
|
||||
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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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<string>(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]
|
||||
|
||||
Reference in New Issue
Block a user