agent-base-kit 0.0.0-stage → 0.1.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/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,260 @@
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
+ **Turn any check into a fail-closed PreToolUse hook for Claude Code and Codex. One function in, a verdict out. Zero dependencies.**
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-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/devmaxxx/agent-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
+ [Install](#install) · [Quick start](#quick-start) · [Kinds](#kinds-and-matchers) · [Fail-closed](#the-fail-closed-contract) · [Install layout](#what-install-writes) · [Claude Code plugin](#shipping-as-a-claude-code-plugin) · [API](#api)
18
+
19
+ </div>
20
+
21
+ <details>
22
+ <summary><b>Table of contents</b></summary>
23
+
24
+ - [Why](#why)
25
+ - [Features](#features)
26
+ - [Install](#install)
27
+ - [Quick start](#quick-start)
28
+ - [Why a devDependency](#why-a-devdependency)
29
+ - [Kinds and matchers](#kinds-and-matchers)
30
+ - [The fail-closed contract](#the-fail-closed-contract)
31
+ - [What install writes](#what-install-writes)
32
+ - [Shipping as a Claude Code plugin](#shipping-as-a-claude-code-plugin)
33
+ - [API](#api)
34
+ - [Design notes](#design-notes)
35
+ - [Contributing](#contributing)
36
+ - [License](#license)
37
+
38
+ </details>
39
+
40
+ ## Why
41
+
42
+ A [PreToolUse hook](https://code.claude.com/docs/en/hooks) is the last point where you can stop an agent's tool call before it runs. Writing one well is mostly not about the check. The agents treat every exit except 2 as "go on", so a hook that crashes, cannot parse its input, or loses its Node.js after an upgrade lets every call through without a word.
43
+
44
+ agent-base-kit is that plumbing, for any guard you build. You write the check; it makes sure the check actually runs, and blocks when it cannot.
45
+
46
+ ## Features
47
+
48
+ - **One function.** `check(call)` gets a typed call (`shell`, `write`, `edit` or `patch`) and returns `{ block, reason }`. Sync or async.
49
+ - **Fails closed.** Unreadable payloads, throws, bad verdicts, hangs and a hook that cannot start all block.
50
+ - **Both agents.** Claude Code and Codex, user or project scope, with matchers derived from the kinds you list.
51
+ - **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 does not silently switch it off.
52
+ - **Plays well with other hooks.** Install and uninstall touch only their own entry and refuse a config they cannot extend safely.
53
+ - **Claude Code plugin files.** One call writes the manifest, `hooks.json` and a marketplace.
54
+ - **Zero dependencies**, ESM only, typed.
55
+
56
+ ## Install
57
+
58
+ ```sh
59
+ pnpm add -D agent-base-kit
60
+ # or: npm i -D agent-base-kit · yarn add -D agent-base-kit · bun add -d agent-base-kit
61
+ ```
62
+
63
+ Requires Node.js 22 or later and a POSIX shell. It goes in `devDependencies`; [here is why](#why-a-devdependency).
64
+
65
+ ## Quick start
66
+
67
+ A toy check that stops force pushes, as a plugin in three files:
68
+
69
+ ```ts
70
+ // src/plugin.ts
71
+ import { defineHook } from 'agent-base-kit'
72
+
73
+ export const plugin = defineHook({
74
+ name: 'no-force-push',
75
+ kinds: ['shell'],
76
+ statusMessage: 'Checking for force pushes',
77
+ check: call => /\bgit\s+push\b.*\s(?:-f|--force)(?:\s|$)/.test(call.command)
78
+ ? { block: true, reason: 'Blocked by no-force-push: push without --force, or use --force-with-lease.' }
79
+ : { block: false },
80
+ })
81
+ ```
82
+
83
+ ```ts
84
+ #!/usr/bin/env node
85
+ // src/cli.ts
86
+ import { runCli } from 'agent-base-kit'
87
+ import { plugin } from './plugin'
88
+
89
+ void runCli(plugin, import.meta.url)
90
+ ```
91
+
92
+ ```jsonc
93
+ // package.json (the parts that matter)
94
+ {
95
+ "name": "no-force-push",
96
+ "type": "module",
97
+ "bin": { "no-force-push": "./dist/cli.mjs" },
98
+ "files": ["dist"],
99
+ "engines": { "node": ">=22" },
100
+ "devDependencies": {
101
+ "agent-base-kit": "^0.1.0",
102
+ "tsdown": "^0.23.1"
103
+ }
104
+ }
105
+ ```
106
+
107
+ Build `src/cli.ts` with tsdown. That gives you this CLI:
108
+
109
+ ```sh
110
+ npx no-force-push install claude # ~/.claude/settings.json
111
+ npx no-force-push install codex --project # ./.codex/hooks.json
112
+ npx no-force-push uninstall claude
113
+ npx no-force-push hook # what the agent runs, with the tool call on stdin
114
+ ```
115
+
116
+ ### Why a devDependency
117
+
118
+ A user-scope install copies the `.mjs` files next to your bin into `~/.<name>/<build-id>/` and runs them from there. A runtime dependency would be imported from `node_modules`, and the copy has none. List agent-base-kit under `devDependencies` instead, and tsdown bundles it into your build. It also inlines its types into your `.d.mts`, so `export type { ToolCall } from 'agent-base-kit'` works for your users. If the copy cannot start anyway, `install` refuses and says why. It runs the copy once before writing any config.
119
+
120
+ ## Kinds and matchers
121
+
122
+ `kinds` decides which calls reach your check. Each agent's matcher is derived from it.
123
+
124
+ | Kind | The call your check gets | Claude Code tools | Codex tools |
125
+ | --- | --- | --- | --- |
126
+ | `shell` | `{ kind: 'shell', command }` | `Bash` | `Bash` |
127
+ | `write` | `{ kind: 'write', path, content }` | `Write`, `NotebookEdit` | `apply_patch` |
128
+ | `edit` | `{ kind: 'edit', path, before, after }` (a MultiEdit's edits joined by newlines) | `Edit`, `MultiEdit` | `apply_patch` |
129
+ | `patch` | `{ kind: 'patch', patch }` (the whole patch text) | none | `apply_patch` |
130
+
131
+ `check` is typed to the kinds you list. It is never called with any other kind, even if a hand-written matcher sends one.
132
+
133
+ Codex sends every file change as one `apply_patch`. A plugin whose kinds include `write` or `edit` but not `patch` would match those calls and then never see them. So `install codex` refuses that plugin until you add `patch` and handle the patch text. A plugin with no tool in an agent, such as `['patch']` for Claude Code, is refused the same way.
134
+
135
+ ## The fail-closed contract
136
+
137
+ The agents treat exit 2 as "block" and every other exit as "go on". So the hook exits 0 only when your check passes the call:
138
+
139
+ | What happens | Exit |
140
+ | --- | --- |
141
+ | `check` returns `{ block: false }` | 0 |
142
+ | `check` returns `{ block: true, reason }` (the reason goes to stderr as is) | 2 |
143
+ | `check` throws or rejects | 2 |
144
+ | `check` returns anything else: `undefined`, `{ block: true }` without a reason, a string | 2 |
145
+ | `check` returns a promise that never settles | 2 |
146
+ | The payload is not JSON, not an object, names no tool, or has a field of the wrong shape (a `command` sent as an array, `edits` as an object) | 2 |
147
+ | The tool is not one of your kinds | 0, and `check` is not called |
148
+ | The hook cannot start: Node.js is missing, the build was deleted, a dependency was not bundled | 2, from `\|\| exit 2` |
149
+ | The hook runs on a Node.js older than 22 | 2, with a message naming the fix |
150
+ | The hook times out | Claude Code blocks it (`onFailure: "block"`). Codex lets the call run. |
151
+
152
+ `check` may be async. Keep it well inside `timeout`, which defaults to 10 seconds. Past the timeout Codex runs the call unchecked.
153
+
154
+ ## What install writes
155
+
156
+ The user scope is the default. Install copies the build to `~/.<name>/<build-id>/` and swaps the `~/.<name>/current` link onto it with a rename. Then it writes:
157
+
158
+ ```sh
159
+ n='/path/to/the/installing/node'; [ -x "$n" ] || n=node; "$n" '/home/you/.<name>/current/cli.mjs' hook || exit 2
160
+ ```
161
+
162
+ - The hook keeps running on the Node.js that installed it, so a project that pins an older Node does not change it.
163
+ - If that Node.js is removed, the hook falls back to the `node` on PATH.
164
+ - The copy outlives the package. An nvm upgrade or an `npx` cache clean does not remove it.
165
+ - The command stays the same across versions installed by the same Node.js, so Codex keeps trusting it.
166
+
167
+ `--project` writes `npx --no-install <packageName> hook || exit 2`, which is safe to commit.
168
+
169
+ Install replaces any entry it wrote before, including the older forms listed below, and keeps every other entry as it is. It refuses, and changes nothing, when:
170
+
171
+ - the settings file is not JSON, or not an object with a `hooks` object and a `PreToolUse` list (a `null` there counts as absent);
172
+ - `~/.<name>/current` exists and is not a link;
173
+ - the bin is not a built `.mjs` file;
174
+ - the copied build does not start.
175
+
176
+ Uninstall leaves the copy in place, since another agent may still run it.
177
+
178
+ In Claude Code the entry also sets `"onFailure": "block"`. That needs Claude Code 2.1.295 or later. Codex has no such field.
179
+
180
+ ## Shipping as a Claude Code plugin
181
+
182
+ A Claude Code plugin needs no install step and no copy, because Claude Code keeps its own copy of the plugin. Generate the plugin files from your build, for example in tsdown's `onSuccess`:
183
+
184
+ ```ts
185
+ // tsdown.config.ts
186
+ import { writeClaudePlugin } from 'agent-base-kit'
187
+ import { defineConfig } from 'tsdown'
188
+ import pkg from './package.json' with { type: 'json' }
189
+ import { plugin } from './src/plugin'
190
+
191
+ export default defineConfig({
192
+ entry: ['src/cli.ts'],
193
+ onSuccess() {
194
+ writeClaudePlugin(import.meta.dirname, {
195
+ plugin,
196
+ entry: 'dist/cli.mjs',
197
+ version: pkg.version,
198
+ description: 'Stops force pushes',
199
+ author: { name: 'you' },
200
+ license: 'MIT',
201
+ marketplace: { name: 'no-force-push', owner: { name: 'you' } },
202
+ })
203
+ },
204
+ })
205
+ ```
206
+
207
+ This writes three files:
208
+
209
+ - `.claude-plugin/plugin.json` holds the manifest.
210
+ - `hooks/hooks.json` holds the hook: `node "${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs" hook || exit 2`, with the matcher from `kinds`, your `timeout` and `statusMessage`, and `"onFailure": "block"`.
211
+ - `.claude-plugin/marketplace.json` is written only with `marketplace`. It is a one-plugin marketplace whose source is your npm package.
212
+
213
+ The build is not committed to git, and Claude Code copies a plugin as it is, without building it. So the marketplace fetches the plugin from npm, where `dist` is published. Add `.claude-plugin` and `hooks` to `files` in package.json, publish, and commit `.claude-plugin/marketplace.json` to the repository. Users then run:
214
+
215
+ ```text
216
+ /plugin marketplace add you/no-force-push
217
+ /plugin install no-force-push@no-force-push
218
+ ```
219
+
220
+ Use either the plugin or `install claude`, not both, or the check runs twice.
221
+
222
+ ## API
223
+
224
+ ```ts
225
+ function defineHook<const K extends ToolKind>(definition: HookDefinition<K>): HookPlugin
226
+ function runHook(plugin: HookPlugin, stdin: string, options?: { cwd?: string, stderr?: (text: string) => void }): Promise<number>
227
+ function createCli(plugin: HookPlugin, options?: { bin?: string }): (argv: readonly string[], io: CliIO) => Promise<number>
228
+ function runCli(plugin: HookPlugin, entryUrl: string, options?: { bin?: string }): Promise<void>
229
+ function readStdin(stream?: AsyncIterable<unknown>): Promise<string>
230
+ function install(options: InstallOptions): { file: string, command?: string }
231
+ function uninstall(options: UninstallOptions): { file: string }
232
+ function configFile(agent: Agent, scope: Scope, place: { home: string, cwd: string }): string
233
+ function matcherFor(agent: Agent, kinds: readonly ToolKind[]): string
234
+ function claudePluginFiles(options: ClaudePluginOptions): Record<string, string>
235
+ function writeClaudePlugin(dir: string, options: ClaudePluginOptions): string[]
236
+ ```
237
+
238
+ `install`, `uninstall` and `createCli` take the home directory, the working directory, the bin path and the Node.js path as arguments, and never read them from the process. `runCli` is the one function that does, which keeps tests away from your real `~`.
239
+
240
+ ## Design notes
241
+
242
+ - **Async checks.** A sync-only API would have to break later to allow I/O. A promise costs nothing now.
243
+ - **The reason is printed as is.** Your plugin owns its message, so start it with the plugin's name. Errors get a `Blocked by <name>, it could not check the call: …` prefix.
244
+ - **Which entries count as its own.** The `npx` form must match exactly. The pinned form matches any file under `~/.<name>/current/`, so a bin renamed between releases still replaces its old entry. The bare `node '<path>' hook` form from before the copy existed needs a directory named exactly after the plugin. So a hook that only mentions the plugin, or another plugin whose path merely contains this one's name, is never removed.
245
+ - **POSIX shells only.** Hook commands rely on `|| exit 2` and single-quoted paths, so Windows is not supported yet.
246
+
247
+ ## Contributing
248
+
249
+ Issues and pull requests are welcome.
250
+
251
+ ```sh
252
+ git clone https://github.com/devmaxxx/agent-kit.git
253
+ cd agent-kit
254
+ pnpm install
255
+ pnpm lint && pnpm typecheck && pnpm test
256
+ ```
257
+
258
+ ## License
259
+
260
+ [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.0",
5
+ "packageManager": "pnpm@10.23.0",
6
+ "description": "Turn any check into a fail-closed PreToolUse hook for Claude Code and Codex: tool-call parsing, install and uninstall, 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-kit#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/devmaxxx/agent-kit.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/devmaxxx/agent-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
+ }