From 4099c395e87041e286e0439c6d850f8d229682ec Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 3 Sep 2026 23:01:57 +0000 Subject: [PATCH 1/2] fix(claude): drop the manifest hooks pointer and pin every Claude hook_event_name Claude Code loads `hooks/hooks.json` on its own; `manifest.hooks` is only for additional documents. The `claude` target has named `./hooks/hooks.json` there since b256d44d7 and #450 added the same pointer to the unified `plugin` bundle. Claude Code 2.1.259 records a `hook-load-failed` plugin error for it: "Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file ... The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files" (reproduced with an isolated CLAUDE_CONFIG_DIR against a built Claude pack). #450's stated root cause does not hold: Claude Code never scans `hooks/` for other documents, so `hooks/hooks-cursor.json` is invisible to it. The live "native hook_event_name must equal postToolUse" errors came from the Claude wrapper path (`event-route-tool-after.mjs`, per the session transcript) during a rebuild/reinstall window of a directory marketplace, whose `${CLAUDE_PLUGIN_ROOT}` is the build output itself; only a Cursor-built wrapper bakes that camelCase constant. - claude.ts / plugin.ts: emit no `hooks` field on `.claude-plugin/plugin.json`. - tests/claude-hook-event-name.test.ts: for every supported Claude event route, the planned wrapper bakes the pinned PascalCase name, the hooks document is keyed by it, and a real Claude envelope passes validateNativeEventEnvelope; the live PostToolUse:Bash envelope is accepted by the Claude wrapper and rejected with the exact observed message only under a Cursor-baked validation; the unified bundle keeps `.mjs`/`.cursor.mjs` spellings apart; no Claude manifest pointer. - docs (en+zh hooks.mdx, installation.mdx) state the loader behavior. --- .changeset/claude-hook-manifest-no-pointer.md | 5 + .changeset/claude-hook-manifest-pointer.md | 5 - packages/agent-bundle/src/adapters/claude.ts | 7 +- packages/agent-bundle/src/adapters/plugin.ts | 49 +--- .../tests/claude-hook-event-name.test.ts | 251 ++++++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 6 +- .../agent-bundle/tests/plugin-bundle.test.ts | 12 +- .../tests/plugin-claude-hook-manifest.test.ts | 63 ----- website/docs/en/guide/authoring/hooks.mdx | 10 +- .../en/guide/distribution/installation.mdx | 2 +- website/docs/zh/guide/authoring/hooks.mdx | 9 +- .../zh/guide/distribution/installation.mdx | 2 +- 12 files changed, 298 insertions(+), 123 deletions(-) create mode 100644 .changeset/claude-hook-manifest-no-pointer.md delete mode 100644 .changeset/claude-hook-manifest-pointer.md create mode 100644 packages/agent-bundle/tests/claude-hook-event-name.test.ts delete mode 100644 packages/agent-bundle/tests/plugin-claude-hook-manifest.test.ts diff --git a/.changeset/claude-hook-manifest-no-pointer.md b/.changeset/claude-hook-manifest-no-pointer.md new file mode 100644 index 000000000..318c6cf04 --- /dev/null +++ b/.changeset/claude-hook-manifest-no-pointer.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Stop emitting a `hooks` pointer in `.claude-plugin/plugin.json` for the `claude` and unified `plugin` targets: Claude Code loads `hooks/hooks.json` on its own and reports a manifest pointer at that same file as a duplicate hooks file (`hook-load-failed`, observed on Claude Code 2.1.259). The generated Claude wrappers keep comparing `hook_event_name` against the pinned PascalCase spellings (`PreToolUse`, `PostToolUse`, `Stop`, ... for every supported Claude event), now covered by a per-event regression test; `native hook_event_name must equal postToolUse` on a Claude session identifies a Cursor-built wrapper under the Claude plugin root. (#PR) diff --git a/.changeset/claude-hook-manifest-pointer.md b/.changeset/claude-hook-manifest-pointer.md deleted file mode 100644 index ee14cb265..000000000 --- a/.changeset/claude-hook-manifest-pointer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"agent-bundle": patch ---- - -Point the unified `plugin` bundle's `.claude-plugin/plugin.json` `hooks` field at `./hooks/hooks.json` so Claude Code loads the Claude/Codex document instead of also discovering `hooks/hooks-cursor.json` and invoking Cursor wrappers that expect camelCase `hook_event_name` values (`preToolUse` / `postToolUse`). (#450) diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 8c9816a4b..4a2365a17 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -3155,13 +3155,18 @@ export const planClaudeArtifacts = ( const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document); const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); + // Claude Code loads the conventional `hooks/hooks.json` on its own; the + // manifest `hooks` field is only for *additional* documents. Naming the + // conventional file there makes Claude Code (2.1.259 observed) record a + // `hook-load-failed` plugin error, "Duplicate hooks file detected ... The + // standard hooks/hooks.json is loaded automatically, so manifest.hooks + // should only reference additional hook files", so no pointer is emitted. const plugin = { author: { name: model.metadata.name }, ...manifestMetadata.document, ...(channels.document === undefined ? {} : { channels: channels.document }), ...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }), description: model.metadata.description ?? model.metadata.name, - ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), name: model.metadata.name, ...(userConfig.document === undefined ? {} : { userConfig: userConfig.document }), version: model.metadata.version, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 4a17e094b..d91a94792 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -72,15 +72,17 @@ const pluginName = 'plugin'; * convention, so the Claude document owns that slot; Codex's manifest carries * explicit pointers, so its MCP document relocates under `.codex-plugin/`. * - * Hooks ship once: Codex documents discovering `hooks/hooks.json` at the - * plugin root, exporting `CLAUDE_PLUGIN_ROOT` into hook processes as a - * compatibility alias and running commands through a real shell, and its - * hook envelope and output contract match Claude's — so one Claude-format - * hook document plus one runtime-host-detecting wrapper per hook serves - * both hosts. Claude Code's `.claude-plugin/plugin.json` names that same - * `./hooks/hooks.json` so the host does not also load `hooks/hooks-cursor.json` - * (Cursor's camelCase `hook_event_name` values) from the shared `hooks/` - * directory. Per-host `nativeHooks` passthrough stays with the host targets. + * Hooks ship once: both hosts document discovering `hooks/hooks.json` at the + * plugin root, Codex documents exporting `CLAUDE_PLUGIN_ROOT` into hook + * processes as a compatibility alias and running commands through a real + * shell, and its hook envelope and output contract match Claude's - so one + * Claude-format hook document plus one runtime-host-detecting wrapper per + * hook serves both hosts. Claude Code loads exactly that conventional file + * and never scans `hooks/` for other documents, so `hooks/hooks-cursor.json` + * is invisible to it; the Claude manifest therefore carries no `hooks` + * pointer (naming the conventional file again is reported by Claude Code as + * a duplicate hooks file, see the Claude adapter). Per-host `nativeHooks` + * passthrough stays with the host targets. * * The full Cursor Plugin contract consumes the same root through `.cursor-plugin/plugin.json`: shared * `skills/` as-is, the conventional root `mcp.json`, and - because @@ -363,7 +365,7 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', ] : []), - '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex; named by `.claude-plugin/plugin.json`), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', + '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', '- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.', '- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.', '', @@ -421,32 +423,6 @@ const mergeEntries = ( return [...merged.values()]; }; -/** - * The Claude half is planned hook-free so this adapter can emit one shared - * `hooks/hooks.json`. Stamp the Claude manifest with that path so Claude - * Code loads it instead of also discovering `hooks/hooks-cursor.json`. - */ -const attachClaudeHookManifest = ( - entries: TargetArtifactEntry[], - hookSourceInputs: readonly string[], -): void => { - const index = entries.findIndex((entry) => entry.relativePath === claudeArtifactPaths.plugin); - if (index === -1) return; - const existing = entries[index]!; - if (existing.kind !== 'write') { - throw new Error('Agent plugin bundle Claude plugin.json must be a generated write entry.'); - } - const parsed: unknown = JSON.parse(existing.content); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Agent plugin bundle Claude plugin.json must be a JSON object.'); - } - entries[index] = Object.freeze({ - ...existing, - content: `${stableJson({ ...parsed, hooks: `./${bundleHookContract.manifestPath}` })}\n`, - sourceInputs: sourceInputs(...existing.sourceInputs, ...hookSourceInputs), - }); -}; - const cursorMcpPlanContext = Object.freeze({ codePrefix: 'plugin.cursor', errorDiagnostic }); const cursorBundleHookContract = createCursorHookContract({ @@ -497,7 +473,6 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const hookSourceInputs = model.hooks .filter((hook) => hook.targets.includes(pluginName)) .map((hook) => hook.provenance.sourcePath); - attachClaudeHookManifest(entries, hookSourceInputs); entries.push({ content: `${stableJson(hookDocument)}\n`, kind: 'write', diff --git a/packages/agent-bundle/tests/claude-hook-event-name.test.ts b/packages/agent-bundle/tests/claude-hook-event-name.test.ts new file mode 100644 index 000000000..a90f4ecad --- /dev/null +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -0,0 +1,251 @@ +import { readFile } from 'node:fs/promises'; + +import { expect, it } from '@rstest/core'; + +import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.250.json' with { type: 'json' }; +import { claudeAdapter } from '../src/adapters/claude.ts'; +import { pluginAdapter } from '../src/adapters/plugin.ts'; +import type { NormalizedHook, NormalizedHookEvent, NormalizedPlugin } from '../src/core/types.ts'; +import { validateNativeEventEnvelope } from '../src/events/projection.ts'; +import type { CanonicalAgentEvent } from '../src/routes/public.ts'; + +const configPath = '/workspace/agent-bundle.config.ts'; + +/** + * Every event route the pinned Claude capability table supports, paired with + * the hook identity `normalizeProject` assigns it and a native envelope of the + * shape Claude Code writes to the wrapper's stdin. Fixture-backed rows reuse + * the documented envelopes under `fixtures/events/`; the four inline rows are + * the events Claude Code fires on every session (the maintainer's live + * session below reproduced exactly these). + */ +const claudeEventRoutes: readonly { + readonly hookEvent: NormalizedHookEvent; + readonly native: string | Readonly>; + readonly route: CanonicalAgentEvent; +}[] = [ + { hookEvent: 'agentIdle', native: 'claude-teammate-idle.json', route: 'agent/idle' }, + { hookEvent: 'agentStart', native: 'claude-subagent-start.json', route: 'agent/start' }, + { hookEvent: 'agentStop', native: 'claude-subagent-stop.json', route: 'agent/stop' }, + { hookEvent: 'compactAfter', native: 'claude-post-compact.json', route: 'compact/after' }, + { hookEvent: 'compactBefore', native: 'claude-pre-compact.json', route: 'compact/before' }, + { hookEvent: 'configChange', native: 'claude-config-change.json', route: 'config/change' }, + { hookEvent: 'fileChange', native: 'claude-file-changed.json', route: 'file/change' }, + { hookEvent: 'permissionDenied', native: 'claude-permission-denied.json', route: 'permission/denied' }, + { hookEvent: 'permissionRequest', native: 'claude-permission-request.json', route: 'permission/request' }, + { hookEvent: 'promptSubmit', native: 'claude-user-prompt-submit.json', route: 'prompt/submit' }, + { hookEvent: 'sessionEnd', native: 'claude-session-end.json', route: 'session/end' }, + { + hookEvent: 'sessionStart', + native: { + cwd: '/workspace', + hook_event_name: 'SessionStart', + session_id: 'session-claude-1', + source: 'startup', + transcript_path: '/workspace/.claude/projects/session.jsonl', + }, + route: 'session/start', + }, + { + hookEvent: 'stop', + native: { + cwd: '/workspace', + hook_event_name: 'Stop', + last_assistant_message: 'Done.', + permission_mode: 'default', + session_id: 'session-claude-1', + stop_hook_active: false, + transcript_path: '/workspace/.claude/projects/session.jsonl', + }, + route: 'stop', + }, + { hookEvent: 'stopFailure', native: 'claude-stop-failure.json', route: 'stop/failure' }, + { hookEvent: 'taskComplete', native: 'claude-task-completed.json', route: 'task/complete' }, + { hookEvent: 'taskCreate', native: 'claude-task-created.json', route: 'task/create' }, + { + hookEvent: 'afterTool', + native: { + cwd: '/workspace', + hook_event_name: 'PostToolUse', + permission_mode: 'bypassPermissions', + session_id: 'session-claude-1', + tool_input: { command: 'git status --short', description: 'Show the working tree' }, + tool_name: 'Bash', + tool_response: { interrupted: false, isImage: false, stderr: '', stdout: ' M README.md\n' }, + tool_use_id: 'toolu_01LvwxiKhvU7wJ1Hf2MUJ2hu', + transcript_path: '/workspace/.claude/projects/session.jsonl', + }, + route: 'tool/after', + }, + { + hookEvent: 'beforeTool', + native: { + cwd: '/workspace', + hook_event_name: 'PreToolUse', + permission_mode: 'bypassPermissions', + session_id: 'session-claude-1', + tool_input: { command: 'git status --short', description: 'Show the working tree' }, + tool_name: 'Bash', + tool_use_id: 'toolu_01Taws9XLqrL8XQk4BsTkjps', + transcript_path: '/workspace/.claude/projects/session.jsonl', + }, + route: 'tool/before', + }, + { hookEvent: 'toolFailure', native: 'claude-post-tool-use-failure.json', route: 'tool/failure' }, +]; + +const pinnedClaudeRoutes: Readonly> = + claudeCapabilityTable.hooks.eventRoutes; + +const supportedClaudeRoutes = Object.entries(pinnedClaudeRoutes) + .filter(([, capability]) => capability.state === 'supported') + .map(([route]) => route) + .sort(); + +const routeHook = ( + route: CanonicalAgentEvent, + hookEvent: NormalizedHookEvent, + targets: readonly string[], +): NormalizedHook => { + const name = `event-route-${route.replace('/', '-')}`; + return { + event: hookEvent, + eventRoute: { event: route, fallback: 'none', runtime: 'shared' }, + id: `hook:${name}`, + name, + provenance: { kind: 'conventional', sourcePath: `/workspace/src/events/${route}.tsx` }, + source: `/workspace/src/events/${route}.tsx`, + targets, + tools: [], + }; +}; + +const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlugin => ({ + extensions: {}, + hooks, + mcpServers: [], + metadata: { + description: 'Claude hook_event_name regression.', + id: 'plugin:hook-event-name', + name: 'hook-event-name', + provenance: { kind: 'config', sourcePath: configPath }, + version: '1.0.0', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: [{ + id: `target:${target}`, + name: target, + provenance: { kind: 'config', sourcePath: configPath }, + }], +}); + +const nativeEnvelope = async ( + native: string | Readonly>, +): Promise>> => + typeof native === 'string' + ? JSON.parse(await readFile(new URL(`./fixtures/events/${native}`, import.meta.url), 'utf8')) as Record + : native; + +const writes = (plan: { readonly entries: readonly { readonly kind: string; readonly relativePath: string; readonly content?: string }[] }) => + Object.fromEntries(plan.entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content!]] : [])); + +it('covers every event route the pinned Claude capability table supports', () => { + expect(claudeEventRoutes.map((entry) => entry.route).sort()).toEqual(supportedClaudeRoutes); +}); + +it('bakes the pinned Claude hook_event_name into every Claude event-route wrapper and accepts the native envelope', async () => { + const hooks = claudeEventRoutes.map((entry) => routeHook(entry.route, entry.hookEvent, ['claude'])); + const plan = claudeAdapter.plan(model('claude', hooks)); + expect(plan.diagnostics).toEqual([]); + + const document = JSON.parse(writes(plan)['hooks/hooks.json']!) as { readonly hooks: Record }; + for (const entry of claudeEventRoutes) { + const expectedNativeEvent = pinnedClaudeRoutes[entry.route]?.nativeEvent; + expect(expectedNativeEvent, entry.route).toEqual(expect.any(String)); + const wrapper = (plan.hookEntries ?? []).find((candidate) => candidate.hook.eventRoute?.event === entry.route); + expect(wrapper, entry.route).toBeDefined(); + // The document key, the baked constant, and the runtime comparison all + // carry Claude's PascalCase spelling; the wrapper compares the envelope's + // hook_event_name against exactly that constant. + expect(Object.keys(document.hooks), entry.route).toContain(expectedNativeEvent); + expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent); + expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`); + expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";'); + expect(wrapper!.virtualSource, entry.route).toContain('const target = artifactTarget;'); + expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })'); + + const native = await nativeEnvelope(entry.native); + expect(native.hook_event_name, entry.route).toBe(expectedNativeEvent); + expect( + validateNativeEventEnvelope(native, { canonicalEvent: entry.route, nativeEvent: wrapper!.nativeEvent, target: 'claude' }), + entry.route, + ).toEqual(native); + } +}); + +it('accepts the live PostToolUse:Bash envelope under the Claude wrapper and names a Cursor wrapper as the only source of the observed error', async () => { + // Regression for the maintainer's Claude Code 2.1.257 session (2026-09-03 + // 20:47:53Z): every `PostToolUse:Bash` hook failed with + // "Agent Bundle event route error: native hook_event_name must equal + // postToolUse". Claude sends PascalCase; only a wrapper compiled for the + // `cursor` target bakes the camelCase constant, so the message identifies a + // Cursor-built wrapper installed under a Claude plugin root, not a Claude + // mapping defect. + const live = await nativeEnvelope(claudeEventRoutes.find((entry) => entry.route === 'tool/after')!.native); + const [claudeWrapper] = claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])])).hookEntries ?? []; + expect(claudeWrapper?.nativeEvent).toBe('PostToolUse'); + expect(validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: claudeWrapper!.nativeEvent, target: 'claude' })) + .toEqual(live); + + expect(() => validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: 'postToolUse', target: 'cursor' })) + .toThrow('Agent Bundle event route error: native hook_event_name must equal postToolUse'); + expect(() => validateNativeEventEnvelope( + { ...live, hook_event_name: 'PreToolUse', tool_response: undefined }, + { canonicalEvent: 'tool/before', nativeEvent: 'preToolUse', target: 'cursor' }, + )).toThrow('Agent Bundle event route error: native hook_event_name must equal preToolUse'); +}); + +it('keeps the shared and Cursor wrappers of the unified plugin bundle on their own host spellings', () => { + const plan = pluginAdapter.plan(model('plugin', [ + routeHook('tool/after', 'afterTool', ['plugin']), + routeHook('session/start', 'sessionStart', ['plugin']), + ])); + expect(plan.diagnostics).toEqual([]); + + const shared = (plan.hookEntries ?? []).find((entry) => + entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs')); + const cursor = (plan.hookEntries ?? []).find((entry) => + entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs')); + expect(shared?.nativeEvent).toBe('PostToolUse'); + expect(cursor?.nativeEvent).toBe('postToolUse'); + expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); + expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); + + const documents = writes(plan); + expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']); + expect(Object.keys((JSON.parse(documents['hooks/hooks-cursor.json']!) as { hooks: object }).hooks).sort()).toEqual(['postToolUse', 'sessionStart']); +}); + +it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hooks.json and flags a pointer at it as a duplicate', () => { + // Claude Code 2.1.259 (observed): `hooks/hooks.json` is loaded on its own + // and `manifest.hooks` is for additional documents only. Naming the + // conventional file records a `hook-load-failed` plugin error, "Duplicate + // hooks file detected ... The standard hooks/hooks.json is loaded + // automatically, so manifest.hooks should only reference additional hook + // files." Claude Code never scans `hooks/` for other documents, so the + // unified bundle's `hooks/hooks-cursor.json` needs no pointer to hide it. + const claude = writes(claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])]))); + expect(claude['hooks/hooks.json']).toBeDefined(); + expect(JSON.parse(claude['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); + + const bundle = writes(pluginAdapter.plan(model('plugin', [routeHook('tool/after', 'afterTool', ['plugin'])]))); + expect(bundle['hooks/hooks.json']).toBeDefined(); + expect(bundle['hooks/hooks-cursor.json']).toBeDefined(); + expect(JSON.parse(bundle['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); + // Codex discovers the same conventional file; Cursor's own contract needs + // the explicit pointer because its document does not live at the default. + expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).not.toHaveProperty('hooks'); + expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' }); +}); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index caf88921e..eddc79101 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1611,9 +1611,9 @@ it('plans deterministic Codex and Claude hook configurations from the same model expect(JSON.parse(writes(codex.entries)['.codex-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json', }); - expect(JSON.parse(writes(claude.entries)['.claude-plugin/plugin.json']!)).toMatchObject({ - hooks: './hooks/hooks.json', - }); + // Claude Code loads hooks/hooks.json by convention and flags a manifest + // pointer at that same file as a duplicate, so only Codex names it. + expect(JSON.parse(writes(claude.entries)['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); expect(JSON.parse(writes(codex.entries)['hooks/hooks.json']!)).toEqual({ hooks: { PostToolUse: [{ diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index a5ef209f3..1713d0022 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -91,11 +91,10 @@ it('lays both host manifests over one shared bundle root', () => { const documents = writeContents(bundleModel); const claudePlugin = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - expect(claudePlugin).toMatchObject({ - hooks: './hooks/hooks.json', - name: 'bundle-example', - version: '2.0.0', - }); + expect(claudePlugin).toMatchObject({ name: 'bundle-example', version: '2.0.0' }); + // Claude Code auto-loads hooks/hooks.json and reports a manifest pointer at + // the same file as a duplicate hooks file, so the manifest never names it. + expect(claudePlugin).not.toHaveProperty('hooks'); const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; expect(codexPlugin).toMatchObject({ @@ -809,7 +808,8 @@ it('builds the unified bundle root on disk with a compiled universal hook wrappe await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); const bundleRoot = join(outputRoot, 'plugin'); const claudePlugin = JSON.parse(await readFile(join(bundleRoot, '.claude-plugin', 'plugin.json'), 'utf8')) as Record; - expect(claudePlugin).toMatchObject({ hooks: './hooks/hooks.json', name: 'bundle-example' }); + expect(claudePlugin).toMatchObject({ name: 'bundle-example' }); + expect(claudePlugin).not.toHaveProperty('hooks'); await expect(readFile(join(bundleRoot, '.codex-plugin', 'plugin.json'), 'utf8')).resolves.toContain('./skills/'); await expect(readFile(join(bundleRoot, 'AGENTS.md'), 'utf8')).resolves.toContain('multi-host agent plugin bundle'); await expect(readFile(join(bundleRoot, 'skills', 'review', 'SKILL.md'), 'utf8')).resolves.toBe(skillMarkdown); diff --git a/packages/agent-bundle/tests/plugin-claude-hook-manifest.test.ts b/packages/agent-bundle/tests/plugin-claude-hook-manifest.test.ts deleted file mode 100644 index 34496ea37..000000000 --- a/packages/agent-bundle/tests/plugin-claude-hook-manifest.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, it } from '@rstest/core'; - -import { pluginAdapter } from '../src/adapters/plugin.ts'; -import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; - -const configPath = '/workspace/agent-bundle.config.ts'; - -const hook = (event: NormalizedHook['event'], name: string): NormalizedHook => ({ - event, - id: `hook:${name}`, - name, - provenance: { kind: 'config', sourcePath: configPath }, - source: `/workspace/src/hooks/${name}.ts`, - targets: ['plugin'], - tools: [], -}); - -const model: NormalizedPlugin = { - extensions: {}, - hooks: [ - hook('afterTool', 'record-write'), - hook('sessionStart', 'session-start'), - ], - mcpServers: [], - metadata: { - description: 'Unified bundle hook pointer.', - id: 'plugin:hook-pointer', - name: 'hook-pointer', - provenance: { kind: 'config', sourcePath: configPath }, - version: '1.0.0', - }, - runtime: { node: '22.12.0' }, - scripts: [], - skills: [], - targets: [{ - id: 'target:plugin', - name: 'plugin', - provenance: { kind: 'config', sourcePath: configPath }, - }], -}; - -it('names hooks/hooks.json on the Claude manifest and bakes host-native event spellings', () => { - const plan = pluginAdapter.plan(model); - expect(plan.diagnostics).toEqual([]); - - const pluginJson = plan.entries.find((entry) => - entry.kind === 'write' && entry.relativePath === '.claude-plugin/plugin.json'); - expect(pluginJson?.kind).toBe('write'); - if (pluginJson?.kind !== 'write') throw new Error('expected Claude plugin.json write entry'); - expect(JSON.parse(pluginJson.content)).toMatchObject({ - hooks: './hooks/hooks.json', - name: 'hook-pointer', - }); - - const shared = (plan.hookEntries ?? []).find((entry) => - entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs')); - const cursor = (plan.hookEntries ?? []).find((entry) => - entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs')); - expect(shared?.nativeEvent).toBe('PostToolUse'); - expect(cursor?.nativeEvent).toBe('postToolUse'); - expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); - expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); -}); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index d50914425..12ac8b9df 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -208,9 +208,13 @@ listed above. Both shapes share the emitted `hooks/hooks.json` wiring, and both compile into a wrapper the host invokes as `node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"` (or the host's own root token). The unified `plugin` target also writes `hooks/hooks-cursor.json` and `hooks/.cursor.mjs` for -Cursor (`preToolUse` / `postToolUse` camelCase). `.claude-plugin/plugin.json` names -`./hooks/hooks.json` so Claude Code loads only the Claude/Codex document and does not invoke those -Cursor wrappers with PascalCase `hook_event_name` values (`PreToolUse` / `PostToolUse`). A +Cursor (`preToolUse` / `postToolUse` camelCase). Claude Code and Codex load `hooks/hooks.json` by +convention and never scan `hooks/` for other documents, so the Claude manifest carries no `hooks` +pointer: Claude Code reports a pointer at the conventional file as a duplicate hooks file +(`manifest.hooks` is for additional documents only). Each wrapper compares the envelope's +`hook_event_name` against the spelling baked for its own host (`PreToolUse` / `PostToolUse` for +Claude and Codex), so `native hook_event_name must equal postToolUse` on a Claude session means a +Cursor-built wrapper is installed under the Claude plugin root, not that Claude sends camelCase. A config-declared handler runs in-process inside that wrapper. An event route with `runtime: 'shared'` instead forwards to the warm runtime living inside the generated MCP server process, so hooks share state with tools: diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index f243e2c80..ff855e219 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -20,7 +20,7 @@ the root contains the selected host's target directory. | Host | Mechanism | Scopes | | --- | --- | --- | -| Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. The unified `plugin` bundle's `.claude-plugin/plugin.json` names `./hooks/hooks.json` so Claude loads the Claude/Codex document and not `hooks/hooks-cursor.json`. | `user`, `project`, `local` | +| Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the unified `plugin` bundle's `hooks/hooks-cursor.json` is never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | | Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. | `user` | | Cursor | Copies the bundle into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hooks document the plugin manifest names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target), so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index d83bcfb45..bde4db1b9 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -191,9 +191,12 @@ Cursor 的 `followup_message`。每个宿主支持哪些事件族,见生成的 两种形态共享输出的 `hooks/hooks.json` 接线,并且都编译成宿主以 `node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"`(或宿主自己的根令牌)调用的包装层。统一 `plugin` target 还会为 Cursor 写出 `hooks/hooks-cursor.json` 与 `hooks/.cursor.mjs`(`preToolUse` / -`postToolUse` 小驼峰)。`.claude-plugin/plugin.json` 会写明 `./hooks/hooks.json`,这样 Claude Code -只加载 Claude/Codex 文档,而不会用 PascalCase 的 `hook_event_name`(`PreToolUse` / `PostToolUse`) -去调用那些 Cursor 包装层。配置声明的处理器 +`postToolUse` 小驼峰)。Claude Code 与 Codex 按约定加载 `hooks/hooks.json`,且从不扫描 `hooks/` +下的其他文档,因此 Claude 清单不写 `hooks` 指针:Claude Code 会把指向约定文件的指针报告为重复的 +hooks 文件(`manifest.hooks` 只用于额外文档)。每个包装层都拿信封里的 `hook_event_name` 与为 +自己宿主烘焙的拼写比较(Claude 与 Codex 为 `PreToolUse` / `PostToolUse`),所以 Claude 会话里出现 +`native hook_event_name must equal postToolUse`,说明 Claude 插件根目录下装的是为 Cursor 构建的 +包装层,而不是 Claude 发送了小驼峰。配置声明的处理器 在该包装层进程内运行。`runtime: 'shared'` 的事件路由则转发给生成的 MCP 服务器进程内的常驻运行时, 因此钩子与工具共享状态: diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index e6f7d5b35..8c88de0bb 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -20,7 +20,7 @@ target 目录。 | 宿主 | 机制 | 作用域 | | --- | --- | --- | -| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。统一 `plugin` 捆绑包的 `.claude-plugin/plugin.json` 会写明 `./hooks/hooks.json`,因此 Claude 加载 Claude/Codex 文档而不是 `hooks/hooks-cursor.json`。 | `user`、`project`、`local` | +| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);统一 `plugin` 捆绑包的 `hooks/hooks-cursor.json` 从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | | Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。 | `user` | | Cursor | 把捆绑包复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载插件清单所指定的 hooks 文档(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`),因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | From fd94af08b2ca40fa7e03a211f9aa63b6c851e7cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:03:03 +0000 Subject: [PATCH 2/2] chore(changeset): reference #470 --- .changeset/claude-hook-manifest-no-pointer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/claude-hook-manifest-no-pointer.md b/.changeset/claude-hook-manifest-no-pointer.md index 318c6cf04..df056dc9d 100644 --- a/.changeset/claude-hook-manifest-no-pointer.md +++ b/.changeset/claude-hook-manifest-no-pointer.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Stop emitting a `hooks` pointer in `.claude-plugin/plugin.json` for the `claude` and unified `plugin` targets: Claude Code loads `hooks/hooks.json` on its own and reports a manifest pointer at that same file as a duplicate hooks file (`hook-load-failed`, observed on Claude Code 2.1.259). The generated Claude wrappers keep comparing `hook_event_name` against the pinned PascalCase spellings (`PreToolUse`, `PostToolUse`, `Stop`, ... for every supported Claude event), now covered by a per-event regression test; `native hook_event_name must equal postToolUse` on a Claude session identifies a Cursor-built wrapper under the Claude plugin root. (#PR) +Stop emitting a `hooks` pointer in `.claude-plugin/plugin.json` for the `claude` and unified `plugin` targets: Claude Code loads `hooks/hooks.json` on its own and reports a manifest pointer at that same file as a duplicate hooks file (`hook-load-failed`, observed on Claude Code 2.1.259). The generated Claude wrappers keep comparing `hook_event_name` against the pinned PascalCase spellings (`PreToolUse`, `PostToolUse`, `Stop`, ... for every supported Claude event), now covered by a per-event regression test; `native hook_event_name must equal postToolUse` on a Claude session identifies a Cursor-built wrapper under the Claude plugin root. (#470)