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 +21 -0
- package/README.md +146 -2
- package/dist/index.d.mts +273 -0
- package/dist/index.mjs +567 -0
- package/package.json +60 -4
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
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/agent-base-kit)
|
|
10
|
+
[](https://www.npmjs.com/package/agent-base-kit)
|
|
11
|
+
[](https://github.com/devmaxxx/agent-base-kit/actions/workflows/ci.yml)
|
|
12
|
+
[](https://www.npmjs.com/package/agent-base-kit)
|
|
13
|
+
[](./package.json)
|
|
14
|
+
[](./package.json)
|
|
15
|
+
[](./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)
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|