Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claude-hook-manifest-pointer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"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)
45 changes: 37 additions & 8 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,15 @@ 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: 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. Per-host `nativeHooks` passthrough stays with the
* host targets.
* 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.
*
* 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
Expand Down Expand Up @@ -361,7 +363,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), plus `hooks-cursor.json` with per-hook Cursor wrappers (`<name>.cursor.mjs`).',
'- `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 (`<name>.cursor.mjs`).',
'- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.',
'- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.',
'',
Expand Down Expand Up @@ -419,6 +421,32 @@ 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({
Expand Down Expand Up @@ -469,6 +497,7 @@ 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',
Expand Down
10 changes: 7 additions & 3 deletions packages/agent-bundle/tests/plugin-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,11 @@ 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<string, unknown>;
expect(claudePlugin).toMatchObject({ name: 'bundle-example', version: '2.0.0' });
expect(claudePlugin).not.toHaveProperty('hooks');
expect(claudePlugin).toMatchObject({
hooks: './hooks/hooks.json',
name: 'bundle-example',
version: '2.0.0',
});

const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record<string, unknown>;
expect(codexPlugin).toMatchObject({
Expand Down Expand Up @@ -805,7 +808,8 @@ it('builds the unified bundle root on disk with a compiled universal hook wrappe
try {
await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() });
const bundleRoot = join(outputRoot, 'plugin');
await expect(readFile(join(bundleRoot, '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('bundle-example');
const claudePlugin = JSON.parse(await readFile(join(bundleRoot, '.claude-plugin', 'plugin.json'), 'utf8')) as Record<string, unknown>;
expect(claudePlugin).toMatchObject({ hooks: './hooks/hooks.json', name: 'bundle-example' });
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);
Expand Down
63 changes: 63 additions & 0 deletions packages/agent-bundle/tests/plugin-claude-hook-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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"');
});
4 changes: 4 additions & 0 deletions packages/agent-bundle/tests/target-hook-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ it('bakes the concrete Cursor target only into the plugin Cursor event wrapper',
const shared = hookEntries.find((entry) => !entry.relativePath.endsWith('.cursor.mjs'));
const cursor = hookEntries.find((entry) => 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"');
expect(shared?.virtualSource).toContain('const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;');
expect(shared?.virtualSource).toContain('process.env.PLUGIN_ROOT === undefined ? "claude" : "codex"');
expect(shared?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })');
Expand Down
6 changes: 5 additions & 1 deletion website/docs/en/guide/authoring/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,11 @@ listed above.
### What is on the wire

Both shapes share the emitted `hooks/hooks.json` wiring, and both compile into a wrapper the host
invokes as `node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs"` (or the host's own root token). A
invokes as `node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs"` (or the host's own root token). The
unified `plugin` target also writes `hooks/hooks-cursor.json` and `hooks/<name>.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
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:
Expand Down
2 changes: 1 addition & 1 deletion website/docs/en/guide/distribution/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. | `user`, `project`, `local` |
| 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` |
| Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. | `user` |
| Cursor | Copies the bundle into `~/.cursor/plugins/local/<name>` (`--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/<name>` 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` |

Expand Down
6 changes: 5 additions & 1 deletion website/docs/zh/guide/authoring/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,11 @@ Cursor 的 `followup_message`。每个宿主支持哪些事件族,见生成的
### 线上到底传了什么

两种形态共享输出的 `hooks/hooks.json` 接线,并且都编译成宿主以
`node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs"`(或宿主自己的根令牌)调用的包装层。配置声明的处理器
`node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs"`(或宿主自己的根令牌)调用的包装层。统一 `plugin`
target 还会为 Cursor 写出 `hooks/hooks-cursor.json` 与 `hooks/<name>.cursor.mjs`(`preToolUse` /
`postToolUse` 小驼峰)。`.claude-plugin/plugin.json` 会写明 `./hooks/hooks.json`,这样 Claude Code
只加载 Claude/Codex 文档,而不会用 PascalCase 的 `hook_event_name`(`PreToolUse` / `PostToolUse`)
去调用那些 Cursor 包装层。配置声明的处理器
在该包装层进程内运行。`runtime: 'shared'` 的事件路由则转发给生成的 MCP 服务器进程内的常驻运行时,
因此钩子与工具共享状态:

Expand Down
2 changes: 1 addition & 1 deletion website/docs/zh/guide/distribution/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ target 目录。

| 宿主 | 机制 | 作用域 |
| --- | --- | --- |
| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。 | `user`、`project`、`local` |
| 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` |
| Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。 | `user` |
| Cursor | 把捆绑包复制到 `~/.cursor/plugins/local/<name>`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/<name>` 下暂存一个已提交的本地市场仓库,并打印 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` |

Expand Down
Loading