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 +72 -11
- package/dist/index.d.mts +197 -38
- package/dist/index.mjs +436 -123
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
[](./package.json)
|
|
15
15
|
[](./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
|
|
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
|
|
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
|
-
|
|
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,
|
|
98
|
-
| The
|
|
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
|
|
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
|
|
127
|
-
function uninstall(options: UninstallOptions):
|
|
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,
|
|
10
|
-
* apply_patch, the whole patch text, which can add, change and delete
|
|
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
|
|
134
|
-
* `onFailure: "block"`, so a plugin that cannot start or times out blocks
|
|
135
|
-
* call
|
|
136
|
-
*
|
|
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
|
|
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
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
195
|
-
* nothing is left to run, so a check whose promise never settles
|
|
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
|
|
214
|
-
* agent reads
|
|
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
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
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
|
|
252
|
-
* it wrote before
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
|
262
|
-
* object it can extend, when the plugin's kinds leave tools
|
|
263
|
-
* to it unchecked,
|
|
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
|
|
268
|
-
* install wrote,
|
|
269
|
-
*
|
|
270
|
-
* a settings
|
|
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
|
|
80
|
-
* `onFailure: "block"`, so a plugin that cannot start or times out blocks
|
|
81
|
-
* call
|
|
82
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
211
|
-
*
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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 =
|
|
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: ${
|
|
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
|
|
250
|
-
return `n=${quote(node)}; [ -x "$n" ] || n=node; "$n" ${quote(script)} hook
|
|
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
|
-
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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,
|
|
345
|
-
const hooks = settings.hooks
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
550
|
+
notes: linesOf(result?.notes, "notes"),
|
|
551
|
+
warnings: linesOf(result?.warnings, "warnings")
|
|
355
552
|
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
368
|
-
* it wrote before
|
|
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
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
|
378
|
-
* object it can extend, when the plugin's kinds leave tools
|
|
379
|
-
* to it unchecked,
|
|
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
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
411
|
-
* install wrote,
|
|
412
|
-
*
|
|
413
|
-
* a settings
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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]",
|
|
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
|
|
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
|
|
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
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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
|
-
|
|
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
|
|
721
|
+
let result;
|
|
466
722
|
try {
|
|
467
|
-
|
|
723
|
+
result = command === "install" ? install({
|
|
468
724
|
...place,
|
|
469
725
|
execPath: io.execPath
|
|
470
|
-
})
|
|
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
|
|
504
|
-
* nothing is left to run, so a check whose promise never settles
|
|
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
|
-
|
|
557
|
-
|
|
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.
|
|
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",
|