MEDIUM fixes (code review):
- Wire resolveManagedHookEvents + resolveHookScripts + buildHookBusEntries
from imperative-hook-bus.cts into writeCursorHooksJson — the install path
is now truly descriptor-driven (reads hostBehaviors.managedHookEvents),
not a hardcoded constant that happens to match the descriptor. bin/install.js
passes the descriptor list via opts.managedHookEvents.
- buildHookBusEntries is now consumed (was dead code); entry-building is no
longer duplicated inline.
- Remove try/finally from cursor-hook-bus-upgrade.test.cjs test bodies
(violated CONTRIBUTING.md L342; redundant with t.after cleanup).
LOW fixes:
- Remove dead require('fs')/require('path') from gsd-cursor-pre-tool.js
- Fix resolveManagedHookEvents docstring (all-invalid fallback behavior)
- Add src/runtime-hooks-surface.cts to the AC2 source-guard file list
Security review: no CRITICAL/HIGH/MEDIUM findings (3 LOW are pre-existing
#777 baseline patterns, not regressions).
156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
/**
|
|
* Imperative hook-bus adapter — descriptor-driven hooks.json binding
|
|
* (ADR-1239 Phase D / #2089).
|
|
*
|
|
* Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson`
|
|
* into a descriptor-driven hook-bus binding that reads the negotiated `hookBus`
|
|
* axis + the host's documented hook-event list (from
|
|
* `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded
|
|
* `sessionStart`/`postToolUse` pair.
|
|
*
|
|
* This module is PURE (no I/O): it resolves the event→script mapping and builds
|
|
* the hooks.json entry manifest. The actual file I/O (copying scripts, writing
|
|
* hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure
|
|
* functions exported here. This separation makes the binding testable without a
|
|
* filesystem.
|
|
*
|
|
* Cursor hook-event universe (closed vocabulary per ADR-1239,
|
|
* https://cursor.com/docs/hooks):
|
|
* sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart,
|
|
* subagentStop, beforeShellExecution, afterShellExecution,
|
|
* afterMCPExecution, afterFileEdit, preCompact, stop,
|
|
* beforeTabFileRead, afterTabFileEdit, workspaceOpen
|
|
*
|
|
* GSD registers for the 6 events in the portable floor + subagent lifecycle
|
|
* (AC4a upgrade, #2089):
|
|
* sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop
|
|
*/
|
|
'use strict';
|
|
|
|
/**
|
|
* The full set of Cursor hook events GSD can register for.
|
|
* Frozen closed vocabulary — adding an event requires updating both this set
|
|
* and the event→script mapping below.
|
|
*/
|
|
export const CURSOR_HOOK_EVENTS = Object.freeze([
|
|
'sessionStart',
|
|
'postToolUse',
|
|
'preToolUse',
|
|
'stop',
|
|
'subagentStart',
|
|
'subagentStop',
|
|
] as const);
|
|
|
|
export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number];
|
|
|
|
/**
|
|
* Event → hook-script mapping. Each event maps to a standalone `.js` script
|
|
* under `hooks/` that Cursor invokes via `hooks.json`.
|
|
*
|
|
* Convention: `gsd-cursor-<kebab-event>.js`. The script files are authored in
|
|
* `hooks/` and copied to `<configDir>/hooks/` during install by
|
|
* `runtime-hooks-surface.cts`.
|
|
*/
|
|
export const CURSOR_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
sessionStart: 'gsd-cursor-session-start.js',
|
|
postToolUse: 'gsd-cursor-post-tool.js',
|
|
preToolUse: 'gsd-cursor-pre-tool.js',
|
|
stop: 'gsd-cursor-stop.js',
|
|
subagentStart: 'gsd-cursor-subagent-start.js',
|
|
subagentStop: 'gsd-cursor-subagent-stop.js',
|
|
});
|
|
|
|
/**
|
|
* The GSD-managed marker written into each hooks.json entry so the
|
|
* reconcile pass can distinguish GSD-owned entries from user-owned ones.
|
|
*/
|
|
export const GSD_HOOK_MARKER = 'gsd-managed';
|
|
|
|
/**
|
|
* Resolve the managed hook events from a runtime descriptor's
|
|
* `hostBehaviors.managedHookEvents` list. Falls back to the full
|
|
* `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list
|
|
* (backward-compat for descriptors predating #2089).
|
|
*
|
|
* Pure: no I/O, never throws. Unknown event names are silently filtered
|
|
* (fail-closed — an unrecognized event is never registered). Falls back to
|
|
* the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries
|
|
* are unrecognized (ensures the portable-event floor is always covered).
|
|
*
|
|
* @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
|
|
* @returns a deduplicated, validated array of event names
|
|
*/
|
|
export function resolveManagedHookEvents(
|
|
managedHookEvents: readonly string[] | null | undefined,
|
|
): readonly string[] {
|
|
if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) {
|
|
return CURSOR_HOOK_EVENTS;
|
|
}
|
|
const valid = new Set<string>(CURSOR_HOOK_EVENTS);
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
for (const ev of managedHookEvents) {
|
|
if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) {
|
|
seen.add(ev);
|
|
result.push(ev);
|
|
}
|
|
}
|
|
return result.length > 0 ? result : CURSOR_HOOK_EVENTS;
|
|
}
|
|
|
|
/**
|
|
* Build the list of hook script files that need to be copied for the given
|
|
* managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`.
|
|
*
|
|
* Pure: returns a deduplicated array of script filenames.
|
|
*
|
|
* @param events - the managed event names (validated by `resolveManagedHookEvents`)
|
|
* @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`)
|
|
*/
|
|
export function resolveHookScripts(
|
|
events: readonly string[],
|
|
): readonly string[] {
|
|
const scripts: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const ev of events) {
|
|
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
|
if (script && !seen.has(script)) {
|
|
seen.add(script);
|
|
scripts.push(script);
|
|
}
|
|
}
|
|
return scripts;
|
|
}
|
|
|
|
/**
|
|
* Build the hooks.json managed-entry manifest for the given events.
|
|
* Each entry is `{ type: 'command', command: <cmd>, [GSD_HOOK_MARKER]: true }`.
|
|
*
|
|
* The `command` string is built by the caller (it requires platform-specific
|
|
* node-runner resolution from `runtime-hooks-surface.cts`). This function
|
|
* receives a pre-built `event → command` map and attaches the marker.
|
|
*
|
|
* Pure: no I/O.
|
|
*
|
|
* @param events - the managed event names
|
|
* @param commands - a map of event → command string (built by the caller)
|
|
* @returns a map of event → managed entry, ready for hooks.json reconciliation
|
|
*/
|
|
export function buildHookBusEntries(
|
|
events: readonly string[],
|
|
commands: Readonly<Record<string, string | null>>,
|
|
): Record<string, { type: string; command: string; [key: string]: unknown }> {
|
|
const entries: Record<string, { type: string; command: string; [key: string]: unknown }> = {};
|
|
for (const ev of events) {
|
|
const cmd = commands[ev];
|
|
if (cmd) {
|
|
entries[ev] = {
|
|
type: 'command',
|
|
command: cmd,
|
|
[GSD_HOOK_MARKER]: true,
|
|
};
|
|
}
|
|
}
|
|
return entries;
|
|
}
|