agent-base-kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  [![node](https://img.shields.io/node/v/agent-base-kit)](./package.json)
15
15
  [![license: MIT](https://img.shields.io/npm/l/agent-base-kit)](./LICENSE)
16
16
 
17
- [Why](#why) · [What you get](#what-you-get) · [Usage](#usage) · [What install writes](#what-install-writes) · [Fail-closed](#the-fail-closed-contract) · [Kinds](#kinds-and-matchers) · [API](#api)
17
+ [Why](#why) · [What you get](#what-you-get) · [Usage](#usage) · [What install writes](#what-install-writes) · [Fail-closed](#the-fail-closed-contract) · [Kinds](#kinds-and-matchers) · [Other events](#stop-and-userpromptsubmit) · [API](#api)
18
18
 
19
19
  </div>
20
20
 
@@ -26,7 +26,8 @@ agent-base-kit is that plumbing, shared by every hook you build. You write the c
26
26
 
27
27
  ## What you get
28
28
 
29
- - **One function to write.** `check(call)` gets a typed call (`shell`, `write`, `edit` or `patch`) and returns `{ block, reason }`, sync or async.
29
+ - **One function to write.** `check(call)` gets a typed call (`shell`, `write`, `edit`, `patch`, or a named tool such as an MCP server's) and returns `{ block, reason }`, sync or async.
30
+ - **Stop and UserPromptSubmit too.** Check the reply before the turn ends or the prompt before the model sees it, failing open where a broken hook would trap the session.
30
31
  - **Both agents.** Claude Code and Codex, with matchers derived from the kinds you list.
31
32
  - **Fail-closed by default.** Unreadable payloads, throws, bad verdicts, hangs and a hook that cannot start all block.
32
33
  - **One command to install.** Your bin gets `install` and `uninstall` for `claude` or `codex`, at user or project scope.
@@ -78,12 +79,18 @@ A user install (the default scope) does four things:
78
79
  4. **Writes the hook:**
79
80
 
80
81
  ```sh
81
- n='/path/to/the/installing/node'; [ -x "$n" ] || n=node; "$n" '/home/you/.<name>/current/cli.mjs' hook || exit 2
82
+ n='/path/to/the/installing/node'; [ -x "$n" ] || n=node; "$n" '/home/you/.<name>/current/cli.mjs' hook --event PreToolUse --agent claude || exit 2
82
83
  ```
83
84
 
84
- `--project` writes `npx --no-install <packageName> hook || exit 2` instead, which is safe to commit. Uninstall removes the entry and leaves the copy, since another agent may still run it.
85
+ `--project` writes `npx --no-install <packageName> hook --event PreToolUse --agent claude || exit 2` instead, which is safe to commit. Each of the plugin's [other events](#stop-and-userpromptsubmit) gets an entry of its own with the same command, naming its own event, minus `|| exit 2`. Uninstall removes every entry and leaves the copy, since another agent may still run it.
85
86
 
86
- Install replaces any entry it wrote before and keeps every other one. It refuses, and changes nothing, when the settings file is not JSON or not an object with a `hooks` object and a `PreToolUse` list, or when `~/.<name>/current` exists and is not a link.
87
+ `--event` tells the hook which event it runs for without reading the payload, so a Stop or UserPromptSubmit payload that arrives cut short or empty still passes instead of being blocked as unreadable. `--agent` tells it which agent to answer, so a prompt block sent to Codex carries only the fields Codex accepts. A command written before the flags existed still works: the hook then takes the event from the payload's `hook_event_name`, and treats a UserPromptSubmit payload carrying `turn_id` (which Codex's input schema requires and Claude Code does not send) as Codex's. Install recognizes those older commands as its own and replaces them; Codex asks to trust the hook once more, since its command changed.
88
+
89
+ Install replaces any entry it wrote before, at any event, and keeps every other one; an entry an earlier version wrote for an event this one no longer has is removed. It refuses, and changes nothing, when the settings file is not JSON, not an object with a `hooks` object, or has a `PreToolUse`, `Stop` or `UserPromptSubmit` that is not a list, or when `~/.<name>/current` exists and is not a link.
90
+
91
+ `onInstall` runs once the config is written, for what else the hook needs, such as a skill copied into the agent's directory; `onUninstall` runs after uninstall. Each gets the agent, scope, home, working directory, bin and config file, and may return `{ notes, warnings }`, which the command line prints after the config file, to stdout and stderr. A step that throws puts the config and `~/.<name>/current` back as they were and fails the command, so the hooks already installed keep their build.
92
+
93
+ A hook that wrote other commands before it moved to agent-base-kit lists them in `ownCommands`, so install replaces and uninstall removes those entries too.
87
94
 
88
95
  ## The fail-closed contract
89
96
 
@@ -94,8 +101,10 @@ The hook exits 0 only when your check passes the call:
94
101
  | `check` returns `{ block: false }` | 0 |
95
102
  | `check` returns `{ block: true, reason }` (the reason goes to stderr as is) | 2 |
96
103
  | `check` throws, rejects, never settles, or returns anything else | 2 |
97
- | The payload is not JSON, names no tool, or has a field of the wrong shape | 2 |
98
- | The tool is not one of your kinds | 0, and `check` is not called |
104
+ | The payload is not JSON, names no tool, has a field of the wrong shape, or names an event other than PreToolUse, Stop or UserPromptSubmit | 2 |
105
+ | The command says `--event PreToolUse` and the payload names another event | 2 |
106
+ | The command has an argument `hook` does not take | 2, or 0 when its first valid `--event` names Stop or UserPromptSubmit or, with none, the payload does |
107
+ | The tool is not one of your kinds or `tools` | 0, and `check` is not called |
99
108
  | The hook cannot start: Node.js is missing, the build was deleted, a dependency was not bundled | 2, from `\|\| exit 2` |
100
109
  | The hook runs on a Node.js older than 22 | 2, with a message naming the fix |
101
110
  | The hook times out | Claude Code blocks it (`onFailure: "block"`, Claude Code 2.1.295 or later). Codex lets the call run. |
@@ -110,29 +119,81 @@ Keep `check` well inside `timeout`, which defaults to 10 seconds.
110
119
  | --- | --- | --- | --- |
111
120
  | `shell` | `{ kind: 'shell', command }` | `Bash` | `Bash` |
112
121
  | `write` | `{ kind: 'write', path, content }` | `Write`, `NotebookEdit` | `apply_patch` |
113
- | `edit` | `{ kind: 'edit', path, before, after }` (a MultiEdit's edits joined by newlines) | `Edit`, `MultiEdit` | `apply_patch` |
122
+ | `edit` | `{ kind: 'edit', path, before, after }` (a MultiEdit's edits one call each) | `Edit`, `MultiEdit` | `apply_patch` |
114
123
  | `patch` | `{ kind: 'patch', patch }` (the whole patch text) | none | `apply_patch` |
124
+ | `tool` | `{ kind: 'tool', name, input }` (the tool's input object) | the names in `tools` | the names in `tools` |
125
+
126
+ `tools` names tools beyond the agents' own, such as `mcp__github__create_pull_request`, and `kinds` lists `tool` exactly when it does. A name holds only letters, digits, `_` and `-`, and may not be a tool a kind already covers (`Bash`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit`, `apply_patch`). A listed tool's input that is not an object blocks the call; an absent one reaches the check as `{}`.
115
127
 
116
128
  Codex sends every file change as one `apply_patch`, so `install codex` refuses a plugin with `write` or `edit` but no `patch`. A plugin with no tool in an agent, such as `['patch']` for Claude Code, is refused too.
117
129
 
130
+ ## Stop and UserPromptSubmit
131
+
132
+ `events` adds checks on two more events, installed beside PreToolUse:
133
+
134
+ ```ts
135
+ function verdict(text: string): HookVerdict {
136
+ return /\bsk-[\w-]{20,}/.test(text)
137
+ ? { block: true, reason: 'no-pasted-keys: reference the key through an environment variable instead.' }
138
+ : { block: false }
139
+ }
140
+
141
+ defineHook({
142
+ name: 'no-pasted-keys',
143
+ kinds: ['shell'],
144
+ check: call => verdict(call.command),
145
+ events: {
146
+ Stop: { check: ({ reply }) => verdict(reply) },
147
+ UserPromptSubmit: { check: ({ prompt }) => verdict(prompt), hidePrompt: true },
148
+ },
149
+ })
150
+ ```
151
+
152
+ A block goes to stdout as `{"decision":"block","reason":…}`. At Stop the agent keeps going with the reason as its next instruction; at UserPromptSubmit it drops the prompt and shows the reason, and `hidePrompt` keeps Claude Code from printing the prompt back under it (Codex has no such field, and a reply carrying it would let the prompt through, so it is not sent there). Both fail open, since an exit 2 at Stop would keep the agent going forever and one at UserPromptSubmit would drop every prompt:
153
+
154
+ | What happens | Exit |
155
+ | --- | --- |
156
+ | `check` returns `{ block: true, reason }` | 0, with the decision on stdout |
157
+ | `check` returns `{ block: false }` | 0, silently |
158
+ | `check` throws, rejects, never settles, returns anything else, or the field has the wrong shape | 0, with the cause on stderr |
159
+ | With `--event`: the payload is not JSON, is empty or cut short, or names another event | 0, with the cause on stderr |
160
+ | A Stop payload with `stop_hook_active`, the reply to the hook's own block | 0, and `check` is not called |
161
+ | The hook cannot start, or runs on a Node.js older than 22 | anything but 2: the event passes |
162
+
163
+ Each event takes its own `statusMessage` and `timeout`, defaulting to the hook's. Without `--event`, a payload that is not JSON is blocked with exit 2 whatever event it was meant for, which is why install writes the flag. A payload with no `hook_event_name` is read as PreToolUse; one naming any other event (`PostToolUse`, `pre_tool_use`, an empty string) is blocked with exit 2, since the tool call it may carry would otherwise run unchecked.
164
+
118
165
  ## API
119
166
 
120
167
  ```ts
121
168
  function defineHook<const K extends ToolKind>(definition: HookDefinition<K>): HookPlugin
122
169
  function runCli(plugin: HookPlugin, entryUrl: string, options?: { bin?: string }): Promise<void>
123
170
  function createCli(plugin: HookPlugin, options?: { bin?: string }): (argv: readonly string[], io: CliIO) => Promise<number>
124
- function runHook(plugin: HookPlugin, stdin: string, options?: { cwd?: string, stderr?: (text: string) => void }): Promise<number>
171
+ function runHook(plugin: HookPlugin, stdin: string, options?: { cwd?: string, stderr?: (text: string) => void, stdout?: (text: string) => void, event?: HookEvent, agent?: Agent }): Promise<number>
125
172
  function readStdin(stream?: AsyncIterable<unknown>): Promise<string>
126
- function install(options: InstallOptions): { file: string, command?: string }
127
- function uninstall(options: UninstallOptions): { file: string }
173
+ function install(options: InstallOptions): InstallResult // { file, command, commands, notes, warnings }
174
+ function uninstall(options: UninstallOptions): InstallResult
128
175
  function configFile(agent: Agent, scope: Scope, place: { home: string, cwd: string }): string
129
176
  function matcherFor(agent: Agent, kinds: readonly ToolKind[]): string
130
177
  function claudePluginFiles(options: ClaudePluginOptions): Record<string, string>
131
178
  function writeClaudePlugin(dir: string, options: ClaudePluginOptions): string[]
132
179
  ```
133
180
 
181
+ `defineHook` takes `name`, `kinds` and `check`, and optionally:
182
+
183
+ | Field | Default | What it does |
184
+ | --- | --- | --- |
185
+ | `packageName` | `name` | The npm package a project install runs through `npx --no-install`. |
186
+ | `statusMessage` | `Running <name>` | What the agent shows while the hook runs. |
187
+ | `timeout` | `10` | Seconds the agent waits for the hook. |
188
+ | `ownCommands` | `[]` | Patterns for commands an earlier version of your hook wrote, which install replaces and uninstall removes, at any event. A pattern must match the whole command, not a part of it; the `g`, `m` and `y` flags are refused. |
189
+ | `tools` | `[]` | Tools beyond the agents' own, checked as `tool` calls. |
190
+ | `events` | `{}` | `Stop` and `UserPromptSubmit` checks, each with an optional `statusMessage` and `timeout`, and `hidePrompt` for UserPromptSubmit. |
191
+ | `onInstall`, `onUninstall` | none | Steps run after the config is written; see [What install writes](#what-install-writes). They do not run for a Claude Code plugin. |
192
+
134
193
  `runCli` is the only function that reads the home directory, the working directory and the Node.js path from the process; the others take them as arguments, which keeps tests away from your real `~`. `writeClaudePlugin` writes the files to ship the same hook as a Claude Code plugin instead; use either the plugin or `install claude`, not both.
135
194
 
195
+ The commands install and `writeClaudePlugin` write pass `--event` and `--agent`, which `createCli` hands to `runHook`. A bin that wires its own hook command passes them as `runHook`'s `event` and `agent` options, so a cut-short payload at Stop or UserPromptSubmit passes there too.
196
+
136
197
  ## Contributing
137
198
 
138
199
  ```sh
package/dist/index.d.mts CHANGED
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * What a hook declares it checks. Each kind selects the agents' tools that
3
3
  * make that change, and is the `kind` of the {@link ToolCall} the check gets.
4
+ * `tool` selects the tools the definition names in `tools`, such as an MCP
5
+ * server's.
4
6
  */
5
- type ToolKind = 'shell' | 'write' | 'edit' | 'patch';
7
+ type ToolKind = 'shell' | 'write' | 'edit' | 'patch' | 'tool';
6
8
  /**
7
9
  * An agent's tool call, reduced to what a check needs. `shell` is Bash in
8
- * either agent; `write` is Claude Code's Write or NotebookEdit; `edit` is Edit
9
- * or MultiEdit, whose edits arrive joined by newlines; `patch` is Codex's
10
- * apply_patch, the whole patch text, which can add, change and delete files.
10
+ * either agent; `write` is Claude Code's Write or NotebookEdit; `edit` is Edit,
11
+ * or one edit of a MultiEdit, which the check gets edit by edit; `patch` is
12
+ * Codex's apply_patch, the whole patch text, which can add, change and delete
13
+ * files; `tool` is one of the tools named in `tools`, with its input as the
14
+ * agent sent it, an object.
11
15
  */
12
16
  type ToolCall = {
13
17
  kind: 'shell';
@@ -24,6 +28,10 @@ type ToolCall = {
24
28
  } | {
25
29
  kind: 'patch';
26
30
  patch: string;
31
+ } | {
32
+ kind: 'tool';
33
+ name: string;
34
+ input: Readonly<Record<string, unknown>>;
27
35
  };
28
36
  /** What a check declared with kinds `K` receives: only the matching shapes of {@link ToolCall}. */
29
37
  type ToolCallOf<K extends ToolKind> = Extract<ToolCall, {
@@ -45,6 +53,65 @@ interface HookContext {
45
53
  /** The agent's working directory, from the payload, else the hook's own. */
46
54
  cwd: string;
47
55
  }
56
+ /**
57
+ * The agent events a hook takes part in. Every hook has PreToolUse, which
58
+ * fails closed; Stop and UserPromptSubmit are optional and fail open, since a
59
+ * hook that cannot run must not trap the session in a loop or swallow every
60
+ * prompt.
61
+ */
62
+ type HookEvent = 'PreToolUse' | 'Stop' | 'UserPromptSubmit';
63
+ /** What a Stop check gets: the reply the agent is about to end its turn on. */
64
+ interface StopInput {
65
+ reply: string;
66
+ }
67
+ /** What a UserPromptSubmit check gets: the prompt the user sent. */
68
+ interface PromptInput {
69
+ prompt: string;
70
+ }
71
+ /**
72
+ * A check on one event other than PreToolUse. A block makes the agent take
73
+ * the reason instead: at Stop it keeps going with the reason as its next
74
+ * instruction, at UserPromptSubmit it drops the prompt and shows the reason.
75
+ * A check that throws or returns anything but a verdict lets the event pass.
76
+ */
77
+ interface EventDefinition<I> {
78
+ check: (input: I, context: HookContext) => HookVerdict | Promise<HookVerdict>;
79
+ /** What the agent shows while the check runs. Defaults to the hook's `statusMessage`. */
80
+ statusMessage?: string;
81
+ /** Seconds the agent waits. Defaults to the hook's `timeout`; past it, the event passes. */
82
+ timeout?: number;
83
+ }
84
+ interface PromptDefinition extends EventDefinition<PromptInput> {
85
+ /**
86
+ * Keeps Claude Code from printing the blocked prompt back under the reason,
87
+ * for a check that blocks a prompt because of what it holds, such as a
88
+ * secret.
89
+ */
90
+ hidePrompt?: boolean;
91
+ }
92
+ /** The events a hook adds on top of PreToolUse. */
93
+ interface EventDefinitions {
94
+ Stop?: EventDefinition<StopInput>;
95
+ UserPromptSubmit?: PromptDefinition;
96
+ }
97
+ /** Where {@link HookDefinition.onInstall} and `onUninstall` run. */
98
+ interface InstallPlace {
99
+ agent: Agent;
100
+ scope: Scope;
101
+ home: string;
102
+ cwd: string;
103
+ /** The built bin install runs from, to find files shipped beside it. */
104
+ script: string;
105
+ /** The agent config just written. */
106
+ file: string;
107
+ }
108
+ /** Lines an install step adds to what the command line prints. */
109
+ interface InstallNotes {
110
+ /** Printed to stdout after the config file. */
111
+ notes?: readonly string[];
112
+ /** Printed to stderr. */
113
+ warnings?: readonly string[];
114
+ }
48
115
  /** What a plugin author writes; {@link defineHook} checks it and fills in the defaults. */
49
116
  interface HookDefinition<K extends ToolKind> {
50
117
  /**
@@ -73,6 +140,38 @@ interface HookDefinition<K extends ToolKind> {
73
140
  * the call run, and Claude Code blocks it.
74
141
  */
75
142
  timeout?: number;
143
+ /**
144
+ * Commands an earlier version of the hook wrote in a form install no longer
145
+ * recognizes, such as a path that does not sit in a directory named after
146
+ * the hook. A command any pattern matches is taken as this hook's own:
147
+ * install replaces it and uninstall removes it. A pattern must match the
148
+ * whole command, not a part of it, and may not carry the `g`, `m` or `y`
149
+ * flag.
150
+ */
151
+ ownCommands?: readonly RegExp[];
152
+ /**
153
+ * Tools no kind covers, by exact name, such as
154
+ * `mcp__github__create_pull_request`. They reach the check as `tool` calls,
155
+ * so `kinds` must list `tool` exactly when this names any.
156
+ */
157
+ tools?: readonly string[];
158
+ /** Checks on events other than PreToolUse, installed beside it. */
159
+ events?: EventDefinitions;
160
+ /**
161
+ * Runs after install writes the config, to set up what else the hook needs,
162
+ * such as files in the agent's directory. Throwing puts the config and the
163
+ * `current` link back as they were and fails the install. Not run for a
164
+ * Claude Code plugin, which Claude Code installs itself.
165
+ */
166
+ onInstall?: (place: InstallPlace) => InstallNotes | void;
167
+ /** Runs after uninstall, even when the config held no entry; throwing puts the config back. */
168
+ onUninstall?: (place: InstallPlace) => InstallNotes | void;
169
+ }
170
+ /** An event hook with its defaults filled in. */
171
+ interface EventHook<I> {
172
+ readonly check: (input: I, context: HookContext) => HookVerdict | Promise<HookVerdict>;
173
+ readonly statusMessage: string;
174
+ readonly timeout: number;
76
175
  }
77
176
  /** A checked hook definition, as {@link defineHook} returns it. */
78
177
  interface HookPlugin {
@@ -81,6 +180,16 @@ interface HookPlugin {
81
180
  readonly kinds: readonly ToolKind[];
82
181
  readonly statusMessage: string;
83
182
  readonly timeout: number;
183
+ readonly ownCommands: readonly RegExp[];
184
+ readonly tools: readonly string[];
185
+ readonly events: Readonly<{
186
+ Stop?: EventHook<StopInput>;
187
+ UserPromptSubmit?: EventHook<PromptInput> & {
188
+ readonly hidePrompt: boolean;
189
+ };
190
+ }>;
191
+ readonly onInstall?: (place: InstallPlace) => InstallNotes | void;
192
+ readonly onUninstall?: (place: InstallPlace) => InstallNotes | void;
84
193
  /** Called only with a call whose kind is in `kinds`. */
85
194
  readonly check: (call: ToolCall, context: HookContext) => HookVerdict | Promise<HookVerdict>;
86
195
  }
@@ -130,10 +239,12 @@ interface ClaudePluginOptions {
130
239
  * to the package root: `.claude-plugin/plugin.json`, `hooks/hooks.json`, and
131
240
  * with `marketplace`, `.claude-plugin/marketplace.json`.
132
241
  *
133
- * The hook runs `node "${CLAUDE_PLUGIN_ROOT}/<entry>" hook || exit 2` with
134
- * `onFailure: "block"`, so a plugin that cannot start or times out blocks the
135
- * call. Claude Code copies the plugin and installs nothing into it, so the
136
- * entry's directory must hold the whole build. Throws when the plugin checks
242
+ * The PreToolUse hook runs `node "${CLAUDE_PLUGIN_ROOT}/<entry>" hook --event
243
+ * PreToolUse --agent claude || exit 2` with `onFailure: "block"`, so a plugin that cannot start or times out blocks
244
+ * the call; each of the plugin's other events runs the same command, naming
245
+ * its own event, without either, so it passes instead. Claude Code copies the plugin and installs
246
+ * nothing into it, and the plugin's `onInstall` does not run, so the entry's
247
+ * directory must hold the whole build. Throws when the plugin checks
137
248
  * no tool Claude Code has, or a field would make Claude Code refuse the
138
249
  * plugin.
139
250
  */
@@ -170,13 +281,19 @@ interface CliOptions {
170
281
  type CliMain = (argv: readonly string[], io: CliIO) => Promise<number>;
171
282
  /**
172
283
  * Builds the plugin's command line: `install` and `uninstall <claude|codex>
173
- * [--project]`, and `hook`, which reads the tool call from `io.stdin`.
284
+ * [--project]`, and `hook [--event <event>] [--agent <claude|codex>]`, which
285
+ * reads the payload from `io.stdin` and hands the flags to {@link runHook}.
174
286
  *
175
- * Exit codes: `hook` returns what {@link runHook} returns, 0 or 2. `install`
176
- * and `uninstall` return 0, or 1 with the reason on stderr. Anything else,
177
- * including an unknown flag or an extra argument, prints the usage and
178
- * returns 1. On a Node.js older than 22, `hook` returns 2
179
- * and `install` 1, each with a message naming the fix; `uninstall` still runs.
287
+ * Exit codes: `hook` returns what {@link runHook} returns, 0 or 2; an
288
+ * argument it does not take is a fault, 0 at Stop or UserPromptSubmit and 2
289
+ * otherwise. `install`
290
+ * and `uninstall` return 0, or 1 with the reason on stderr; the notes the
291
+ * plugin's `onInstall` or `onUninstall` returns follow the config file on
292
+ * stdout, its warnings go to stderr. Anything else, including an unknown flag
293
+ * or an extra argument, prints the usage and returns 1. On a Node.js older
294
+ * than 22, `hook` returns 2 for PreToolUse and 0 for Stop and
295
+ * UserPromptSubmit, and `install` 1, each with a message naming the fix;
296
+ * `uninstall` still runs.
180
297
  */
181
298
  export declare function createCli(plugin: HookPlugin, options?: CliOptions): CliMain;
182
299
  /**
@@ -191,9 +308,10 @@ export declare function readStdin(stream?: AsyncIterable<unknown>): Promise<stri
191
308
  * `hook`, and sets `process.exitCode`. Call it without `await`, passing the
192
309
  * bin's own `import.meta.url`, which is what install copies and runs.
193
310
  *
194
- * For `hook` the exit code is 2 until a verdict is in. Node.js exits 0 once
195
- * nothing is left to run, so a check whose promise never settles would
196
- * otherwise let the call run.
311
+ * For a PreToolUse `hook` the exit code is 2 until a verdict is in. Node.js
312
+ * exits 0 once nothing is left to run, so a check whose promise never settles
313
+ * would otherwise let the call run. A Stop or UserPromptSubmit check that
314
+ * never settles exits 0, as any other fault there does.
197
315
  */
198
316
  export declare function runCli(plugin: HookPlugin, entryUrl: string, options?: CliOptions): Promise<void>;
199
317
  /**
@@ -208,16 +326,45 @@ interface RunHookOptions {
208
326
  cwd?: string;
209
327
  /** Receives the block reason or the error, newline-terminated. */
210
328
  stderr?: (text: string) => void;
329
+ /** Receives the decision a Stop or UserPromptSubmit check blocks with, as a JSON line. */
330
+ stdout?: (text: string) => void;
331
+ /**
332
+ * The event the agent runs the hook for, as the installed command names it
333
+ * with `--event`. Given, it decides how a fault exits without reading the
334
+ * payload, so a Stop or UserPromptSubmit payload that is cut short or not
335
+ * JSON passes, and a payload naming another event is a fault. Absent, the
336
+ * event comes from the payload's `hook_event_name`.
337
+ */
338
+ event?: HookEvent;
339
+ /**
340
+ * The agent that runs the hook, as the installed command names it with
341
+ * `--agent`. Absent, a UserPromptSubmit payload carrying `turn_id`, which
342
+ * only Codex sends, reads as Codex.
343
+ */
344
+ agent?: Agent;
211
345
  }
212
346
  /**
213
- * Runs one PreToolUse call through the plugin and returns the exit code the
214
- * agent reads: 2 blocks the call, 0 lets it run.
347
+ * Runs one hook payload through the plugin and returns the exit code the
348
+ * agent reads.
349
+ *
350
+ * A PreToolUse call returns 2 to block it and 0 to let it run, and fails
351
+ * closed: a payload that is not JSON or names no tool, a field of the wrong
352
+ * shape, a check that throws or rejects, and a result that is not a verdict
353
+ * all return 2 with the error on stderr, because any other exit lets the call
354
+ * run unchecked. A tool the plugin did not ask for returns 0 without calling
355
+ * the check. A MultiEdit calls the check once per edit and blocks on the first
356
+ * edit it blocks. A payload with no `hook_event_name` is read as PreToolUse;
357
+ * one naming any event other than PreToolUse, Stop or UserPromptSubmit, or
358
+ * a `hook_event_name` that is not a string, returns 2.
359
+ *
360
+ * Stop and UserPromptSubmit always return 0. A block goes to stdout as
361
+ * `{"decision":"block","reason":…}`, and a fault to stderr, letting the event
362
+ * pass. A Stop with `stop_hook_active` set passes unchecked, and so does an
363
+ * event the plugin has no check for.
215
364
  *
216
- * It fails closed. A payload that is not JSON or names no tool, a field of the
217
- * wrong shape, a check that throws or rejects, and a result that is not a
218
- * verdict all return 2 with the error on stderr, because any other exit lets
219
- * the call run unchecked. A tool the plugin did not ask for returns 0 without
220
- * calling the check.
365
+ * With `options.event`, the event is the one named, not the payload's: a
366
+ * Stop or UserPromptSubmit returns 0 on any fault, a payload that is not JSON
367
+ * included, and a payload naming another event is a fault for that event.
221
368
  */
222
369
  export declare function runHook(plugin: HookPlugin, stdin: string, options?: RunHookOptions): Promise<number>;
223
370
  /** Where {@link uninstall} looks: everything is passed in, so nothing reaches past what the caller names. */
@@ -237,10 +384,17 @@ interface InstallOptions extends UninstallOptions {
237
384
  /** The Node.js a user-scope hook keeps running on, as `process.execPath` gives it. */
238
385
  execPath: string;
239
386
  }
240
- /** The config file written and, for an install, the command in it. */
387
+ /** The config file written and, for an install, the commands in it. */
241
388
  interface InstallResult {
242
389
  file: string;
390
+ /** The PreToolUse command. */
243
391
  command?: string;
392
+ /** The command written for each event. */
393
+ commands?: Partial<Record<HookEvent, string>>;
394
+ /** What the plugin's `onInstall` or `onUninstall` returned, for stdout. */
395
+ notes: string[];
396
+ /** And for stderr. */
397
+ warnings: string[];
244
398
  }
245
399
  /** The agent config a scope writes: `~/.claude/settings.json`, `<cwd>/.codex/hooks.json` and so on. */
246
400
  export declare function configFile(agent: Agent, scope: Scope, place: {
@@ -248,26 +402,31 @@ export declare function configFile(agent: Agent, scope: Scope, place: {
248
402
  cwd: string;
249
403
  }): string;
250
404
  /**
251
- * Adds the plugin's PreToolUse hook to one agent's config, replacing any entry
252
- * it wrote before and keeping every other one.
405
+ * Adds the plugin's hooks to one agent's config, PreToolUse and each event in
406
+ * its `events`, replacing any entry it wrote before, for any event, and
407
+ * keeping every other one.
253
408
  *
254
409
  * The user scope copies the bin's directory to `~/.<name>/<build-id>/`, checks
255
410
  * the copy runs, points `~/.<name>/current` at it and runs it from there on
256
411
  * `execPath`. The project scope runs `npx --no-install <packageName> hook`.
257
- * Either command ends in `|| exit 2`, so a hook that cannot start blocks the
258
- * call. The Claude Code entry also sets `onFailure: "block"` for a hook that
259
- * times out.
412
+ * Each command names its event and agent, `hook --event <event> --agent
413
+ * <agent>`, and the PreToolUse one ends in `|| exit 2`, so a hook that cannot start
414
+ * blocks the call, and the Claude Code entry sets `onFailure: "block"` for one
415
+ * that times out. The other events' commands do not, so they pass instead.
416
+ * Then the plugin's `onInstall` runs.
260
417
  *
261
- * Throws, and leaves the config as it was, when the config is not a settings
262
- * object it can extend, when the plugin's kinds leave tools the agent routes
263
- * to it unchecked, or when the build cannot be copied or does not run.
418
+ * Throws, and leaves the config and `current` as they were, when the config
419
+ * is not a settings object it can extend, when the plugin's kinds leave tools
420
+ * the agent routes to it unchecked, when the build cannot be copied or does
421
+ * not run, or when `onInstall` throws.
264
422
  */
265
423
  export declare function install(options: InstallOptions): InstallResult;
266
424
  /**
267
- * Removes the plugin's hook from one agent's config, whichever form an earlier
268
- * install wrote, and keeps every other entry. The stable copy stays: another
269
- * agent may still run it. Throws, and leaves the file as it was, when it is not
270
- * a settings object.
425
+ * Removes the plugin's hooks from one agent's config, for every event and
426
+ * whichever form an earlier install wrote, keeps every other entry, and runs
427
+ * the plugin's `onUninstall`. The stable copy stays: another agent may still
428
+ * run it. Throws, and leaves the file as it was, when it is not a settings
429
+ * object or `onUninstall` throws.
271
430
  */
272
431
  export declare function uninstall(options: UninstallOptions): InstallResult;
273
- export type { Agent, ClaudePluginOptions, ClaudePluginPerson, CliIO, CliMain, CliOptions, HookContext, HookDefinition, HookPlugin, HookVerdict, InstallOptions, InstallResult, RunHookOptions, Scope, ToolCall, ToolCallOf, ToolKind, UninstallOptions };
432
+ export type { Agent, ClaudePluginOptions, ClaudePluginPerson, CliIO, CliMain, CliOptions, EventDefinition, EventDefinitions, EventHook, HookContext, HookDefinition, HookEvent, HookPlugin, HookVerdict, InstallNotes, InstallOptions, InstallPlace, InstallResult, PromptDefinition, PromptInput, RunHookOptions, Scope, StopInput, ToolCall, ToolCallOf, ToolKind, UninstallOptions };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { homedir } from "node:os";
@@ -40,7 +40,8 @@ const SELECTED_BY = {
40
40
  "write",
41
41
  "edit",
42
42
  "patch"
43
- ]
43
+ ],
44
+ tool: []
44
45
  };
45
46
  const KIND_OF_TOOL = new Map(Object.values(TOOLS).flatMap((tools) => Object.entries(tools)));
46
47
  function selected(agent, kinds) {
@@ -56,15 +57,36 @@ function matcherFor(agent, kinds) {
56
57
  }
57
58
  function requireMatcher(agent, plugin) {
58
59
  const tools = selected(agent, plugin.kinds);
59
- if (tools.length === 0) throw new Error(`${plugin.name} checks ${plugin.kinds.join(", ")} calls, which ${AGENTS[agent].label} does not make`);
60
+ if (tools.length === 0 && plugin.tools.length === 0) throw new Error(`${plugin.name} checks ${plugin.kinds.join(", ")} calls, which ${AGENTS[agent].label} does not make`);
60
61
  const unseen = tools.filter(([, kind]) => !plugin.kinds.includes(kind)).map(([tool]) => tool);
61
62
  if (unseen.length > 0) throw new Error(`${AGENTS[agent].label} sends file changes as ${unseen.join(", ")}, which ${plugin.name} does not take; add 'patch' to its kinds`);
62
- return tools.map(([tool]) => tool).join("|");
63
+ return [...tools.map(([tool]) => tool), ...plugin.tools].join("|");
63
64
  }
65
+ const hookFlags = (event, agent) => `--event ${event} --agent ${agent}`;
64
66
  // src/claude-plugin.ts
65
67
  const MARKETPLACE_NAME = /^[a-z0-9][\w.-]*$/i;
66
68
  const RESERVED = /^(?:claude|anthropics?)(?:-|$)|^cc-plugin-|(?:claude|anthropic)s?-?official|official-?(?:claude|anthropic)/;
67
69
  const ENTRY = /^[\w-][\w.-]*(?:\/[\w-][\w.-]*)*\.mjs$/;
70
+ function pluginHooks(plugin, matcher, entry) {
71
+ const command = (event) => `node "\${CLAUDE_PLUGIN_ROOT}/${entry}" hook ${hookFlags(event, "claude")}`;
72
+ const hooks = { PreToolUse: [{
73
+ matcher,
74
+ hooks: [{
75
+ type: "command",
76
+ command: `${command("PreToolUse")} || exit 2`,
77
+ timeout: plugin.timeout,
78
+ statusMessage: plugin.statusMessage,
79
+ onFailure: "block"
80
+ }]
81
+ }] };
82
+ for (const [event, hook] of Object.entries(plugin.events)) hooks[event] = [{ hooks: [{
83
+ type: "command",
84
+ command: command(event),
85
+ timeout: hook.timeout,
86
+ statusMessage: hook.statusMessage
87
+ }] }];
88
+ return hooks;
89
+ }
68
90
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
69
91
  function entryOf(entry) {
70
92
  const relative = entry.replace(/^\.\//, "");
@@ -76,10 +98,12 @@ function entryOf(entry) {
76
98
  * to the package root: `.claude-plugin/plugin.json`, `hooks/hooks.json`, and
77
99
  * with `marketplace`, `.claude-plugin/marketplace.json`.
78
100
  *
79
- * The hook runs `node "${CLAUDE_PLUGIN_ROOT}/<entry>" hook || exit 2` with
80
- * `onFailure: "block"`, so a plugin that cannot start or times out blocks the
81
- * call. Claude Code copies the plugin and installs nothing into it, so the
82
- * entry's directory must hold the whole build. Throws when the plugin checks
101
+ * The PreToolUse hook runs `node "${CLAUDE_PLUGIN_ROOT}/<entry>" hook --event
102
+ * PreToolUse --agent claude || exit 2` with `onFailure: "block"`, so a plugin that cannot start or times out blocks
103
+ * the call; each of the plugin's other events runs the same command, naming
104
+ * its own event, without either, so it passes instead. Claude Code copies the plugin and installs
105
+ * nothing into it, and the plugin's `onInstall` does not run, so the entry's
106
+ * directory must hold the whole build. Throws when the plugin checks
83
107
  * no tool Claude Code has, or a field would make Claude Code refuse the
84
108
  * plugin.
85
109
  */
@@ -100,16 +124,7 @@ function claudePluginFiles(options) {
100
124
  license,
101
125
  keywords
102
126
  }),
103
- "hooks/hooks.json": json({ hooks: { PreToolUse: [{
104
- matcher,
105
- hooks: [{
106
- type: "command",
107
- command: `node "\${CLAUDE_PLUGIN_ROOT}/${entry}" hook || exit 2`,
108
- timeout: plugin.timeout,
109
- statusMessage: plugin.statusMessage,
110
- onFailure: "block"
111
- }]
112
- }] } })
127
+ "hooks/hooks.json": json({ hooks: pluginHooks(plugin, matcher, entry) })
113
128
  };
114
129
  if (marketplace) {
115
130
  if (!MARKETPLACE_NAME.test(marketplace.name)) throw new TypeError(`marketplace name ${JSON.stringify(marketplace.name)} may hold only letters, digits, '.', '_' and '-', and must start with a letter or digit`);
@@ -144,6 +159,9 @@ function writeClaudePlugin(dir, options) {
144
159
  });
145
160
  }
146
161
  // src/hook.ts
162
+ const OPEN_EVENTS = ["Stop", "UserPromptSubmit"];
163
+ const EVENTS$2 = ["PreToolUse", ...OPEN_EVENTS];
164
+ const isOpen = (event) => OPEN_EVENTS.includes(event);
147
165
  const isObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
148
166
  function stringField(input, key) {
149
167
  const value = input[key];
@@ -161,9 +179,30 @@ function editList(input) {
161
179
  if (!Array.isArray(edits)) throw new TypeError("edits is not a list");
162
180
  return edits.map((edit) => record(edit, "edit"));
163
181
  }
164
- function toToolCall(tool, input) {
182
+ function toToolCalls(plugin, tool, input) {
183
+ if (tool !== "MultiEdit") return [toToolCall(plugin, tool, input)];
184
+ const path = stringField(input, "file_path");
185
+ const edits = editList(input);
186
+ if (edits.length === 0) return [{
187
+ kind: "edit",
188
+ path,
189
+ before: "",
190
+ after: ""
191
+ }];
192
+ return edits.map((edit) => ({
193
+ kind: "edit",
194
+ path,
195
+ before: stringField(edit, "old_string"),
196
+ after: stringField(edit, "new_string")
197
+ }));
198
+ }
199
+ function toToolCall(plugin, tool, input) {
200
+ if (plugin.tools.includes(tool)) return {
201
+ kind: "tool",
202
+ name: tool,
203
+ input
204
+ };
165
205
  const text = (key, from = input) => stringField(from, key);
166
- const joined = (key) => editList(input).map((edit) => text(key, edit)).join("\n");
167
206
  switch (tool) {
168
207
  case "Bash": return {
169
208
  kind: "shell",
@@ -185,12 +224,6 @@ function toToolCall(tool, input) {
185
224
  before: text("old_string"),
186
225
  after: text("new_string")
187
226
  };
188
- case "MultiEdit": return {
189
- kind: "edit",
190
- path: text("file_path"),
191
- before: joined("old_string"),
192
- after: joined("new_string")
193
- };
194
227
  case "apply_patch": return {
195
228
  kind: "patch",
196
229
  patch: text("command") || text("input") || text("patch")
@@ -206,31 +239,105 @@ function verdictOf(value) {
206
239
  };
207
240
  throw new TypeError("check returned neither { block: false } nor { block: true, reason }");
208
241
  }
242
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
243
+ /**
244
+ * The event a hook payload is for when it names one the hook lets pass on a
245
+ * fault, else `undefined`. Read before the Node.js check and the hang guard,
246
+ * which must not block these either.
247
+ */
248
+ function openEventOf(stdin) {
249
+ try {
250
+ const event = JSON.parse(stdin)?.hook_event_name;
251
+ return OPEN_EVENTS.find((open) => open === event);
252
+ } catch {
253
+ return;
254
+ }
255
+ }
209
256
  /**
210
- * Runs one PreToolUse call through the plugin and returns the exit code the
211
- * agent reads: 2 blocks the call, 0 lets it run.
257
+ * Whether a fault lets the event pass: the named event when the command names
258
+ * one, else the event the payload names.
259
+ */
260
+ function passesOnFault(event, stdin) {
261
+ return event === void 0 ? openEventOf(stdin) !== void 0 : isOpen(event);
262
+ }
263
+ function payloadOf(stdin) {
264
+ const payload = JSON.parse(stdin);
265
+ if (!isObject$1(payload)) throw new TypeError("payload is not an object");
266
+ return payload;
267
+ }
268
+ async function runOpenEvent(plugin, event, read, options) {
269
+ if (!plugin.events[event]) return 0;
270
+ const stderr = options.stderr ?? ((text) => void process.stderr.write(text));
271
+ const stdout = options.stdout ?? ((text) => void process.stdout.write(text));
272
+ try {
273
+ const payload = read();
274
+ if (payload.hook_event_name !== void 0 && payload.hook_event_name !== event) throw new TypeError(`hook_event_name ${JSON.stringify(payload.hook_event_name)} is not ${event}`);
275
+ if (options.agent !== void 0 && options.agent !== "claude" && options.agent !== "codex") throw new TypeError(`agent ${JSON.stringify(options.agent)} is neither claude nor codex`);
276
+ if (event === "Stop" && payload.stop_hook_active === true) return 0;
277
+ const cwd = stringField(payload, "cwd") || (options.cwd ?? process.cwd());
278
+ const verdict = event === "Stop" ? verdictOf(await plugin.events.Stop.check({ reply: stringField(payload, "last_assistant_message") }, { cwd })) : verdictOf(await plugin.events.UserPromptSubmit.check({ prompt: stringField(payload, "prompt") }, { cwd }));
279
+ if (!verdict.block) return 0;
280
+ const codex = options.agent === void 0 ? payload.turn_id !== void 0 : options.agent === "codex";
281
+ const hidden = event === "UserPromptSubmit" && plugin.events.UserPromptSubmit.hidePrompt && !codex;
282
+ stdout(`${JSON.stringify({
283
+ decision: "block",
284
+ reason: verdict.reason,
285
+ ...hidden ? { hookSpecificOutput: {
286
+ hookEventName: "UserPromptSubmit",
287
+ suppressOriginalPrompt: true
288
+ } } : {}
289
+ })}\n`);
290
+ } catch (error) {
291
+ stderr(`${plugin.name} could not check the ${event === "Stop" ? "reply" : "prompt"}, so it let it pass: ${messageOf(error)}\n`);
292
+ }
293
+ return 0;
294
+ }
295
+ /**
296
+ * Runs one hook payload through the plugin and returns the exit code the
297
+ * agent reads.
298
+ *
299
+ * A PreToolUse call returns 2 to block it and 0 to let it run, and fails
300
+ * closed: a payload that is not JSON or names no tool, a field of the wrong
301
+ * shape, a check that throws or rejects, and a result that is not a verdict
302
+ * all return 2 with the error on stderr, because any other exit lets the call
303
+ * run unchecked. A tool the plugin did not ask for returns 0 without calling
304
+ * the check. A MultiEdit calls the check once per edit and blocks on the first
305
+ * edit it blocks. A payload with no `hook_event_name` is read as PreToolUse;
306
+ * one naming any event other than PreToolUse, Stop or UserPromptSubmit, or
307
+ * a `hook_event_name` that is not a string, returns 2.
212
308
  *
213
- * It fails closed. A payload that is not JSON or names no tool, a field of the
214
- * wrong shape, a check that throws or rejects, and a result that is not a
215
- * verdict all return 2 with the error on stderr, because any other exit lets
216
- * the call run unchecked. A tool the plugin did not ask for returns 0 without
217
- * calling the check.
309
+ * Stop and UserPromptSubmit always return 0. A block goes to stdout as
310
+ * `{"decision":"block","reason":…}`, and a fault to stderr, letting the event
311
+ * pass. A Stop with `stop_hook_active` set passes unchecked, and so does an
312
+ * event the plugin has no check for.
313
+ *
314
+ * With `options.event`, the event is the one named, not the payload's: a
315
+ * Stop or UserPromptSubmit returns 0 on any fault, a payload that is not JSON
316
+ * included, and a payload naming another event is a fault for that event.
218
317
  */
219
318
  async function runHook(plugin, stdin, options = {}) {
220
319
  const stderr = options.stderr ?? ((text) => void process.stderr.write(text));
320
+ if (isOpen(options.event)) return runOpenEvent(plugin, options.event, () => payloadOf(stdin), options);
221
321
  let verdict;
222
322
  try {
223
- const payload = JSON.parse(stdin);
224
- if (!isObject$1(payload)) throw new TypeError("payload is not an object");
323
+ if (options.event !== void 0 && !EVENTS$2.includes(options.event)) throw new TypeError(`event ${JSON.stringify(options.event)} is not an event the hook runs`);
324
+ const payload = payloadOf(stdin);
325
+ const event = payload.hook_event_name;
326
+ if (options.event === void 0 && isOpen(event)) return runOpenEvent(plugin, event, () => payload, options);
327
+ if (event !== void 0 && event !== "PreToolUse") throw new TypeError(`hook_event_name ${JSON.stringify(event)} is not ${options.event ?? "an event the hook runs"}`);
225
328
  const tool = payload.tool_name;
226
329
  if (typeof tool !== "string" || tool === "") throw new TypeError("tool_name is not a tool name");
227
- const kind = KIND_OF_TOOL.get(tool);
330
+ const kind = plugin.tools.includes(tool) ? "tool" : KIND_OF_TOOL.get(tool);
228
331
  if (kind === void 0 || !plugin.kinds.includes(kind)) return 0;
229
- const call = toToolCall(tool, record(payload.tool_input, "tool_input"));
332
+ const calls = toToolCalls(plugin, tool, record(payload.tool_input, "tool_input"));
230
333
  const cwd = stringField(payload, "cwd") || (options.cwd ?? process.cwd());
231
- verdict = verdictOf(await plugin.check(call, { cwd }));
334
+ verdict = { block: false };
335
+ for (const call of calls) {
336
+ verdict = verdictOf(await plugin.check(call, { cwd }));
337
+ if (verdict.block) break;
338
+ }
232
339
  } catch (error) {
233
- stderr(`Blocked by ${plugin.name}, it could not check the call: ${error instanceof Error ? error.message : String(error)}\n`);
340
+ stderr(`Blocked by ${plugin.name}, it could not check the call: ${messageOf(error)}\n`);
234
341
  return 2;
235
342
  }
236
343
  if (!verdict.block) return 0;
@@ -238,6 +345,11 @@ async function runHook(plugin, stdin, options = {}) {
238
345
  return 2;
239
346
  }
240
347
  // src/install.ts
348
+ const EVENTS$1 = [
349
+ "PreToolUse",
350
+ "Stop",
351
+ "UserPromptSubmit"
352
+ ];
241
353
  /** The agent config a scope writes: `~/.claude/settings.json`, `<cwd>/.codex/hooks.json` and so on. */
242
354
  function configFile(agent, scope, place) {
243
355
  const { dir, file } = AGENTS[agent];
@@ -246,8 +358,31 @@ function configFile(agent, scope, place) {
246
358
  const quote = (word) => `'${word.replaceAll("'", `'\\''`)}'`;
247
359
  const escape = (text) => text.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
248
360
  function hookCommand(plugin, scope, script, node) {
249
- if (scope === "project") return `npx --no-install ${plugin.packageName} hook || exit 2`;
250
- return `n=${quote(node)}; [ -x "$n" ] || n=node; "$n" ${quote(script)} hook || exit 2`;
361
+ if (scope === "project") return `npx --no-install ${plugin.packageName} hook`;
362
+ return `n=${quote(node)}; [ -x "$n" ] || n=node; "$n" ${quote(script)} hook`;
363
+ }
364
+ function entriesFor(plugin, agent, command) {
365
+ const entry = (event, status, timeout) => {
366
+ const run = `${command} ${hookFlags(event, agent)}`;
367
+ return {
368
+ type: "command",
369
+ command: event === "PreToolUse" ? `${run} || exit 2` : run,
370
+ timeout,
371
+ statusMessage: status
372
+ };
373
+ };
374
+ const { Stop, UserPromptSubmit } = plugin.events;
375
+ return {
376
+ PreToolUse: {
377
+ matcher: requireMatcher(agent, plugin),
378
+ hooks: [{
379
+ ...entry("PreToolUse", plugin.statusMessage, plugin.timeout),
380
+ ...agent === "claude" ? { onFailure: "block" } : {}
381
+ }]
382
+ },
383
+ ...Stop ? { Stop: { hooks: [entry("Stop", Stop.statusMessage, Stop.timeout)] } } : {},
384
+ ...UserPromptSubmit ? { UserPromptSubmit: { hooks: [entry("UserPromptSubmit", UserPromptSubmit.statusMessage, UserPromptSubmit.timeout)] } } : {}
385
+ };
251
386
  }
252
387
  const QUOTED = String.raw`(?:[^']|'\\'')*`;
253
388
  function ownHook(plugin, entry) {
@@ -255,7 +390,10 @@ function ownHook(plugin, entry) {
255
390
  const pinned = String.raw`n='${QUOTED}'; \[ -x "\$n" \] \|\| n=node; "\$n" '${QUOTED}/\.${name}/current/[^/']+' hook`;
256
391
  const bare = `node '${QUOTED}/${name}/(?:${QUOTED}/)?${escape(entry)}' hook`;
257
392
  const project = `npx --no-install ${escape(plugin.packageName)} hook`;
258
- return new RegExp(`^(?:${pinned}|${bare}|${project})(?: \\|\\| exit 2)?$`);
393
+ const flags = String.raw`(?: --event (?:PreToolUse|Stop|UserPromptSubmit))?(?: --agent (?:claude|codex))?`;
394
+ const own = new RegExp(`^(?:${pinned}|${bare}|${project})${flags}(?: \\|\\| exit 2)?$`);
395
+ const earlier = plugin.ownCommands.map((pattern) => new RegExp(`^(?:${pattern.source})$`, pattern.flags));
396
+ return (command) => own.test(command) || earlier.some((pattern) => pattern.test(command));
259
397
  }
260
398
  const builtFiles = (dir) => readdirSync(dir).filter((name) => name.endsWith(".mjs")).sort();
261
399
  function buildId(dir) {
@@ -313,6 +451,22 @@ function linkCurrent(root, id) {
313
451
  }
314
452
  return current;
315
453
  }
454
+ function restoreCurrent(root, id) {
455
+ const current = join(root, "current");
456
+ const target = () => {
457
+ try {
458
+ return readlinkSync(current);
459
+ } catch {
460
+ return null;
461
+ }
462
+ };
463
+ const previous = target();
464
+ return () => {
465
+ if (target() !== id) return;
466
+ if (previous === null) rmSync(current, { force: true });
467
+ else linkCurrent(root, previous);
468
+ };
469
+ }
316
470
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
317
471
  function readSettings(file) {
318
472
  if (!existsSync(file)) return {};
@@ -323,135 +477,237 @@ function readSettings(file) {
323
477
  throw new Error(`${file} is not valid JSON (${error.message}); it was left unchanged`);
324
478
  }
325
479
  const hooks = isObject(settings) ? settings.hooks : void 0;
326
- const preToolUse = isObject(hooks) ? hooks.PreToolUse : void 0;
327
480
  const wrongHooks = hooks !== void 0 && hooks !== null && !isObject(hooks);
328
- const wrongPreToolUse = preToolUse !== void 0 && preToolUse !== null && !Array.isArray(preToolUse);
329
- if (!isObject(settings) || wrongHooks || wrongPreToolUse) throw new Error(`${file} does not hold a settings object with a hooks object and a PreToolUse list; it was left unchanged`);
481
+ const wrongList = EVENTS$1.find((event) => {
482
+ const list = isObject(hooks) ? hooks[event] : void 0;
483
+ return list !== void 0 && list !== null && !Array.isArray(list);
484
+ });
485
+ if (!isObject(settings) || wrongHooks) throw new Error(`${file} does not hold a settings object with a hooks object; it was left unchanged`);
486
+ if (wrongList) throw new Error(`${file} has a ${wrongList} that is not a list; it was left unchanged`);
330
487
  return settings;
331
488
  }
332
489
  function writeConfig(file, text) {
333
490
  const real = existsSync(file) ? realpathSync(file) : file;
334
491
  mkdirSync(dirname(real), { recursive: true });
335
492
  const temp = `${real}.${process.pid}.tmp`;
493
+ const mode = existsSync(real) ? statSync(real).mode & 4095 : void 0;
336
494
  try {
337
- writeFileSync(temp, text);
338
- if (existsSync(real)) chmodSync(temp, statSync(real).mode & 4095);
495
+ rmSync(temp, { force: true });
496
+ writeFileSync(temp, text, {
497
+ mode: mode ?? 438,
498
+ flag: "wx"
499
+ });
500
+ if (mode !== void 0) chmodSync(temp, mode);
339
501
  renameSync(temp, real);
340
502
  } finally {
341
503
  rmSync(temp, { force: true });
342
504
  }
343
505
  }
344
- function configure(file, settings, own, entry) {
345
- const hooks = settings.hooks ?? {};
346
- const before = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
347
- let removed = 0;
348
- const kept = before.map((item) => {
349
- if (!Array.isArray(item?.hooks)) return item;
350
- const rest = item.hooks.filter((hook) => !(typeof hook?.command === "string" && own.test(hook.command)));
351
- removed += item.hooks.length - rest.length;
506
+ function configure(file, settings, own, entries) {
507
+ const hooks = { ...settings.hooks };
508
+ let changed = false;
509
+ for (const event of EVENTS$1) {
510
+ const kept = (Array.isArray(hooks[event]) ? hooks[event] : []).map((item) => {
511
+ if (!Array.isArray(item?.hooks)) return item;
512
+ const rest = item.hooks.filter((hook) => !(typeof hook?.command === "string" && own(hook.command)));
513
+ changed ||= rest.length < item.hooks.length;
514
+ return {
515
+ ...item,
516
+ hooks: rest
517
+ };
518
+ }).filter((item) => !Array.isArray(item?.hooks) || item.hooks.length > 0);
519
+ const entry = entries[event];
520
+ if (entry) {
521
+ kept.push(entry);
522
+ changed = true;
523
+ }
524
+ if (kept.length > 0) hooks[event] = kept;
525
+ else if (Array.isArray(hooks[event])) delete hooks[event];
526
+ }
527
+ if (!changed) return;
528
+ settings.hooks = hooks;
529
+ writeConfig(file, `${JSON.stringify(settings, null, 2)}\n`);
530
+ }
531
+ function linesOf(value, field) {
532
+ if (value === void 0) return [];
533
+ if (!Array.isArray(value) || value.some((line) => typeof line !== "string")) throw new TypeError(`${field} is not a list of strings`);
534
+ return [...value];
535
+ }
536
+ function withExtra(file, place, write, extra) {
537
+ const before = existsSync(file) ? readFileSync(file, "utf8") : null;
538
+ write();
539
+ if (!extra) return {
540
+ notes: [],
541
+ warnings: []
542
+ };
543
+ try {
544
+ const result = extra({
545
+ ...place,
546
+ file
547
+ });
548
+ if (result !== void 0 && !isObject(result)) throw new TypeError("an install step returned neither nothing nor { notes, warnings }");
352
549
  return {
353
- ...item,
354
- hooks: rest
550
+ notes: linesOf(result?.notes, "notes"),
551
+ warnings: linesOf(result?.warnings, "warnings")
355
552
  };
356
- }).filter((item) => !Array.isArray(item?.hooks) || item.hooks.length > 0);
357
- if (!entry && removed === 0) return;
358
- if (entry) kept.push(entry);
359
- settings.hooks = {
360
- ...hooks,
361
- PreToolUse: kept
362
- };
363
- if (kept.length === 0) delete settings.hooks.PreToolUse;
364
- writeConfig(file, `${JSON.stringify(settings, null, 2)}\n`);
553
+ } catch (error) {
554
+ if (before === null) rmSync(file, { force: true });
555
+ else if (readFileSync(file, "utf8") !== before) writeConfig(file, before);
556
+ throw error;
557
+ }
365
558
  }
366
559
  /**
367
- * Adds the plugin's PreToolUse hook to one agent's config, replacing any entry
368
- * it wrote before and keeping every other one.
560
+ * Adds the plugin's hooks to one agent's config, PreToolUse and each event in
561
+ * its `events`, replacing any entry it wrote before, for any event, and
562
+ * keeping every other one.
369
563
  *
370
564
  * The user scope copies the bin's directory to `~/.<name>/<build-id>/`, checks
371
565
  * the copy runs, points `~/.<name>/current` at it and runs it from there on
372
566
  * `execPath`. The project scope runs `npx --no-install <packageName> hook`.
373
- * Either command ends in `|| exit 2`, so a hook that cannot start blocks the
374
- * call. The Claude Code entry also sets `onFailure: "block"` for a hook that
375
- * times out.
567
+ * Each command names its event and agent, `hook --event <event> --agent
568
+ * <agent>`, and the PreToolUse one ends in `|| exit 2`, so a hook that cannot start
569
+ * blocks the call, and the Claude Code entry sets `onFailure: "block"` for one
570
+ * that times out. The other events' commands do not, so they pass instead.
571
+ * Then the plugin's `onInstall` runs.
376
572
  *
377
- * Throws, and leaves the config as it was, when the config is not a settings
378
- * object it can extend, when the plugin's kinds leave tools the agent routes
379
- * to it unchecked, or when the build cannot be copied or does not run.
573
+ * Throws, and leaves the config and `current` as they were, when the config
574
+ * is not a settings object it can extend, when the plugin's kinds leave tools
575
+ * the agent routes to it unchecked, when the build cannot be copied or does
576
+ * not run, or when `onInstall` throws.
380
577
  */
381
578
  function install(options) {
382
579
  const { plugin, agent, scope, script } = options;
383
- const matcher = requireMatcher(agent, plugin);
580
+ requireMatcher(agent, plugin);
384
581
  const file = configFile(agent, scope, options);
385
582
  const settings = readSettings(file);
386
583
  let target = script;
584
+ let restore = () => {};
387
585
  if (scope === "user") {
388
586
  const root = join(options.home, `.${plugin.name}`);
389
587
  const id = copyBuild(plugin, script, root);
390
588
  probe(join(root, id, basename(script)), options.execPath);
589
+ restore = restoreCurrent(root, id);
391
590
  target = join(linkCurrent(root, id), basename(script));
392
591
  }
393
- const command = hookCommand(plugin, scope, target, options.execPath);
394
- configure(file, settings, ownHook(plugin, basename(script)), {
395
- matcher,
396
- hooks: [{
397
- type: "command",
398
- command,
399
- timeout: plugin.timeout,
400
- statusMessage: plugin.statusMessage,
401
- ...agent === "claude" ? { onFailure: "block" } : {}
402
- }]
403
- });
592
+ const entries = entriesFor(plugin, agent, hookCommand(plugin, scope, target, options.execPath));
593
+ const place = {
594
+ agent,
595
+ scope,
596
+ home: options.home,
597
+ cwd: options.cwd,
598
+ script
599
+ };
600
+ let lines;
601
+ try {
602
+ lines = withExtra(file, place, () => configure(file, settings, ownHook(plugin, basename(script)), entries), plugin.onInstall);
603
+ } catch (error) {
604
+ try {
605
+ restore();
606
+ } catch {}
607
+ throw error;
608
+ }
609
+ const commands = Object.fromEntries(Object.entries(entries).map(([event, entry]) => [event, entry.hooks[0].command]));
404
610
  return {
405
611
  file,
406
- command
612
+ command: commands.PreToolUse,
613
+ commands,
614
+ ...lines
407
615
  };
408
616
  }
409
617
  /**
410
- * Removes the plugin's hook from one agent's config, whichever form an earlier
411
- * install wrote, and keeps every other entry. The stable copy stays: another
412
- * agent may still run it. Throws, and leaves the file as it was, when it is not
413
- * a settings object.
618
+ * Removes the plugin's hooks from one agent's config, for every event and
619
+ * whichever form an earlier install wrote, keeps every other entry, and runs
620
+ * the plugin's `onUninstall`. The stable copy stays: another agent may still
621
+ * run it. Throws, and leaves the file as it was, when it is not a settings
622
+ * object or `onUninstall` throws.
414
623
  */
415
624
  function uninstall(options) {
416
- const file = configFile(options.agent, options.scope, options);
417
- if (!existsSync(file)) return { file };
418
- configure(file, readSettings(file), ownHook(options.plugin, basename(options.script)), null);
419
- return { file };
625
+ const { plugin, agent, scope, script } = options;
626
+ const file = configFile(agent, scope, options);
627
+ return {
628
+ file,
629
+ ...withExtra(file, {
630
+ agent,
631
+ scope,
632
+ home: options.home,
633
+ cwd: options.cwd,
634
+ script
635
+ }, () => {
636
+ if (existsSync(file)) configure(file, readSettings(file), ownHook(plugin, basename(script)), {});
637
+ }, plugin.onUninstall)
638
+ };
420
639
  }
421
640
  // src/cli.ts
422
641
  const isAgent = (word) => word === "claude" || word === "codex";
423
- function usage(bin) {
642
+ const isEvent = (word) => word === "PreToolUse" || word === "Stop" || word === "UserPromptSubmit";
643
+ function hookArgs(args) {
644
+ const parsed = {};
645
+ for (let i = 0; i < args.length; i += 2) {
646
+ const [flag, value] = [args[i], args[i + 1]];
647
+ if (flag === "--event" && isEvent(value) && parsed.event === void 0) parsed.event = value;
648
+ else if (flag === "--agent" && isAgent(value) && parsed.agent === void 0) parsed.agent = value;
649
+ else {
650
+ const named = args.find((word, at) => args[at - 1] === "--event" && isEvent(word));
651
+ return {
652
+ ...parsed,
653
+ event: parsed.event ?? (isEvent(named) ? named : void 0),
654
+ error: `${[flag, value].filter((word) => word !== void 0).join(" ")} is not an argument hook takes`
655
+ };
656
+ }
657
+ }
658
+ return parsed;
659
+ }
660
+ function usage(plugin, bin) {
661
+ const events = ["PreToolUse", ...Object.keys(plugin.events)];
424
662
  const rows = [
425
- ["install <claude|codex> [--project]", "add the PreToolUse hook"],
663
+ ["install <claude|codex> [--project]", `add the ${events.join(" and ")} hook${events.length > 1 ? "s" : ""}`],
426
664
  ["uninstall <claude|codex> [--project]", "remove it"],
427
- ["hook", "run as the hook (reads the tool call on stdin)"]
665
+ ["hook [--event <event>] [--agent <claude|codex>]", "run as the hook (reads the agent's payload on stdin)"]
428
666
  ];
429
667
  const width = Math.max(...rows.map(([command]) => command.length)) + 1;
430
668
  return `Usage:\n${rows.map(([command, what]) => ` ${bin} ${command.padEnd(width)}${what}`).join("\n")}\n`;
431
669
  }
432
670
  /**
433
671
  * Builds the plugin's command line: `install` and `uninstall <claude|codex>
434
- * [--project]`, and `hook`, which reads the tool call from `io.stdin`.
672
+ * [--project]`, and `hook [--event <event>] [--agent <claude|codex>]`, which
673
+ * reads the payload from `io.stdin` and hands the flags to {@link runHook}.
435
674
  *
436
- * Exit codes: `hook` returns what {@link runHook} returns, 0 or 2. `install`
437
- * and `uninstall` return 0, or 1 with the reason on stderr. Anything else,
438
- * including an unknown flag or an extra argument, prints the usage and
439
- * returns 1. On a Node.js older than 22, `hook` returns 2
440
- * and `install` 1, each with a message naming the fix; `uninstall` still runs.
675
+ * Exit codes: `hook` returns what {@link runHook} returns, 0 or 2; an
676
+ * argument it does not take is a fault, 0 at Stop or UserPromptSubmit and 2
677
+ * otherwise. `install`
678
+ * and `uninstall` return 0, or 1 with the reason on stderr; the notes the
679
+ * plugin's `onInstall` or `onUninstall` returns follow the config file on
680
+ * stdout, its warnings go to stderr. Anything else, including an unknown flag
681
+ * or an extra argument, prints the usage and returns 1. On a Node.js older
682
+ * than 22, `hook` returns 2 for PreToolUse and 0 for Stop and
683
+ * UserPromptSubmit, and `install` 1, each with a message naming the fix;
684
+ * `uninstall` still runs.
441
685
  */
442
686
  function createCli(plugin, options = {}) {
443
687
  const bin = options.bin ?? plugin.name;
444
688
  return async (argv, io) => {
445
689
  const [command, agent, ...flags] = argv;
690
+ const args = command === "hook" ? hookArgs(argv.slice(1)) : {};
446
691
  const major = Number(/^v(\d+)\./.exec(io.node)?.[1] ?? 0);
447
692
  if ((command === "hook" || command === "install") && major < 22) {
448
693
  io.stderr(`${bin} needs Node.js 22 or later, and this one is ${io.node}. Run \`npx ${plugin.packageName} install claude\` (or codex) again with Node.js 22 or later, or put one first on PATH and restart the agent.\n`);
449
- return command === "hook" ? 2 : 1;
694
+ if (command === "install") return 1;
695
+ return passesOnFault(args.event, io.stdin()) ? 0 : 2;
696
+ }
697
+ if (command === "hook") {
698
+ if (args.error) {
699
+ const open = passesOnFault(args.event, io.stdin());
700
+ io.stderr(open ? `${plugin.name} let it pass: ${args.error}\n` : `Blocked by ${plugin.name}: ${args.error}\n`);
701
+ return open ? 0 : 2;
702
+ }
703
+ return runHook(plugin, io.stdin(), {
704
+ cwd: io.cwd,
705
+ stderr: io.stderr,
706
+ stdout: io.stdout,
707
+ event: args.event,
708
+ agent: args.agent
709
+ });
450
710
  }
451
- if (command === "hook") return runHook(plugin, io.stdin(), {
452
- cwd: io.cwd,
453
- stderr: io.stderr
454
- });
455
711
  const project = flags.length === 1 && flags[0] === "--project";
456
712
  if ((command === "install" || command === "uninstall") && isAgent(agent) && (flags.length === 0 || project)) {
457
713
  const place = {
@@ -462,21 +718,23 @@ function createCli(plugin, options = {}) {
462
718
  home: io.home,
463
719
  cwd: io.cwd
464
720
  };
465
- let file;
721
+ let result;
466
722
  try {
467
- file = command === "install" ? install({
723
+ result = command === "install" ? install({
468
724
  ...place,
469
725
  execPath: io.execPath
470
- }).file : uninstall(place).file;
726
+ }) : uninstall(place);
471
727
  } catch (error) {
472
728
  io.stderr(`${bin}: ${error instanceof Error ? error.message : String(error)}\n`);
473
729
  return 1;
474
730
  }
475
- io.stdout(`${command === "install" ? "Installed in" : "Removed from"} ${file}\n`);
731
+ io.stdout(`${command === "install" ? "Installed in" : "Removed from"} ${result.file}\n`);
732
+ for (const note of result.notes) io.stdout(`${note}\n`);
733
+ for (const warning of result.warnings) io.stderr(`${bin}: ${warning}\n`);
476
734
  if (command === "install" && agent === "codex") io.stdout("Codex runs a new hook only after you trust it: open /hooks in Codex once.\n");
477
735
  return 0;
478
736
  }
479
- io.stderr(usage(bin));
737
+ io.stderr(usage(plugin, bin));
480
738
  return 1;
481
739
  };
482
740
  }
@@ -500,22 +758,29 @@ async function readStdin(stream = process.stdin) {
500
758
  * `hook`, and sets `process.exitCode`. Call it without `await`, passing the
501
759
  * bin's own `import.meta.url`, which is what install copies and runs.
502
760
  *
503
- * For `hook` the exit code is 2 until a verdict is in. Node.js exits 0 once
504
- * nothing is left to run, so a check whose promise never settles would
505
- * otherwise let the call run.
761
+ * For a PreToolUse `hook` the exit code is 2 until a verdict is in. Node.js
762
+ * exits 0 once nothing is left to run, so a check whose promise never settles
763
+ * would otherwise let the call run. A Stop or UserPromptSubmit check that
764
+ * never settles exits 0, as any other fault there does.
506
765
  */
507
766
  async function runCli(plugin, entryUrl, options = {}) {
508
767
  const argv = process.argv.slice(2);
509
768
  const hook = argv[0] === "hook";
769
+ const { event } = hook ? hookArgs(argv.slice(1)) : {};
510
770
  let done = false;
771
+ let open = false;
511
772
  if (hook) {
512
773
  process.exitCode = 2;
513
774
  process.once("beforeExit", () => {
514
- if (!done) process.stderr.write(`Blocked by ${plugin.name}, its check never finished\n`);
775
+ if (!done) process.stderr.write(open ? `${plugin.name} let it pass, its check never finished\n` : `Blocked by ${plugin.name}, its check never finished\n`);
515
776
  });
516
777
  }
517
778
  try {
518
779
  const input = hook ? await readStdin() : "";
780
+ if (hook && passesOnFault(event, input)) {
781
+ open = true;
782
+ process.exitCode = 0;
783
+ }
519
784
  process.exitCode = await createCli(plugin, options)(argv, {
520
785
  stdin: () => input,
521
786
  script: fileURLToPath(entryUrl),
@@ -530,7 +795,7 @@ async function runCli(plugin, entryUrl, options = {}) {
530
795
  } catch (error) {
531
796
  done = true;
532
797
  process.stderr.write(`${options.bin ?? plugin.name}: ${error instanceof Error ? error.message : String(error)}\n`);
533
- process.exitCode = hook ? 2 : 1;
798
+ process.exitCode = hook ? open ? 0 : 2 : 1;
534
799
  }
535
800
  }
536
801
  // src/define.ts
@@ -538,8 +803,11 @@ const KINDS = [
538
803
  "shell",
539
804
  "write",
540
805
  "edit",
541
- "patch"
806
+ "patch",
807
+ "tool"
542
808
  ];
809
+ const TOOL_NAME = /^[\w-]+$/;
810
+ const EVENTS = ["Stop", "UserPromptSubmit"];
543
811
  const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
544
812
  const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
545
813
  /**
@@ -548,20 +816,65 @@ const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
548
816
  * broken plugin fails when it is built, not when the hook runs.
549
817
  */
550
818
  function defineHook(definition) {
551
- const { name, kinds, check, packageName = name, statusMessage = `Running ${name}`, timeout = 10 } = definition;
819
+ const { name, kinds, check, packageName = name, statusMessage = `Running ${name}`, timeout = 10, ownCommands = [], tools = [], events = {}, onInstall, onUninstall } = definition;
552
820
  if (typeof name !== "string" || !NAME.test(name)) throw new TypeError(`name ${JSON.stringify(name)} is not kebab-case (lowercase letters and digits, joined by single hyphens)`);
553
821
  if (typeof packageName !== "string" || !PACKAGE.test(packageName)) throw new TypeError(`packageName ${JSON.stringify(packageName)} is not an npm package name`);
554
822
  if (!Array.isArray(kinds) || kinds.length === 0 || kinds.some((kind) => !KINDS.includes(kind))) throw new TypeError(`kinds must list one or more of ${KINDS.join(", ")}`);
555
823
  if (typeof check !== "function") throw new TypeError("check is not a function");
556
- if (typeof statusMessage !== "string" || statusMessage === "") throw new TypeError("statusMessage is not a non-empty string");
557
- if (!Number.isInteger(timeout) || timeout < 1) throw new TypeError(`timeout ${JSON.stringify(timeout)} is not a whole number of seconds`);
824
+ checkStatus(statusMessage, "statusMessage");
825
+ checkTimeout(timeout, "timeout");
826
+ const patterns = Array.isArray(ownCommands) ? [...ownCommands] : null;
827
+ if (patterns === null || patterns.some((pattern) => !(pattern instanceof RegExp) || pattern.global || pattern.multiline || pattern.sticky)) throw new TypeError("ownCommands must list regular expressions without the g, m or y flag");
828
+ const names = Array.isArray(tools) ? [...tools] : null;
829
+ if (names === null || names.some((tool) => typeof tool !== "string" || !TOOL_NAME.test(tool))) throw new TypeError("tools must list tool names of letters, digits, _ and -");
830
+ const builtIn = names.find((tool) => KIND_OF_TOOL.has(tool));
831
+ if (builtIn !== void 0) throw new TypeError(`${builtIn} is one of the agents' own tools; list its kind instead`);
832
+ if (kinds.includes("tool") !== names.length > 0) throw new TypeError("kinds lists tool exactly when tools names one or more tools");
833
+ for (const [key, hook] of [["onInstall", onInstall], ["onUninstall", onUninstall]]) if (hook !== void 0 && typeof hook !== "function") throw new TypeError(`${key} is not a function`);
558
834
  return Object.freeze({
559
835
  name,
560
836
  packageName,
561
837
  kinds: Object.freeze([...new Set(kinds)]),
562
838
  statusMessage,
563
839
  timeout,
840
+ ownCommands: Object.freeze(patterns),
841
+ tools: Object.freeze([...new Set(names)]),
842
+ events: eventHooks(events, statusMessage, timeout),
843
+ onInstall,
844
+ onUninstall,
564
845
  check
565
846
  });
566
847
  }
848
+ function checkStatus(value, field) {
849
+ if (typeof value !== "string" || value === "") throw new TypeError(`${field} is not a non-empty string`);
850
+ }
851
+ function checkTimeout(value, field) {
852
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`${field} ${JSON.stringify(value)} is not a whole number of seconds`);
853
+ }
854
+ function eventHook(event, definition, statusMessage, timeout) {
855
+ if (typeof definition !== "object" || definition === null || typeof definition.check !== "function") throw new TypeError(`events.${event}.check is not a function`);
856
+ const hook = {
857
+ check: definition.check,
858
+ statusMessage: definition.statusMessage ?? statusMessage,
859
+ timeout: definition.timeout ?? timeout
860
+ };
861
+ checkStatus(hook.statusMessage, `events.${event}.statusMessage`);
862
+ checkTimeout(hook.timeout, `events.${event}.timeout`);
863
+ return hook;
864
+ }
865
+ function eventHooks(events, statusMessage, timeout) {
866
+ if (typeof events !== "object" || events === null || Array.isArray(events)) throw new TypeError("events is not an object");
867
+ const unknown = Object.keys(events).find((event) => !EVENTS.includes(event));
868
+ if (unknown !== void 0) throw new TypeError(`events.${unknown} is not one of ${EVENTS.join(", ")}`);
869
+ const { Stop, UserPromptSubmit } = events;
870
+ const hidePrompt = UserPromptSubmit?.hidePrompt ?? false;
871
+ if (typeof hidePrompt !== "boolean") throw new TypeError("events.UserPromptSubmit.hidePrompt is not a boolean");
872
+ return Object.freeze({
873
+ ...Stop ? { Stop: Object.freeze(eventHook("Stop", Stop, statusMessage, timeout)) } : {},
874
+ ...UserPromptSubmit ? { UserPromptSubmit: Object.freeze({
875
+ ...eventHook("UserPromptSubmit", UserPromptSubmit, statusMessage, timeout),
876
+ hidePrompt
877
+ }) } : {}
878
+ });
879
+ }
567
880
  export { claudePluginFiles, configFile, createCli, defineHook, install, matcherFor, readStdin, runCli, runHook, uninstall, writeClaudePlugin };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-base-kit",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.3.0",
5
5
  "packageManager": "pnpm@10.23.0",
6
6
  "description": "The base kit for Claude Code and Codex hooks: typed tool calls, a fail-closed runner, install and uninstall with a stable copy of the build, and Claude Code plugin files. Zero dependencies.",
7
7
  "author": "devmaxxx",