agent-base-kit 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devmaxxx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img src="docs/assets/logo.svg" width="96" height="96" alt="">
4
+
5
+ # agent-base-kit
6
+
7
+ **The base kit for Claude Code and Codex hooks.**
8
+
9
+ [![npm version](https://img.shields.io/npm/v/agent-base-kit?color=cb3837)](https://www.npmjs.com/package/agent-base-kit)
10
+ [![npm downloads](https://img.shields.io/npm/dm/agent-base-kit)](https://www.npmjs.com/package/agent-base-kit)
11
+ [![CI](https://github.com/devmaxxx/agent-base-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/devmaxxx/agent-base-kit/actions/workflows/ci.yml)
12
+ [![types](https://img.shields.io/npm/types/agent-base-kit)](https://www.npmjs.com/package/agent-base-kit)
13
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json)
14
+ [![node](https://img.shields.io/node/v/agent-base-kit)](./package.json)
15
+ [![license: MIT](https://img.shields.io/npm/l/agent-base-kit)](./LICENSE)
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)
18
+
19
+ </div>
20
+
21
+ ## Why
22
+
23
+ A [PreToolUse hook](https://code.claude.com/docs/en/hooks) is the last point where you can stop an agent's tool call. Every hook needs the same plumbing before its own check: read the agent's payload, write the agent's config without breaking anyone else's entries, keep running across Node.js upgrades, and block when the check cannot run, since the agents treat every exit except 2 as "go on".
24
+
25
+ agent-base-kit is that plumbing, shared by every hook you build. You write the check; reuse the rest for a secret scanner, a policy guard, or any other hook.
26
+
27
+ ## What you get
28
+
29
+ - **One function to write.** `check(call)` gets a typed call (`shell`, `write`, `edit` or `patch`) and returns `{ block, reason }`, sync or async.
30
+ - **Both agents.** Claude Code and Codex, with matchers derived from the kinds you list.
31
+ - **Fail-closed by default.** Unreadable payloads, throws, bad verdicts, hangs and a hook that cannot start all block.
32
+ - **One command to install.** Your bin gets `install` and `uninstall` for `claude` or `codex`, at user or project scope.
33
+ - **Other hooks left alone.** Install and uninstall touch only their own entry, and refuse a config they cannot extend safely.
34
+ - **Survives upgrades.** A user install runs its own copy of your build on the Node.js that installed it, so an nvm or Homebrew upgrade or a rebuild does not switch it off.
35
+ - **Claude Code plugin files.** One call writes the manifest, `hooks.json` and a marketplace.
36
+ - **Zero dependencies**, ESM only, typed.
37
+
38
+ ## Usage
39
+
40
+ ```sh
41
+ pnpm add -D agent-base-kit
42
+ ```
43
+
44
+ Write the check and hand it to `runCli` in your bin:
45
+
46
+ ```ts
47
+ #!/usr/bin/env node
48
+ import { defineHook, runCli } from 'agent-base-kit'
49
+
50
+ const plugin = defineHook({
51
+ name: 'no-force-push',
52
+ kinds: ['shell'],
53
+ check: call => /\bgit\s+push\b.*\s(?:-f|--force)(?:\s|$)/.test(call.command)
54
+ ? { block: true, reason: 'Blocked by no-force-push: push without --force, or use --force-with-lease.' }
55
+ : { block: false },
56
+ })
57
+
58
+ void runCli(plugin, import.meta.url)
59
+ ```
60
+
61
+ Bundle the bin into `.mjs` files with agent-base-kit inlined, for example with tsdown, since the stable copy has no `node_modules`. Your users then run:
62
+
63
+ ```sh
64
+ npx no-force-push install claude # ~/.claude/settings.json
65
+ npx no-force-push install codex --project # ./.codex/hooks.json
66
+ npx no-force-push uninstall claude
67
+ ```
68
+
69
+ Requires Node.js 22 or later and a POSIX shell.
70
+
71
+ ## What install writes
72
+
73
+ A user install (the default scope) does four things:
74
+
75
+ 1. **Copies the build.** The `.mjs` files beside your bin go to `~/.<name>/<build-id>/`, where the id is a hash of their content. The same build reuses its directory, a new build gets a new one, and the copy is staged and renamed in, so a hook never loads a half-written one.
76
+ 2. **Runs the copy once.** Before any config points at it, the copy runs on a tool no kind covers. If it cannot start, `install` refuses and changes nothing.
77
+ 3. **Swaps `~/.<name>/current`.** One rename moves the link to the new build, so the hook command stays the same across versions and Codex keeps trusting it.
78
+ 4. **Writes the hook:**
79
+
80
+ ```sh
81
+ n='/path/to/the/installing/node'; [ -x "$n" ] || n=node; "$n" '/home/you/.<name>/current/cli.mjs' hook || exit 2
82
+ ```
83
+
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
+
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
+
88
+ ## The fail-closed contract
89
+
90
+ The hook exits 0 only when your check passes the call:
91
+
92
+ | What happens | Exit |
93
+ | --- | --- |
94
+ | `check` returns `{ block: false }` | 0 |
95
+ | `check` returns `{ block: true, reason }` (the reason goes to stderr as is) | 2 |
96
+ | `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 |
99
+ | The hook cannot start: Node.js is missing, the build was deleted, a dependency was not bundled | 2, from `\|\| exit 2` |
100
+ | The hook runs on a Node.js older than 22 | 2, with a message naming the fix |
101
+ | The hook times out | Claude Code blocks it (`onFailure: "block"`, Claude Code 2.1.295 or later). Codex lets the call run. |
102
+
103
+ Keep `check` well inside `timeout`, which defaults to 10 seconds.
104
+
105
+ ## Kinds and matchers
106
+
107
+ `kinds` decides which calls reach your check, and each agent's matcher is derived from it.
108
+
109
+ | Kind | The call your check gets | Claude Code tools | Codex tools |
110
+ | --- | --- | --- | --- |
111
+ | `shell` | `{ kind: 'shell', command }` | `Bash` | `Bash` |
112
+ | `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` |
114
+ | `patch` | `{ kind: 'patch', patch }` (the whole patch text) | none | `apply_patch` |
115
+
116
+ 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
+
118
+ ## API
119
+
120
+ ```ts
121
+ function defineHook<const K extends ToolKind>(definition: HookDefinition<K>): HookPlugin
122
+ function runCli(plugin: HookPlugin, entryUrl: string, options?: { bin?: string }): Promise<void>
123
+ 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>
125
+ function readStdin(stream?: AsyncIterable<unknown>): Promise<string>
126
+ function install(options: InstallOptions): { file: string, command?: string }
127
+ function uninstall(options: UninstallOptions): { file: string }
128
+ function configFile(agent: Agent, scope: Scope, place: { home: string, cwd: string }): string
129
+ function matcherFor(agent: Agent, kinds: readonly ToolKind[]): string
130
+ function claudePluginFiles(options: ClaudePluginOptions): Record<string, string>
131
+ function writeClaudePlugin(dir: string, options: ClaudePluginOptions): string[]
132
+ ```
133
+
134
+ `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
+
136
+ ## Contributing
137
+
138
+ ```sh
139
+ git clone https://github.com/devmaxxx/agent-base-kit.git
140
+ cd agent-base-kit
141
+ pnpm install
142
+ pnpm lint && pnpm typecheck && pnpm test
143
+ ```
144
+
145
+ ## License
146
+
147
+ [MIT](./LICENSE)
@@ -0,0 +1,273 @@
1
+ /**
2
+ * What a hook declares it checks. Each kind selects the agents' tools that
3
+ * make that change, and is the `kind` of the {@link ToolCall} the check gets.
4
+ */
5
+ type ToolKind = 'shell' | 'write' | 'edit' | 'patch';
6
+ /**
7
+ * 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.
11
+ */
12
+ type ToolCall = {
13
+ kind: 'shell';
14
+ command: string;
15
+ } | {
16
+ kind: 'write';
17
+ path: string;
18
+ content: string;
19
+ } | {
20
+ kind: 'edit';
21
+ path: string;
22
+ before: string;
23
+ after: string;
24
+ } | {
25
+ kind: 'patch';
26
+ patch: string;
27
+ };
28
+ /** What a check declared with kinds `K` receives: only the matching shapes of {@link ToolCall}. */
29
+ type ToolCallOf<K extends ToolKind> = Extract<ToolCall, {
30
+ kind: K;
31
+ }>;
32
+ /**
33
+ * What a check decides. A block's `reason` is printed to stderr as it is, and
34
+ * the agent shows it to the model, so it should name the hook and say what to
35
+ * do instead.
36
+ */
37
+ type HookVerdict = {
38
+ block: false;
39
+ } | {
40
+ block: true;
41
+ reason: string;
42
+ };
43
+ /** Where the call runs. */
44
+ interface HookContext {
45
+ /** The agent's working directory, from the payload, else the hook's own. */
46
+ cwd: string;
47
+ }
48
+ /** What a plugin author writes; {@link defineHook} checks it and fills in the defaults. */
49
+ interface HookDefinition<K extends ToolKind> {
50
+ /**
51
+ * Kebab-case. It names the stable copy (`~/.<name>/`), the Claude Code
52
+ * plugin, and the commands install recognizes as its own.
53
+ */
54
+ name: string;
55
+ /**
56
+ * The calls to check. The agents' matchers are derived from it, and the
57
+ * check is only ever handed a call of one of these kinds. Codex sends every
58
+ * file change as one apply_patch, so a hook that takes `write` or `edit`
59
+ * must take `patch` too before it can be installed for Codex.
60
+ */
61
+ kinds: readonly K[];
62
+ /**
63
+ * Decides one call. Throwing, rejecting or returning anything but a verdict
64
+ * blocks the call.
65
+ */
66
+ check: (call: ToolCallOf<K>, context: HookContext) => HookVerdict | Promise<HookVerdict>;
67
+ /** The npm package whose bin runs the hook; a project install runs it through npx. Defaults to `name`. */
68
+ packageName?: string;
69
+ /** What the agent shows while the hook runs. Defaults to `Running <name>`. */
70
+ statusMessage?: string;
71
+ /**
72
+ * Seconds the agent waits for the hook. Defaults to 10. Past it Codex lets
73
+ * the call run, and Claude Code blocks it.
74
+ */
75
+ timeout?: number;
76
+ }
77
+ /** A checked hook definition, as {@link defineHook} returns it. */
78
+ interface HookPlugin {
79
+ readonly name: string;
80
+ readonly packageName: string;
81
+ readonly kinds: readonly ToolKind[];
82
+ readonly statusMessage: string;
83
+ readonly timeout: number;
84
+ /** Called only with a call whose kind is in `kinds`. */
85
+ readonly check: (call: ToolCall, context: HookContext) => HookVerdict | Promise<HookVerdict>;
86
+ }
87
+ /** `claude` installs into `.claude/settings.json`, `codex` into `.codex/hooks.json`. */
88
+ type Agent = 'claude' | 'codex';
89
+ /** `user` writes the agent's config in the home directory, `project` the one in the working directory. */
90
+ type Scope = 'user' | 'project';
91
+ /**
92
+ * The PreToolUse matcher that routes the given kinds of call to the hook in
93
+ * one agent, such as `Bash|Write|Edit|MultiEdit|NotebookEdit` for every kind
94
+ * in Claude Code; empty when the agent has no tool of those kinds.
95
+ */
96
+ export declare function matcherFor(agent: Agent, kinds: readonly ToolKind[]): string;
97
+ /** A person or organisation, as the plugin and marketplace manifests take it. */
98
+ interface ClaudePluginPerson {
99
+ name: string;
100
+ email?: string;
101
+ url?: string;
102
+ }
103
+ /** The plugin package's metadata; the hook itself comes from `plugin`. */
104
+ interface ClaudePluginOptions {
105
+ plugin: HookPlugin;
106
+ /** The built bin, relative to the package root: `dist/cli.mjs`. */
107
+ entry: string;
108
+ /** The package version. Without it, Claude Code lists a plugin installed from npm as version `unknown`. */
109
+ version?: string;
110
+ description?: string;
111
+ author?: ClaudePluginPerson;
112
+ /** Must be a URL; Claude Code does not load a plugin whose homepage is not one. */
113
+ homepage?: string;
114
+ repository?: string;
115
+ license?: string;
116
+ keywords?: readonly string[];
117
+ /**
118
+ * Also writes `.claude-plugin/marketplace.json`: a marketplace of this one
119
+ * plugin, fetched from npm as `packageName`, at `version` (a version or
120
+ * range) or the latest one.
121
+ */
122
+ marketplace?: {
123
+ name: string;
124
+ owner: ClaudePluginPerson;
125
+ version?: string;
126
+ };
127
+ }
128
+ /**
129
+ * The files that make a plugin package a Claude Code plugin, by path relative
130
+ * to the package root: `.claude-plugin/plugin.json`, `hooks/hooks.json`, and
131
+ * with `marketplace`, `.claude-plugin/marketplace.json`.
132
+ *
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
137
+ * no tool Claude Code has, or a field would make Claude Code refuse the
138
+ * plugin.
139
+ */
140
+ export declare function claudePluginFiles(options: ClaudePluginOptions): Record<string, string>;
141
+ /**
142
+ * Writes {@link claudePluginFiles} under `dir`, the plugin package's root, and
143
+ * returns the paths written. Run it from the package's build, and publish the
144
+ * written files with the build.
145
+ */
146
+ export declare function writeClaudePlugin(dir: string, options: ClaudePluginOptions): string[];
147
+ /** What the command line needs from its process, passed in so tests can supply it. */
148
+ interface CliIO {
149
+ /** The tool call, read only by `hook`. */
150
+ stdin: () => string;
151
+ /** The built bin the installed hook runs. */
152
+ script: string;
153
+ /** The running Node.js version, as `process.version` gives it. */
154
+ node: string;
155
+ /** The running Node.js executable, which a user-scope hook keeps running on. */
156
+ execPath: string;
157
+ /** Holds the user-scope config and the stable copy of the build. */
158
+ home: string;
159
+ /** Holds the project-scope config, and is the hook's working directory when the payload names none. */
160
+ cwd: string;
161
+ stdout: (text: string) => void;
162
+ stderr: (text: string) => void;
163
+ }
164
+ /** How the command names itself. */
165
+ interface CliOptions {
166
+ /** The command name in usage and error text, for a bin not named after the plugin. Defaults to the plugin's name. */
167
+ bin?: string;
168
+ }
169
+ /** Runs one command line and resolves to its exit code. */
170
+ type CliMain = (argv: readonly string[], io: CliIO) => Promise<number>;
171
+ /**
172
+ * Builds the plugin's command line: `install` and `uninstall <claude|codex>
173
+ * [--project]`, and `hook`, which reads the tool call from `io.stdin`.
174
+ *
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.
180
+ */
181
+ export declare function createCli(plugin: HookPlugin, options?: CliOptions): CliMain;
182
+ /**
183
+ * Reads a stream to its end as UTF-8, `process.stdin` by default. A
184
+ * synchronous read of fd 0 fails with EAGAIN on a non-blocking pipe once the
185
+ * payload outgrows its buffer, so the hook reads this way. A read that fails
186
+ * resolves to `''`, which {@link runHook} blocks.
187
+ */
188
+ export declare function readStdin(stream?: AsyncIterable<unknown>): Promise<string>;
189
+ /**
190
+ * The whole bin: runs {@link createCli} on `process.argv`, reads stdin only for
191
+ * `hook`, and sets `process.exitCode`. Call it without `await`, passing the
192
+ * bin's own `import.meta.url`, which is what install copies and runs.
193
+ *
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.
197
+ */
198
+ export declare function runCli(plugin: HookPlugin, entryUrl: string, options?: CliOptions): Promise<void>;
199
+ /**
200
+ * Checks a hook definition and returns the plugin every other function takes.
201
+ * Throws a `TypeError` naming the field when the definition is malformed, so a
202
+ * broken plugin fails when it is built, not when the hook runs.
203
+ */
204
+ export declare function defineHook<const K extends ToolKind>(definition: HookDefinition<K>): HookPlugin;
205
+ /** Where {@link runHook} falls back to and writes to; both default to the process. */
206
+ interface RunHookOptions {
207
+ /** The working directory a payload without `cwd` gets. */
208
+ cwd?: string;
209
+ /** Receives the block reason or the error, newline-terminated. */
210
+ stderr?: (text: string) => void;
211
+ }
212
+ /**
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.
215
+ *
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.
221
+ */
222
+ export declare function runHook(plugin: HookPlugin, stdin: string, options?: RunHookOptions): Promise<number>;
223
+ /** Where {@link uninstall} looks: everything is passed in, so nothing reaches past what the caller names. */
224
+ interface UninstallOptions {
225
+ plugin: HookPlugin;
226
+ agent: Agent;
227
+ scope: Scope;
228
+ /** The built bin the hook runs; its file name also picks out entries written before the stable copy existed. */
229
+ script: string;
230
+ /** Holds the user-scope config and the stable copy of the build. */
231
+ home: string;
232
+ /** Holds the project-scope config. */
233
+ cwd: string;
234
+ }
235
+ /** What {@link install} needs on top of {@link UninstallOptions}. */
236
+ interface InstallOptions extends UninstallOptions {
237
+ /** The Node.js a user-scope hook keeps running on, as `process.execPath` gives it. */
238
+ execPath: string;
239
+ }
240
+ /** The config file written and, for an install, the command in it. */
241
+ interface InstallResult {
242
+ file: string;
243
+ command?: string;
244
+ }
245
+ /** The agent config a scope writes: `~/.claude/settings.json`, `<cwd>/.codex/hooks.json` and so on. */
246
+ export declare function configFile(agent: Agent, scope: Scope, place: {
247
+ home: string;
248
+ cwd: string;
249
+ }): string;
250
+ /**
251
+ * Adds the plugin's PreToolUse hook to one agent's config, replacing any entry
252
+ * it wrote before and keeping every other one.
253
+ *
254
+ * The user scope copies the bin's directory to `~/.<name>/<build-id>/`, checks
255
+ * the copy runs, points `~/.<name>/current` at it and runs it from there on
256
+ * `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.
260
+ *
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.
264
+ */
265
+ export declare function install(options: InstallOptions): InstallResult;
266
+ /**
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.
271
+ */
272
+ 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 };
package/dist/index.mjs ADDED
@@ -0,0 +1,567 @@
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { Buffer } from "node:buffer";
4
+ import { homedir } from "node:os";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { spawnSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ // src/agents.ts
10
+ const AGENTS = {
11
+ claude: {
12
+ label: "Claude Code",
13
+ dir: ".claude",
14
+ file: "settings.json"
15
+ },
16
+ codex: {
17
+ label: "Codex",
18
+ dir: ".codex",
19
+ file: "hooks.json"
20
+ }
21
+ };
22
+ const TOOLS = {
23
+ claude: {
24
+ Bash: "shell",
25
+ Write: "write",
26
+ Edit: "edit",
27
+ MultiEdit: "edit",
28
+ NotebookEdit: "write"
29
+ },
30
+ codex: {
31
+ Bash: "shell",
32
+ apply_patch: "patch"
33
+ }
34
+ };
35
+ const SELECTED_BY = {
36
+ shell: ["shell"],
37
+ write: ["write"],
38
+ edit: ["edit"],
39
+ patch: [
40
+ "write",
41
+ "edit",
42
+ "patch"
43
+ ]
44
+ };
45
+ const KIND_OF_TOOL = new Map(Object.values(TOOLS).flatMap((tools) => Object.entries(tools)));
46
+ function selected(agent, kinds) {
47
+ return Object.entries(TOOLS[agent]).filter(([, kind]) => SELECTED_BY[kind].some((by) => kinds.includes(by)));
48
+ }
49
+ /**
50
+ * The PreToolUse matcher that routes the given kinds of call to the hook in
51
+ * one agent, such as `Bash|Write|Edit|MultiEdit|NotebookEdit` for every kind
52
+ * in Claude Code; empty when the agent has no tool of those kinds.
53
+ */
54
+ function matcherFor(agent, kinds) {
55
+ return selected(agent, kinds).map(([tool]) => tool).join("|");
56
+ }
57
+ function requireMatcher(agent, plugin) {
58
+ 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
+ const unseen = tools.filter(([, kind]) => !plugin.kinds.includes(kind)).map(([tool]) => tool);
61
+ 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
+ }
64
+ // src/claude-plugin.ts
65
+ const MARKETPLACE_NAME = /^[a-z0-9][\w.-]*$/i;
66
+ const RESERVED = /^(?:claude|anthropics?)(?:-|$)|^cc-plugin-|(?:claude|anthropic)s?-?official|official-?(?:claude|anthropic)/;
67
+ const ENTRY = /^[\w-][\w.-]*(?:\/[\w-][\w.-]*)*\.mjs$/;
68
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
69
+ function entryOf(entry) {
70
+ const relative = entry.replace(/^\.\//, "");
71
+ if (!ENTRY.test(relative)) throw new TypeError(`entry ${JSON.stringify(entry)} is not a .mjs path inside the package`);
72
+ return relative;
73
+ }
74
+ /**
75
+ * The files that make a plugin package a Claude Code plugin, by path relative
76
+ * to the package root: `.claude-plugin/plugin.json`, `hooks/hooks.json`, and
77
+ * with `marketplace`, `.claude-plugin/marketplace.json`.
78
+ *
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
83
+ * no tool Claude Code has, or a field would make Claude Code refuse the
84
+ * plugin.
85
+ */
86
+ function claudePluginFiles(options) {
87
+ const { plugin, version, description, author, homepage, repository, license, keywords, marketplace } = options;
88
+ const matcher = requireMatcher("claude", plugin);
89
+ const entry = entryOf(options.entry);
90
+ if (RESERVED.test(plugin.name)) throw new TypeError(`Claude Code reserves plugin names like ${plugin.name} for Anthropic's own; name it for what it does`);
91
+ if (homepage !== void 0 && !URL.canParse(homepage)) throw new TypeError(`homepage ${JSON.stringify(homepage)} is not a URL`);
92
+ const files = {
93
+ ".claude-plugin/plugin.json": json({
94
+ name: plugin.name,
95
+ version,
96
+ description,
97
+ author,
98
+ homepage,
99
+ repository,
100
+ license,
101
+ keywords
102
+ }),
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
+ }] } })
113
+ };
114
+ if (marketplace) {
115
+ 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`);
116
+ files[".claude-plugin/marketplace.json"] = json({
117
+ name: marketplace.name,
118
+ owner: marketplace.owner,
119
+ description,
120
+ plugins: [{
121
+ name: plugin.name,
122
+ source: {
123
+ source: "npm",
124
+ package: plugin.packageName,
125
+ version: marketplace.version
126
+ },
127
+ description
128
+ }]
129
+ });
130
+ }
131
+ return files;
132
+ }
133
+ /**
134
+ * Writes {@link claudePluginFiles} under `dir`, the plugin package's root, and
135
+ * returns the paths written. Run it from the package's build, and publish the
136
+ * written files with the build.
137
+ */
138
+ function writeClaudePlugin(dir, options) {
139
+ return Object.entries(claudePluginFiles(options)).map(([path, text]) => {
140
+ const file = join(dir, path);
141
+ mkdirSync(dirname(file), { recursive: true });
142
+ writeFileSync(file, text);
143
+ return file;
144
+ });
145
+ }
146
+ // src/hook.ts
147
+ const isObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
148
+ function stringField(input, key) {
149
+ const value = input[key];
150
+ if (value === void 0 || value === null) return "";
151
+ if (typeof value !== "string") throw new TypeError(`${key} is not a string`);
152
+ return value;
153
+ }
154
+ function record(value, key) {
155
+ if (value === void 0 || value === null) return {};
156
+ if (!isObject$1(value)) throw new TypeError(`${key} is not an object`);
157
+ return value;
158
+ }
159
+ function editList(input) {
160
+ const edits = input.edits ?? [];
161
+ if (!Array.isArray(edits)) throw new TypeError("edits is not a list");
162
+ return edits.map((edit) => record(edit, "edit"));
163
+ }
164
+ function toToolCall(tool, input) {
165
+ const text = (key, from = input) => stringField(from, key);
166
+ const joined = (key) => editList(input).map((edit) => text(key, edit)).join("\n");
167
+ switch (tool) {
168
+ case "Bash": return {
169
+ kind: "shell",
170
+ command: text("command")
171
+ };
172
+ case "Write": return {
173
+ kind: "write",
174
+ path: text("file_path"),
175
+ content: text("content")
176
+ };
177
+ case "NotebookEdit": return {
178
+ kind: "write",
179
+ path: text("notebook_path"),
180
+ content: text("new_source")
181
+ };
182
+ case "Edit": return {
183
+ kind: "edit",
184
+ path: text("file_path"),
185
+ before: text("old_string"),
186
+ after: text("new_string")
187
+ };
188
+ case "MultiEdit": return {
189
+ kind: "edit",
190
+ path: text("file_path"),
191
+ before: joined("old_string"),
192
+ after: joined("new_string")
193
+ };
194
+ case "apply_patch": return {
195
+ kind: "patch",
196
+ patch: text("command") || text("input") || text("patch")
197
+ };
198
+ default: throw new TypeError(`${tool} is not a tool the hook reads`);
199
+ }
200
+ }
201
+ function verdictOf(value) {
202
+ if (isObject$1(value) && value.block === false) return { block: false };
203
+ if (isObject$1(value) && value.block === true && typeof value.reason === "string" && value.reason.trim() !== "") return {
204
+ block: true,
205
+ reason: value.reason
206
+ };
207
+ throw new TypeError("check returned neither { block: false } nor { block: true, reason }");
208
+ }
209
+ /**
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.
212
+ *
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.
218
+ */
219
+ async function runHook(plugin, stdin, options = {}) {
220
+ const stderr = options.stderr ?? ((text) => void process.stderr.write(text));
221
+ let verdict;
222
+ try {
223
+ const payload = JSON.parse(stdin);
224
+ if (!isObject$1(payload)) throw new TypeError("payload is not an object");
225
+ const tool = payload.tool_name;
226
+ if (typeof tool !== "string" || tool === "") throw new TypeError("tool_name is not a tool name");
227
+ const kind = KIND_OF_TOOL.get(tool);
228
+ if (kind === void 0 || !plugin.kinds.includes(kind)) return 0;
229
+ const call = toToolCall(tool, record(payload.tool_input, "tool_input"));
230
+ const cwd = stringField(payload, "cwd") || (options.cwd ?? process.cwd());
231
+ verdict = verdictOf(await plugin.check(call, { cwd }));
232
+ } catch (error) {
233
+ stderr(`Blocked by ${plugin.name}, it could not check the call: ${error instanceof Error ? error.message : String(error)}\n`);
234
+ return 2;
235
+ }
236
+ if (!verdict.block) return 0;
237
+ stderr(verdict.reason.endsWith("\n") ? verdict.reason : `${verdict.reason}\n`);
238
+ return 2;
239
+ }
240
+ // src/install.ts
241
+ /** The agent config a scope writes: `~/.claude/settings.json`, `<cwd>/.codex/hooks.json` and so on. */
242
+ function configFile(agent, scope, place) {
243
+ const { dir, file } = AGENTS[agent];
244
+ return join(scope === "project" ? place.cwd : place.home, dir, file);
245
+ }
246
+ const quote = (word) => `'${word.replaceAll("'", `'\\''`)}'`;
247
+ const escape = (text) => text.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
248
+ 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`;
251
+ }
252
+ const QUOTED = String.raw`(?:[^']|'\\'')*`;
253
+ function ownHook(plugin, entry) {
254
+ const name = escape(plugin.name);
255
+ const pinned = String.raw`n='${QUOTED}'; \[ -x "\$n" \] \|\| n=node; "\$n" '${QUOTED}/\.${name}/current/[^/']+' hook`;
256
+ const bare = `node '${QUOTED}/${name}/(?:${QUOTED}/)?${escape(entry)}' hook`;
257
+ const project = `npx --no-install ${escape(plugin.packageName)} hook`;
258
+ return new RegExp(`^(?:${pinned}|${bare}|${project})(?: \\|\\| exit 2)?$`);
259
+ }
260
+ const builtFiles = (dir) => readdirSync(dir).filter((name) => name.endsWith(".mjs")).sort();
261
+ function buildId(dir) {
262
+ const hash = createHash("sha256");
263
+ for (const name of builtFiles(dir)) hash.update(name).update("\0").update(readFileSync(join(dir, name)));
264
+ return hash.digest("hex").slice(0, 12);
265
+ }
266
+ function copyBuild(plugin, script, root) {
267
+ if (!script.endsWith(".mjs") || !existsSync(script)) throw new Error(`${script} is not a built ${plugin.name} entry; run install from the published package or a build`);
268
+ const source = dirname(script);
269
+ const id = buildId(source);
270
+ const target = join(root, id);
271
+ if (!existsSync(target) || buildId(target) !== id) {
272
+ mkdirSync(root, { recursive: true });
273
+ const staging = mkdtempSync(`${target}-`);
274
+ try {
275
+ for (const name of builtFiles(source)) copyFileSync(join(source, name), join(staging, name));
276
+ if (existsSync(target) && buildId(target) !== id) renameSync(target, `${staging}-stale`);
277
+ if (!existsSync(target)) renameSync(staging, target);
278
+ } catch (error) {
279
+ if (!existsSync(target) || buildId(target) !== id) throw error;
280
+ } finally {
281
+ rmSync(staging, {
282
+ recursive: true,
283
+ force: true
284
+ });
285
+ rmSync(`${staging}-stale`, {
286
+ recursive: true,
287
+ force: true
288
+ });
289
+ }
290
+ }
291
+ return id;
292
+ }
293
+ function probe(file, node) {
294
+ const ran = spawnSync(node, [file, "hook"], {
295
+ input: "{\"tool_name\":\"Read\"}",
296
+ encoding: "utf8",
297
+ timeout: 1e4
298
+ });
299
+ if (ran.status === 0) return;
300
+ const why = ran.error?.message ?? ran.stderr.trim().split("\n").find((line) => /error/i.test(line)) ?? `exit ${ran.status ?? ran.signal}`;
301
+ throw new Error(`the copy of the build at ${file} does not run on its own (${why}); the bin's directory must hold the whole build as .mjs files, with its dependencies bundled in`);
302
+ }
303
+ function linkCurrent(root, id) {
304
+ const current = join(root, "current");
305
+ if (existsSync(current) && !lstatSync(current).isSymbolicLink()) throw new Error(`${current} is not the link install writes; move it away and install again`);
306
+ const link = `${current}.${process.pid}`;
307
+ try {
308
+ rmSync(link, { force: true });
309
+ symlinkSync(id, link);
310
+ renameSync(link, current);
311
+ } finally {
312
+ rmSync(link, { force: true });
313
+ }
314
+ return current;
315
+ }
316
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
317
+ function readSettings(file) {
318
+ if (!existsSync(file)) return {};
319
+ let settings;
320
+ try {
321
+ settings = JSON.parse(readFileSync(file, "utf8"));
322
+ } catch (error) {
323
+ throw new Error(`${file} is not valid JSON (${error.message}); it was left unchanged`);
324
+ }
325
+ const hooks = isObject(settings) ? settings.hooks : void 0;
326
+ const preToolUse = isObject(hooks) ? hooks.PreToolUse : void 0;
327
+ 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`);
330
+ return settings;
331
+ }
332
+ function writeConfig(file, text) {
333
+ const real = existsSync(file) ? realpathSync(file) : file;
334
+ mkdirSync(dirname(real), { recursive: true });
335
+ const temp = `${real}.${process.pid}.tmp`;
336
+ try {
337
+ writeFileSync(temp, text);
338
+ if (existsSync(real)) chmodSync(temp, statSync(real).mode & 4095);
339
+ renameSync(temp, real);
340
+ } finally {
341
+ rmSync(temp, { force: true });
342
+ }
343
+ }
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;
352
+ return {
353
+ ...item,
354
+ hooks: rest
355
+ };
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`);
365
+ }
366
+ /**
367
+ * Adds the plugin's PreToolUse hook to one agent's config, replacing any entry
368
+ * it wrote before and keeping every other one.
369
+ *
370
+ * The user scope copies the bin's directory to `~/.<name>/<build-id>/`, checks
371
+ * the copy runs, points `~/.<name>/current` at it and runs it from there on
372
+ * `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.
376
+ *
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.
380
+ */
381
+ function install(options) {
382
+ const { plugin, agent, scope, script } = options;
383
+ const matcher = requireMatcher(agent, plugin);
384
+ const file = configFile(agent, scope, options);
385
+ const settings = readSettings(file);
386
+ let target = script;
387
+ if (scope === "user") {
388
+ const root = join(options.home, `.${plugin.name}`);
389
+ const id = copyBuild(plugin, script, root);
390
+ probe(join(root, id, basename(script)), options.execPath);
391
+ target = join(linkCurrent(root, id), basename(script));
392
+ }
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
+ });
404
+ return {
405
+ file,
406
+ command
407
+ };
408
+ }
409
+ /**
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.
414
+ */
415
+ 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 };
420
+ }
421
+ // src/cli.ts
422
+ const isAgent = (word) => word === "claude" || word === "codex";
423
+ function usage(bin) {
424
+ const rows = [
425
+ ["install <claude|codex> [--project]", "add the PreToolUse hook"],
426
+ ["uninstall <claude|codex> [--project]", "remove it"],
427
+ ["hook", "run as the hook (reads the tool call on stdin)"]
428
+ ];
429
+ const width = Math.max(...rows.map(([command]) => command.length)) + 1;
430
+ return `Usage:\n${rows.map(([command, what]) => ` ${bin} ${command.padEnd(width)}${what}`).join("\n")}\n`;
431
+ }
432
+ /**
433
+ * Builds the plugin's command line: `install` and `uninstall <claude|codex>
434
+ * [--project]`, and `hook`, which reads the tool call from `io.stdin`.
435
+ *
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.
441
+ */
442
+ function createCli(plugin, options = {}) {
443
+ const bin = options.bin ?? plugin.name;
444
+ return async (argv, io) => {
445
+ const [command, agent, ...flags] = argv;
446
+ const major = Number(/^v(\d+)\./.exec(io.node)?.[1] ?? 0);
447
+ if ((command === "hook" || command === "install") && major < 22) {
448
+ 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;
450
+ }
451
+ if (command === "hook") return runHook(plugin, io.stdin(), {
452
+ cwd: io.cwd,
453
+ stderr: io.stderr
454
+ });
455
+ const project = flags.length === 1 && flags[0] === "--project";
456
+ if ((command === "install" || command === "uninstall") && isAgent(agent) && (flags.length === 0 || project)) {
457
+ const place = {
458
+ plugin,
459
+ agent,
460
+ scope: project ? "project" : "user",
461
+ script: io.script,
462
+ home: io.home,
463
+ cwd: io.cwd
464
+ };
465
+ let file;
466
+ try {
467
+ file = command === "install" ? install({
468
+ ...place,
469
+ execPath: io.execPath
470
+ }).file : uninstall(place).file;
471
+ } catch (error) {
472
+ io.stderr(`${bin}: ${error instanceof Error ? error.message : String(error)}\n`);
473
+ return 1;
474
+ }
475
+ io.stdout(`${command === "install" ? "Installed in" : "Removed from"} ${file}\n`);
476
+ if (command === "install" && agent === "codex") io.stdout("Codex runs a new hook only after you trust it: open /hooks in Codex once.\n");
477
+ return 0;
478
+ }
479
+ io.stderr(usage(bin));
480
+ return 1;
481
+ };
482
+ }
483
+ /**
484
+ * Reads a stream to its end as UTF-8, `process.stdin` by default. A
485
+ * synchronous read of fd 0 fails with EAGAIN on a non-blocking pipe once the
486
+ * payload outgrows its buffer, so the hook reads this way. A read that fails
487
+ * resolves to `''`, which {@link runHook} blocks.
488
+ */
489
+ async function readStdin(stream = process.stdin) {
490
+ const chunks = [];
491
+ try {
492
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
493
+ } catch {
494
+ return "";
495
+ }
496
+ return Buffer.concat(chunks).toString("utf8");
497
+ }
498
+ /**
499
+ * The whole bin: runs {@link createCli} on `process.argv`, reads stdin only for
500
+ * `hook`, and sets `process.exitCode`. Call it without `await`, passing the
501
+ * bin's own `import.meta.url`, which is what install copies and runs.
502
+ *
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.
506
+ */
507
+ async function runCli(plugin, entryUrl, options = {}) {
508
+ const argv = process.argv.slice(2);
509
+ const hook = argv[0] === "hook";
510
+ let done = false;
511
+ if (hook) {
512
+ process.exitCode = 2;
513
+ process.once("beforeExit", () => {
514
+ if (!done) process.stderr.write(`Blocked by ${plugin.name}, its check never finished\n`);
515
+ });
516
+ }
517
+ try {
518
+ const input = hook ? await readStdin() : "";
519
+ process.exitCode = await createCli(plugin, options)(argv, {
520
+ stdin: () => input,
521
+ script: fileURLToPath(entryUrl),
522
+ node: process.version,
523
+ execPath: process.execPath,
524
+ home: homedir(),
525
+ cwd: process.cwd(),
526
+ stdout: (text) => void process.stdout.write(text),
527
+ stderr: (text) => void process.stderr.write(text)
528
+ });
529
+ done = true;
530
+ } catch (error) {
531
+ done = true;
532
+ process.stderr.write(`${options.bin ?? plugin.name}: ${error instanceof Error ? error.message : String(error)}\n`);
533
+ process.exitCode = hook ? 2 : 1;
534
+ }
535
+ }
536
+ // src/define.ts
537
+ const KINDS = [
538
+ "shell",
539
+ "write",
540
+ "edit",
541
+ "patch"
542
+ ];
543
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
544
+ const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
545
+ /**
546
+ * Checks a hook definition and returns the plugin every other function takes.
547
+ * Throws a `TypeError` naming the field when the definition is malformed, so a
548
+ * broken plugin fails when it is built, not when the hook runs.
549
+ */
550
+ function defineHook(definition) {
551
+ const { name, kinds, check, packageName = name, statusMessage = `Running ${name}`, timeout = 10 } = definition;
552
+ 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
+ if (typeof packageName !== "string" || !PACKAGE.test(packageName)) throw new TypeError(`packageName ${JSON.stringify(packageName)} is not an npm package name`);
554
+ 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
+ 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`);
558
+ return Object.freeze({
559
+ name,
560
+ packageName,
561
+ kinds: Object.freeze([...new Set(kinds)]),
562
+ statusMessage,
563
+ timeout,
564
+ check
565
+ });
566
+ }
567
+ export { claudePluginFiles, configFile, createCli, defineHook, install, matcherFor, readStdin, runCli, runHook, uninstall, writeClaudePlugin };
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "agent-base-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "version": "0.1.1",
5
+ "packageManager": "pnpm@10.23.0",
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
+ "author": "devmaxxx",
8
+ "license": "MIT",
9
+ "homepage": "https://github.com/devmaxxx/agent-base-kit#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/devmaxxx/agent-base-kit.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/devmaxxx/agent-base-kit/issues"
16
+ },
17
+ "keywords": [
18
+ "ai-agent",
19
+ "claude-code",
20
+ "claude-code-plugin",
21
+ "codex",
22
+ "hook",
23
+ "pretooluse",
24
+ "guard",
25
+ "policy",
26
+ "security"
27
+ ],
28
+ "sideEffects": false,
29
+ "exports": {
30
+ ".": "./dist/index.mjs",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "types": "./dist/index.d.mts",
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ },
44
+ "scripts": {
45
+ "build": "tsdown",
46
+ "dev": "tsdown --watch",
47
+ "lint": "eslint .",
48
+ "typecheck": "tsc",
49
+ "test": "vitest run",
50
+ "prepublishOnly": "pnpm run build"
51
+ },
52
+ "devDependencies": {
53
+ "@antfu/eslint-config": "^9.5.1",
54
+ "@types/node": "^26.6.5",
55
+ "eslint": "^10.13.0",
56
+ "publint": "^0.3.25",
57
+ "tsdown": "^0.23.1",
58
+ "typescript": "^6.0.3",
59
+ "unrun": "^0.3.1",
60
+ "vitest": "^5.0.5"
61
+ }
62
+ }