agent-base-kit 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { homedir } from "node:os";
@@ -40,7 +40,8 @@ const SELECTED_BY = {
40
40
  "write",
41
41
  "edit",
42
42
  "patch"
43
- ]
43
+ ],
44
+ tool: []
44
45
  };
45
46
  const KIND_OF_TOOL = new Map(Object.values(TOOLS).flatMap((tools) => Object.entries(tools)));
46
47
  function selected(agent, kinds) {
@@ -56,15 +57,36 @@ function matcherFor(agent, kinds) {
56
57
  }
57
58
  function requireMatcher(agent, plugin) {
58
59
  const tools = selected(agent, plugin.kinds);
59
- if (tools.length === 0) throw new Error(`${plugin.name} checks ${plugin.kinds.join(", ")} calls, which ${AGENTS[agent].label} does not make`);
60
+ if (tools.length === 0 && plugin.tools.length === 0) throw new Error(`${plugin.name} checks ${plugin.kinds.join(", ")} calls, which ${AGENTS[agent].label} does not make`);
60
61
  const unseen = tools.filter(([, kind]) => !plugin.kinds.includes(kind)).map(([tool]) => tool);
61
62
  if (unseen.length > 0) throw new Error(`${AGENTS[agent].label} sends file changes as ${unseen.join(", ")}, which ${plugin.name} does not take; add 'patch' to its kinds`);
62
- return tools.map(([tool]) => tool).join("|");
63
+ return [...tools.map(([tool]) => tool), ...plugin.tools].join("|");
63
64
  }
65
+ const hookFlags = (event, agent) => `--event ${event} --agent ${agent}`;
64
66
  // src/claude-plugin.ts
65
67
  const MARKETPLACE_NAME = /^[a-z0-9][\w.-]*$/i;
66
68
  const RESERVED = /^(?:claude|anthropics?)(?:-|$)|^cc-plugin-|(?:claude|anthropic)s?-?official|official-?(?:claude|anthropic)/;
67
69
  const ENTRY = /^[\w-][\w.-]*(?:\/[\w-][\w.-]*)*\.mjs$/;
70
+ function pluginHooks(plugin, matcher, entry) {
71
+ const command = (event) => `node "\${CLAUDE_PLUGIN_ROOT}/${entry}" hook ${hookFlags(event, "claude")}`;
72
+ const hooks = { PreToolUse: [{
73
+ matcher,
74
+ hooks: [{
75
+ type: "command",
76
+ command: `${command("PreToolUse")} || exit 2`,
77
+ timeout: plugin.timeout,
78
+ statusMessage: plugin.statusMessage,
79
+ onFailure: "block"
80
+ }]
81
+ }] };
82
+ for (const [event, hook] of Object.entries(plugin.events)) hooks[event] = [{ hooks: [{
83
+ type: "command",
84
+ command: command(event),
85
+ timeout: hook.timeout,
86
+ statusMessage: hook.statusMessage
87
+ }] }];
88
+ return hooks;
89
+ }
68
90
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
69
91
  function entryOf(entry) {
70
92
  const relative = entry.replace(/^\.\//, "");
@@ -76,10 +98,12 @@ function entryOf(entry) {
76
98
  * to the package root: `.claude-plugin/plugin.json`, `hooks/hooks.json`, and
77
99
  * with `marketplace`, `.claude-plugin/marketplace.json`.
78
100
  *
79
- * The hook runs `node "${CLAUDE_PLUGIN_ROOT}/<entry>" hook || exit 2` with
80
- * `onFailure: "block"`, so a plugin that cannot start or times out blocks the
81
- * call. Claude Code copies the plugin and installs nothing into it, so the
82
- * entry's directory must hold the whole build. Throws when the plugin checks
101
+ * The PreToolUse hook runs `node "${CLAUDE_PLUGIN_ROOT}/<entry>" hook --event
102
+ * PreToolUse --agent claude || exit 2` with `onFailure: "block"`, so a plugin that cannot start or times out blocks
103
+ * the call; each of the plugin's other events runs the same command, naming
104
+ * its own event, without either, so it passes instead. Claude Code copies the plugin and installs
105
+ * nothing into it, and the plugin's `onInstall` does not run, so the entry's
106
+ * directory must hold the whole build. Throws when the plugin checks
83
107
  * no tool Claude Code has, or a field would make Claude Code refuse the
84
108
  * plugin.
85
109
  */
@@ -100,16 +124,7 @@ function claudePluginFiles(options) {
100
124
  license,
101
125
  keywords
102
126
  }),
103
- "hooks/hooks.json": json({ hooks: { PreToolUse: [{
104
- matcher,
105
- hooks: [{
106
- type: "command",
107
- command: `node "\${CLAUDE_PLUGIN_ROOT}/${entry}" hook || exit 2`,
108
- timeout: plugin.timeout,
109
- statusMessage: plugin.statusMessage,
110
- onFailure: "block"
111
- }]
112
- }] } })
127
+ "hooks/hooks.json": json({ hooks: pluginHooks(plugin, matcher, entry) })
113
128
  };
114
129
  if (marketplace) {
115
130
  if (!MARKETPLACE_NAME.test(marketplace.name)) throw new TypeError(`marketplace name ${JSON.stringify(marketplace.name)} may hold only letters, digits, '.', '_' and '-', and must start with a letter or digit`);
@@ -144,6 +159,9 @@ function writeClaudePlugin(dir, options) {
144
159
  });
145
160
  }
146
161
  // src/hook.ts
162
+ const OPEN_EVENTS = ["Stop", "UserPromptSubmit"];
163
+ const EVENTS$2 = ["PreToolUse", ...OPEN_EVENTS];
164
+ const isOpen = (event) => OPEN_EVENTS.includes(event);
147
165
  const isObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
148
166
  function stringField(input, key) {
149
167
  const value = input[key];
@@ -161,9 +179,30 @@ function editList(input) {
161
179
  if (!Array.isArray(edits)) throw new TypeError("edits is not a list");
162
180
  return edits.map((edit) => record(edit, "edit"));
163
181
  }
164
- function toToolCall(tool, input) {
182
+ function toToolCalls(plugin, tool, input) {
183
+ if (tool !== "MultiEdit") return [toToolCall(plugin, tool, input)];
184
+ const path = stringField(input, "file_path");
185
+ const edits = editList(input);
186
+ if (edits.length === 0) return [{
187
+ kind: "edit",
188
+ path,
189
+ before: "",
190
+ after: ""
191
+ }];
192
+ return edits.map((edit) => ({
193
+ kind: "edit",
194
+ path,
195
+ before: stringField(edit, "old_string"),
196
+ after: stringField(edit, "new_string")
197
+ }));
198
+ }
199
+ function toToolCall(plugin, tool, input) {
200
+ if (plugin.tools.includes(tool)) return {
201
+ kind: "tool",
202
+ name: tool,
203
+ input
204
+ };
165
205
  const text = (key, from = input) => stringField(from, key);
166
- const joined = (key) => editList(input).map((edit) => text(key, edit)).join("\n");
167
206
  switch (tool) {
168
207
  case "Bash": return {
169
208
  kind: "shell",
@@ -185,12 +224,6 @@ function toToolCall(tool, input) {
185
224
  before: text("old_string"),
186
225
  after: text("new_string")
187
226
  };
188
- case "MultiEdit": return {
189
- kind: "edit",
190
- path: text("file_path"),
191
- before: joined("old_string"),
192
- after: joined("new_string")
193
- };
194
227
  case "apply_patch": return {
195
228
  kind: "patch",
196
229
  patch: text("command") || text("input") || text("patch")
@@ -206,31 +239,105 @@ function verdictOf(value) {
206
239
  };
207
240
  throw new TypeError("check returned neither { block: false } nor { block: true, reason }");
208
241
  }
242
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
243
+ /**
244
+ * The event a hook payload is for when it names one the hook lets pass on a
245
+ * fault, else `undefined`. Read before the Node.js check and the hang guard,
246
+ * which must not block these either.
247
+ */
248
+ function openEventOf(stdin) {
249
+ try {
250
+ const event = JSON.parse(stdin)?.hook_event_name;
251
+ return OPEN_EVENTS.find((open) => open === event);
252
+ } catch {
253
+ return;
254
+ }
255
+ }
209
256
  /**
210
- * Runs one PreToolUse call through the plugin and returns the exit code the
211
- * agent reads: 2 blocks the call, 0 lets it run.
257
+ * Whether a fault lets the event pass: the named event when the command names
258
+ * one, else the event the payload names.
259
+ */
260
+ function passesOnFault(event, stdin) {
261
+ return event === void 0 ? openEventOf(stdin) !== void 0 : isOpen(event);
262
+ }
263
+ function payloadOf(stdin) {
264
+ const payload = JSON.parse(stdin);
265
+ if (!isObject$1(payload)) throw new TypeError("payload is not an object");
266
+ return payload;
267
+ }
268
+ async function runOpenEvent(plugin, event, read, options) {
269
+ if (!plugin.events[event]) return 0;
270
+ const stderr = options.stderr ?? ((text) => void process.stderr.write(text));
271
+ const stdout = options.stdout ?? ((text) => void process.stdout.write(text));
272
+ try {
273
+ const payload = read();
274
+ if (payload.hook_event_name !== void 0 && payload.hook_event_name !== event) throw new TypeError(`hook_event_name ${JSON.stringify(payload.hook_event_name)} is not ${event}`);
275
+ if (options.agent !== void 0 && options.agent !== "claude" && options.agent !== "codex") throw new TypeError(`agent ${JSON.stringify(options.agent)} is neither claude nor codex`);
276
+ if (event === "Stop" && payload.stop_hook_active === true) return 0;
277
+ const cwd = stringField(payload, "cwd") || (options.cwd ?? process.cwd());
278
+ const verdict = event === "Stop" ? verdictOf(await plugin.events.Stop.check({ reply: stringField(payload, "last_assistant_message") }, { cwd })) : verdictOf(await plugin.events.UserPromptSubmit.check({ prompt: stringField(payload, "prompt") }, { cwd }));
279
+ if (!verdict.block) return 0;
280
+ const codex = options.agent === void 0 ? payload.turn_id !== void 0 : options.agent === "codex";
281
+ const hidden = event === "UserPromptSubmit" && plugin.events.UserPromptSubmit.hidePrompt && !codex;
282
+ stdout(`${JSON.stringify({
283
+ decision: "block",
284
+ reason: verdict.reason,
285
+ ...hidden ? { hookSpecificOutput: {
286
+ hookEventName: "UserPromptSubmit",
287
+ suppressOriginalPrompt: true
288
+ } } : {}
289
+ })}\n`);
290
+ } catch (error) {
291
+ stderr(`${plugin.name} could not check the ${event === "Stop" ? "reply" : "prompt"}, so it let it pass: ${messageOf(error)}\n`);
292
+ }
293
+ return 0;
294
+ }
295
+ /**
296
+ * Runs one hook payload through the plugin and returns the exit code the
297
+ * agent reads.
298
+ *
299
+ * A PreToolUse call returns 2 to block it and 0 to let it run, and fails
300
+ * closed: a payload that is not JSON or names no tool, a field of the wrong
301
+ * shape, a check that throws or rejects, and a result that is not a verdict
302
+ * all return 2 with the error on stderr, because any other exit lets the call
303
+ * run unchecked. A tool the plugin did not ask for returns 0 without calling
304
+ * the check. A MultiEdit calls the check once per edit and blocks on the first
305
+ * edit it blocks. A payload with no `hook_event_name` is read as PreToolUse;
306
+ * one naming any event other than PreToolUse, Stop or UserPromptSubmit, or
307
+ * a `hook_event_name` that is not a string, returns 2.
212
308
  *
213
- * It fails closed. A payload that is not JSON or names no tool, a field of the
214
- * wrong shape, a check that throws or rejects, and a result that is not a
215
- * verdict all return 2 with the error on stderr, because any other exit lets
216
- * the call run unchecked. A tool the plugin did not ask for returns 0 without
217
- * calling the check.
309
+ * Stop and UserPromptSubmit always return 0. A block goes to stdout as
310
+ * `{"decision":"block","reason":…}`, and a fault to stderr, letting the event
311
+ * pass. A Stop with `stop_hook_active` set passes unchecked, and so does an
312
+ * event the plugin has no check for.
313
+ *
314
+ * With `options.event`, the event is the one named, not the payload's: a
315
+ * Stop or UserPromptSubmit returns 0 on any fault, a payload that is not JSON
316
+ * included, and a payload naming another event is a fault for that event.
218
317
  */
219
318
  async function runHook(plugin, stdin, options = {}) {
220
319
  const stderr = options.stderr ?? ((text) => void process.stderr.write(text));
320
+ if (isOpen(options.event)) return runOpenEvent(plugin, options.event, () => payloadOf(stdin), options);
221
321
  let verdict;
222
322
  try {
223
- const payload = JSON.parse(stdin);
224
- if (!isObject$1(payload)) throw new TypeError("payload is not an object");
323
+ if (options.event !== void 0 && !EVENTS$2.includes(options.event)) throw new TypeError(`event ${JSON.stringify(options.event)} is not an event the hook runs`);
324
+ const payload = payloadOf(stdin);
325
+ const event = payload.hook_event_name;
326
+ if (options.event === void 0 && isOpen(event)) return runOpenEvent(plugin, event, () => payload, options);
327
+ if (event !== void 0 && event !== "PreToolUse") throw new TypeError(`hook_event_name ${JSON.stringify(event)} is not ${options.event ?? "an event the hook runs"}`);
225
328
  const tool = payload.tool_name;
226
329
  if (typeof tool !== "string" || tool === "") throw new TypeError("tool_name is not a tool name");
227
- const kind = KIND_OF_TOOL.get(tool);
330
+ const kind = plugin.tools.includes(tool) ? "tool" : KIND_OF_TOOL.get(tool);
228
331
  if (kind === void 0 || !plugin.kinds.includes(kind)) return 0;
229
- const call = toToolCall(tool, record(payload.tool_input, "tool_input"));
332
+ const calls = toToolCalls(plugin, tool, record(payload.tool_input, "tool_input"));
230
333
  const cwd = stringField(payload, "cwd") || (options.cwd ?? process.cwd());
231
- verdict = verdictOf(await plugin.check(call, { cwd }));
334
+ verdict = { block: false };
335
+ for (const call of calls) {
336
+ verdict = verdictOf(await plugin.check(call, { cwd }));
337
+ if (verdict.block) break;
338
+ }
232
339
  } catch (error) {
233
- stderr(`Blocked by ${plugin.name}, it could not check the call: ${error instanceof Error ? error.message : String(error)}\n`);
340
+ stderr(`Blocked by ${plugin.name}, it could not check the call: ${messageOf(error)}\n`);
234
341
  return 2;
235
342
  }
236
343
  if (!verdict.block) return 0;
@@ -238,6 +345,11 @@ async function runHook(plugin, stdin, options = {}) {
238
345
  return 2;
239
346
  }
240
347
  // src/install.ts
348
+ const EVENTS$1 = [
349
+ "PreToolUse",
350
+ "Stop",
351
+ "UserPromptSubmit"
352
+ ];
241
353
  /** The agent config a scope writes: `~/.claude/settings.json`, `<cwd>/.codex/hooks.json` and so on. */
242
354
  function configFile(agent, scope, place) {
243
355
  const { dir, file } = AGENTS[agent];
@@ -246,8 +358,31 @@ function configFile(agent, scope, place) {
246
358
  const quote = (word) => `'${word.replaceAll("'", `'\\''`)}'`;
247
359
  const escape = (text) => text.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
248
360
  function hookCommand(plugin, scope, script, node) {
249
- if (scope === "project") return `npx --no-install ${plugin.packageName} hook || exit 2`;
250
- return `n=${quote(node)}; [ -x "$n" ] || n=node; "$n" ${quote(script)} hook || exit 2`;
361
+ if (scope === "project") return `npx --no-install ${plugin.packageName} hook`;
362
+ return `n=${quote(node)}; [ -x "$n" ] || n=node; "$n" ${quote(script)} hook`;
363
+ }
364
+ function entriesFor(plugin, agent, command) {
365
+ const entry = (event, status, timeout) => {
366
+ const run = `${command} ${hookFlags(event, agent)}`;
367
+ return {
368
+ type: "command",
369
+ command: event === "PreToolUse" ? `${run} || exit 2` : run,
370
+ timeout,
371
+ statusMessage: status
372
+ };
373
+ };
374
+ const { Stop, UserPromptSubmit } = plugin.events;
375
+ return {
376
+ PreToolUse: {
377
+ matcher: requireMatcher(agent, plugin),
378
+ hooks: [{
379
+ ...entry("PreToolUse", plugin.statusMessage, plugin.timeout),
380
+ ...agent === "claude" ? { onFailure: "block" } : {}
381
+ }]
382
+ },
383
+ ...Stop ? { Stop: { hooks: [entry("Stop", Stop.statusMessage, Stop.timeout)] } } : {},
384
+ ...UserPromptSubmit ? { UserPromptSubmit: { hooks: [entry("UserPromptSubmit", UserPromptSubmit.statusMessage, UserPromptSubmit.timeout)] } } : {}
385
+ };
251
386
  }
252
387
  const QUOTED = String.raw`(?:[^']|'\\'')*`;
253
388
  function ownHook(plugin, entry) {
@@ -255,7 +390,10 @@ function ownHook(plugin, entry) {
255
390
  const pinned = String.raw`n='${QUOTED}'; \[ -x "\$n" \] \|\| n=node; "\$n" '${QUOTED}/\.${name}/current/[^/']+' hook`;
256
391
  const bare = `node '${QUOTED}/${name}/(?:${QUOTED}/)?${escape(entry)}' hook`;
257
392
  const project = `npx --no-install ${escape(plugin.packageName)} hook`;
258
- return new RegExp(`^(?:${pinned}|${bare}|${project})(?: \\|\\| exit 2)?$`);
393
+ const flags = String.raw`(?: --event (?:PreToolUse|Stop|UserPromptSubmit))?(?: --agent (?:claude|codex))?`;
394
+ const own = new RegExp(`^(?:${pinned}|${bare}|${project})${flags}(?: \\|\\| exit 2)?$`);
395
+ const earlier = plugin.ownCommands.map((pattern) => new RegExp(`^(?:${pattern.source})$`, pattern.flags));
396
+ return (command) => own.test(command) || earlier.some((pattern) => pattern.test(command));
259
397
  }
260
398
  const builtFiles = (dir) => readdirSync(dir).filter((name) => name.endsWith(".mjs")).sort();
261
399
  function buildId(dir) {
@@ -313,6 +451,22 @@ function linkCurrent(root, id) {
313
451
  }
314
452
  return current;
315
453
  }
454
+ function restoreCurrent(root, id) {
455
+ const current = join(root, "current");
456
+ const target = () => {
457
+ try {
458
+ return readlinkSync(current);
459
+ } catch {
460
+ return null;
461
+ }
462
+ };
463
+ const previous = target();
464
+ return () => {
465
+ if (target() !== id) return;
466
+ if (previous === null) rmSync(current, { force: true });
467
+ else linkCurrent(root, previous);
468
+ };
469
+ }
316
470
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
317
471
  function readSettings(file) {
318
472
  if (!existsSync(file)) return {};
@@ -323,135 +477,237 @@ function readSettings(file) {
323
477
  throw new Error(`${file} is not valid JSON (${error.message}); it was left unchanged`);
324
478
  }
325
479
  const hooks = isObject(settings) ? settings.hooks : void 0;
326
- const preToolUse = isObject(hooks) ? hooks.PreToolUse : void 0;
327
480
  const wrongHooks = hooks !== void 0 && hooks !== null && !isObject(hooks);
328
- const wrongPreToolUse = preToolUse !== void 0 && preToolUse !== null && !Array.isArray(preToolUse);
329
- if (!isObject(settings) || wrongHooks || wrongPreToolUse) throw new Error(`${file} does not hold a settings object with a hooks object and a PreToolUse list; it was left unchanged`);
481
+ const wrongList = EVENTS$1.find((event) => {
482
+ const list = isObject(hooks) ? hooks[event] : void 0;
483
+ return list !== void 0 && list !== null && !Array.isArray(list);
484
+ });
485
+ if (!isObject(settings) || wrongHooks) throw new Error(`${file} does not hold a settings object with a hooks object; it was left unchanged`);
486
+ if (wrongList) throw new Error(`${file} has a ${wrongList} that is not a list; it was left unchanged`);
330
487
  return settings;
331
488
  }
332
489
  function writeConfig(file, text) {
333
490
  const real = existsSync(file) ? realpathSync(file) : file;
334
491
  mkdirSync(dirname(real), { recursive: true });
335
492
  const temp = `${real}.${process.pid}.tmp`;
493
+ const mode = existsSync(real) ? statSync(real).mode & 4095 : void 0;
336
494
  try {
337
- writeFileSync(temp, text);
338
- if (existsSync(real)) chmodSync(temp, statSync(real).mode & 4095);
495
+ rmSync(temp, { force: true });
496
+ writeFileSync(temp, text, {
497
+ mode: mode ?? 438,
498
+ flag: "wx"
499
+ });
500
+ if (mode !== void 0) chmodSync(temp, mode);
339
501
  renameSync(temp, real);
340
502
  } finally {
341
503
  rmSync(temp, { force: true });
342
504
  }
343
505
  }
344
- function configure(file, settings, own, entry) {
345
- const hooks = settings.hooks ?? {};
346
- const before = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
347
- let removed = 0;
348
- const kept = before.map((item) => {
349
- if (!Array.isArray(item?.hooks)) return item;
350
- const rest = item.hooks.filter((hook) => !(typeof hook?.command === "string" && own.test(hook.command)));
351
- removed += item.hooks.length - rest.length;
506
+ function configure(file, settings, own, entries) {
507
+ const hooks = { ...settings.hooks };
508
+ let changed = false;
509
+ for (const event of EVENTS$1) {
510
+ const kept = (Array.isArray(hooks[event]) ? hooks[event] : []).map((item) => {
511
+ if (!Array.isArray(item?.hooks)) return item;
512
+ const rest = item.hooks.filter((hook) => !(typeof hook?.command === "string" && own(hook.command)));
513
+ changed ||= rest.length < item.hooks.length;
514
+ return {
515
+ ...item,
516
+ hooks: rest
517
+ };
518
+ }).filter((item) => !Array.isArray(item?.hooks) || item.hooks.length > 0);
519
+ const entry = entries[event];
520
+ if (entry) {
521
+ kept.push(entry);
522
+ changed = true;
523
+ }
524
+ if (kept.length > 0) hooks[event] = kept;
525
+ else if (Array.isArray(hooks[event])) delete hooks[event];
526
+ }
527
+ if (!changed) return;
528
+ settings.hooks = hooks;
529
+ writeConfig(file, `${JSON.stringify(settings, null, 2)}\n`);
530
+ }
531
+ function linesOf(value, field) {
532
+ if (value === void 0) return [];
533
+ if (!Array.isArray(value) || value.some((line) => typeof line !== "string")) throw new TypeError(`${field} is not a list of strings`);
534
+ return [...value];
535
+ }
536
+ function withExtra(file, place, write, extra) {
537
+ const before = existsSync(file) ? readFileSync(file, "utf8") : null;
538
+ write();
539
+ if (!extra) return {
540
+ notes: [],
541
+ warnings: []
542
+ };
543
+ try {
544
+ const result = extra({
545
+ ...place,
546
+ file
547
+ });
548
+ if (result !== void 0 && !isObject(result)) throw new TypeError("an install step returned neither nothing nor { notes, warnings }");
352
549
  return {
353
- ...item,
354
- hooks: rest
550
+ notes: linesOf(result?.notes, "notes"),
551
+ warnings: linesOf(result?.warnings, "warnings")
355
552
  };
356
- }).filter((item) => !Array.isArray(item?.hooks) || item.hooks.length > 0);
357
- if (!entry && removed === 0) return;
358
- if (entry) kept.push(entry);
359
- settings.hooks = {
360
- ...hooks,
361
- PreToolUse: kept
362
- };
363
- if (kept.length === 0) delete settings.hooks.PreToolUse;
364
- writeConfig(file, `${JSON.stringify(settings, null, 2)}\n`);
553
+ } catch (error) {
554
+ if (before === null) rmSync(file, { force: true });
555
+ else if (readFileSync(file, "utf8") !== before) writeConfig(file, before);
556
+ throw error;
557
+ }
365
558
  }
366
559
  /**
367
- * Adds the plugin's PreToolUse hook to one agent's config, replacing any entry
368
- * it wrote before and keeping every other one.
560
+ * Adds the plugin's hooks to one agent's config, PreToolUse and each event in
561
+ * its `events`, replacing any entry it wrote before, for any event, and
562
+ * keeping every other one.
369
563
  *
370
564
  * The user scope copies the bin's directory to `~/.<name>/<build-id>/`, checks
371
565
  * the copy runs, points `~/.<name>/current` at it and runs it from there on
372
566
  * `execPath`. The project scope runs `npx --no-install <packageName> hook`.
373
- * Either command ends in `|| exit 2`, so a hook that cannot start blocks the
374
- * call. The Claude Code entry also sets `onFailure: "block"` for a hook that
375
- * times out.
567
+ * Each command names its event and agent, `hook --event <event> --agent
568
+ * <agent>`, and the PreToolUse one ends in `|| exit 2`, so a hook that cannot start
569
+ * blocks the call, and the Claude Code entry sets `onFailure: "block"` for one
570
+ * that times out. The other events' commands do not, so they pass instead.
571
+ * Then the plugin's `onInstall` runs.
376
572
  *
377
- * Throws, and leaves the config as it was, when the config is not a settings
378
- * object it can extend, when the plugin's kinds leave tools the agent routes
379
- * to it unchecked, or when the build cannot be copied or does not run.
573
+ * Throws, and leaves the config and `current` as they were, when the config
574
+ * is not a settings object it can extend, when the plugin's kinds leave tools
575
+ * the agent routes to it unchecked, when the build cannot be copied or does
576
+ * not run, or when `onInstall` throws.
380
577
  */
381
578
  function install(options) {
382
579
  const { plugin, agent, scope, script } = options;
383
- const matcher = requireMatcher(agent, plugin);
580
+ requireMatcher(agent, plugin);
384
581
  const file = configFile(agent, scope, options);
385
582
  const settings = readSettings(file);
386
583
  let target = script;
584
+ let restore = () => {};
387
585
  if (scope === "user") {
388
586
  const root = join(options.home, `.${plugin.name}`);
389
587
  const id = copyBuild(plugin, script, root);
390
588
  probe(join(root, id, basename(script)), options.execPath);
589
+ restore = restoreCurrent(root, id);
391
590
  target = join(linkCurrent(root, id), basename(script));
392
591
  }
393
- const command = hookCommand(plugin, scope, target, options.execPath);
394
- configure(file, settings, ownHook(plugin, basename(script)), {
395
- matcher,
396
- hooks: [{
397
- type: "command",
398
- command,
399
- timeout: plugin.timeout,
400
- statusMessage: plugin.statusMessage,
401
- ...agent === "claude" ? { onFailure: "block" } : {}
402
- }]
403
- });
592
+ const entries = entriesFor(plugin, agent, hookCommand(plugin, scope, target, options.execPath));
593
+ const place = {
594
+ agent,
595
+ scope,
596
+ home: options.home,
597
+ cwd: options.cwd,
598
+ script
599
+ };
600
+ let lines;
601
+ try {
602
+ lines = withExtra(file, place, () => configure(file, settings, ownHook(plugin, basename(script)), entries), plugin.onInstall);
603
+ } catch (error) {
604
+ try {
605
+ restore();
606
+ } catch {}
607
+ throw error;
608
+ }
609
+ const commands = Object.fromEntries(Object.entries(entries).map(([event, entry]) => [event, entry.hooks[0].command]));
404
610
  return {
405
611
  file,
406
- command
612
+ command: commands.PreToolUse,
613
+ commands,
614
+ ...lines
407
615
  };
408
616
  }
409
617
  /**
410
- * Removes the plugin's hook from one agent's config, whichever form an earlier
411
- * install wrote, and keeps every other entry. The stable copy stays: another
412
- * agent may still run it. Throws, and leaves the file as it was, when it is not
413
- * a settings object.
618
+ * Removes the plugin's hooks from one agent's config, for every event and
619
+ * whichever form an earlier install wrote, keeps every other entry, and runs
620
+ * the plugin's `onUninstall`. The stable copy stays: another agent may still
621
+ * run it. Throws, and leaves the file as it was, when it is not a settings
622
+ * object or `onUninstall` throws.
414
623
  */
415
624
  function uninstall(options) {
416
- const file = configFile(options.agent, options.scope, options);
417
- if (!existsSync(file)) return { file };
418
- configure(file, readSettings(file), ownHook(options.plugin, basename(options.script)), null);
419
- return { file };
625
+ const { plugin, agent, scope, script } = options;
626
+ const file = configFile(agent, scope, options);
627
+ return {
628
+ file,
629
+ ...withExtra(file, {
630
+ agent,
631
+ scope,
632
+ home: options.home,
633
+ cwd: options.cwd,
634
+ script
635
+ }, () => {
636
+ if (existsSync(file)) configure(file, readSettings(file), ownHook(plugin, basename(script)), {});
637
+ }, plugin.onUninstall)
638
+ };
420
639
  }
421
640
  // src/cli.ts
422
641
  const isAgent = (word) => word === "claude" || word === "codex";
423
- function usage(bin) {
642
+ const isEvent = (word) => word === "PreToolUse" || word === "Stop" || word === "UserPromptSubmit";
643
+ function hookArgs(args) {
644
+ const parsed = {};
645
+ for (let i = 0; i < args.length; i += 2) {
646
+ const [flag, value] = [args[i], args[i + 1]];
647
+ if (flag === "--event" && isEvent(value) && parsed.event === void 0) parsed.event = value;
648
+ else if (flag === "--agent" && isAgent(value) && parsed.agent === void 0) parsed.agent = value;
649
+ else {
650
+ const named = args.find((word, at) => args[at - 1] === "--event" && isEvent(word));
651
+ return {
652
+ ...parsed,
653
+ event: parsed.event ?? (isEvent(named) ? named : void 0),
654
+ error: `${[flag, value].filter((word) => word !== void 0).join(" ")} is not an argument hook takes`
655
+ };
656
+ }
657
+ }
658
+ return parsed;
659
+ }
660
+ function usage(plugin, bin) {
661
+ const events = ["PreToolUse", ...Object.keys(plugin.events)];
424
662
  const rows = [
425
- ["install <claude|codex> [--project]", "add the PreToolUse hook"],
663
+ ["install <claude|codex> [--project]", `add the ${events.join(" and ")} hook${events.length > 1 ? "s" : ""}`],
426
664
  ["uninstall <claude|codex> [--project]", "remove it"],
427
- ["hook", "run as the hook (reads the tool call on stdin)"]
665
+ ["hook [--event <event>] [--agent <claude|codex>]", "run as the hook (reads the agent's payload on stdin)"]
428
666
  ];
429
667
  const width = Math.max(...rows.map(([command]) => command.length)) + 1;
430
668
  return `Usage:\n${rows.map(([command, what]) => ` ${bin} ${command.padEnd(width)}${what}`).join("\n")}\n`;
431
669
  }
432
670
  /**
433
671
  * Builds the plugin's command line: `install` and `uninstall <claude|codex>
434
- * [--project]`, and `hook`, which reads the tool call from `io.stdin`.
672
+ * [--project]`, and `hook [--event <event>] [--agent <claude|codex>]`, which
673
+ * reads the payload from `io.stdin` and hands the flags to {@link runHook}.
435
674
  *
436
- * Exit codes: `hook` returns what {@link runHook} returns, 0 or 2. `install`
437
- * and `uninstall` return 0, or 1 with the reason on stderr. Anything else,
438
- * including an unknown flag or an extra argument, prints the usage and
439
- * returns 1. On a Node.js older than 22, `hook` returns 2
440
- * and `install` 1, each with a message naming the fix; `uninstall` still runs.
675
+ * Exit codes: `hook` returns what {@link runHook} returns, 0 or 2; an
676
+ * argument it does not take is a fault, 0 at Stop or UserPromptSubmit and 2
677
+ * otherwise. `install`
678
+ * and `uninstall` return 0, or 1 with the reason on stderr; the notes the
679
+ * plugin's `onInstall` or `onUninstall` returns follow the config file on
680
+ * stdout, its warnings go to stderr. Anything else, including an unknown flag
681
+ * or an extra argument, prints the usage and returns 1. On a Node.js older
682
+ * than 22, `hook` returns 2 for PreToolUse and 0 for Stop and
683
+ * UserPromptSubmit, and `install` 1, each with a message naming the fix;
684
+ * `uninstall` still runs.
441
685
  */
442
686
  function createCli(plugin, options = {}) {
443
687
  const bin = options.bin ?? plugin.name;
444
688
  return async (argv, io) => {
445
689
  const [command, agent, ...flags] = argv;
690
+ const args = command === "hook" ? hookArgs(argv.slice(1)) : {};
446
691
  const major = Number(/^v(\d+)\./.exec(io.node)?.[1] ?? 0);
447
692
  if ((command === "hook" || command === "install") && major < 22) {
448
693
  io.stderr(`${bin} needs Node.js 22 or later, and this one is ${io.node}. Run \`npx ${plugin.packageName} install claude\` (or codex) again with Node.js 22 or later, or put one first on PATH and restart the agent.\n`);
449
- return command === "hook" ? 2 : 1;
694
+ if (command === "install") return 1;
695
+ return passesOnFault(args.event, io.stdin()) ? 0 : 2;
696
+ }
697
+ if (command === "hook") {
698
+ if (args.error) {
699
+ const open = passesOnFault(args.event, io.stdin());
700
+ io.stderr(open ? `${plugin.name} let it pass: ${args.error}\n` : `Blocked by ${plugin.name}: ${args.error}\n`);
701
+ return open ? 0 : 2;
702
+ }
703
+ return runHook(plugin, io.stdin(), {
704
+ cwd: io.cwd,
705
+ stderr: io.stderr,
706
+ stdout: io.stdout,
707
+ event: args.event,
708
+ agent: args.agent
709
+ });
450
710
  }
451
- if (command === "hook") return runHook(plugin, io.stdin(), {
452
- cwd: io.cwd,
453
- stderr: io.stderr
454
- });
455
711
  const project = flags.length === 1 && flags[0] === "--project";
456
712
  if ((command === "install" || command === "uninstall") && isAgent(agent) && (flags.length === 0 || project)) {
457
713
  const place = {
@@ -462,21 +718,23 @@ function createCli(plugin, options = {}) {
462
718
  home: io.home,
463
719
  cwd: io.cwd
464
720
  };
465
- let file;
721
+ let result;
466
722
  try {
467
- file = command === "install" ? install({
723
+ result = command === "install" ? install({
468
724
  ...place,
469
725
  execPath: io.execPath
470
- }).file : uninstall(place).file;
726
+ }) : uninstall(place);
471
727
  } catch (error) {
472
728
  io.stderr(`${bin}: ${error instanceof Error ? error.message : String(error)}\n`);
473
729
  return 1;
474
730
  }
475
- io.stdout(`${command === "install" ? "Installed in" : "Removed from"} ${file}\n`);
731
+ io.stdout(`${command === "install" ? "Installed in" : "Removed from"} ${result.file}\n`);
732
+ for (const note of result.notes) io.stdout(`${note}\n`);
733
+ for (const warning of result.warnings) io.stderr(`${bin}: ${warning}\n`);
476
734
  if (command === "install" && agent === "codex") io.stdout("Codex runs a new hook only after you trust it: open /hooks in Codex once.\n");
477
735
  return 0;
478
736
  }
479
- io.stderr(usage(bin));
737
+ io.stderr(usage(plugin, bin));
480
738
  return 1;
481
739
  };
482
740
  }
@@ -500,22 +758,29 @@ async function readStdin(stream = process.stdin) {
500
758
  * `hook`, and sets `process.exitCode`. Call it without `await`, passing the
501
759
  * bin's own `import.meta.url`, which is what install copies and runs.
502
760
  *
503
- * For `hook` the exit code is 2 until a verdict is in. Node.js exits 0 once
504
- * nothing is left to run, so a check whose promise never settles would
505
- * otherwise let the call run.
761
+ * For a PreToolUse `hook` the exit code is 2 until a verdict is in. Node.js
762
+ * exits 0 once nothing is left to run, so a check whose promise never settles
763
+ * would otherwise let the call run. A Stop or UserPromptSubmit check that
764
+ * never settles exits 0, as any other fault there does.
506
765
  */
507
766
  async function runCli(plugin, entryUrl, options = {}) {
508
767
  const argv = process.argv.slice(2);
509
768
  const hook = argv[0] === "hook";
769
+ const { event } = hook ? hookArgs(argv.slice(1)) : {};
510
770
  let done = false;
771
+ let open = false;
511
772
  if (hook) {
512
773
  process.exitCode = 2;
513
774
  process.once("beforeExit", () => {
514
- if (!done) process.stderr.write(`Blocked by ${plugin.name}, its check never finished\n`);
775
+ if (!done) process.stderr.write(open ? `${plugin.name} let it pass, its check never finished\n` : `Blocked by ${plugin.name}, its check never finished\n`);
515
776
  });
516
777
  }
517
778
  try {
518
779
  const input = hook ? await readStdin() : "";
780
+ if (hook && passesOnFault(event, input)) {
781
+ open = true;
782
+ process.exitCode = 0;
783
+ }
519
784
  process.exitCode = await createCli(plugin, options)(argv, {
520
785
  stdin: () => input,
521
786
  script: fileURLToPath(entryUrl),
@@ -530,7 +795,7 @@ async function runCli(plugin, entryUrl, options = {}) {
530
795
  } catch (error) {
531
796
  done = true;
532
797
  process.stderr.write(`${options.bin ?? plugin.name}: ${error instanceof Error ? error.message : String(error)}\n`);
533
- process.exitCode = hook ? 2 : 1;
798
+ process.exitCode = hook ? open ? 0 : 2 : 1;
534
799
  }
535
800
  }
536
801
  // src/define.ts
@@ -538,8 +803,11 @@ const KINDS = [
538
803
  "shell",
539
804
  "write",
540
805
  "edit",
541
- "patch"
806
+ "patch",
807
+ "tool"
542
808
  ];
809
+ const TOOL_NAME = /^[\w-]+$/;
810
+ const EVENTS = ["Stop", "UserPromptSubmit"];
543
811
  const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
544
812
  const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
545
813
  /**
@@ -548,20 +816,65 @@ const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
548
816
  * broken plugin fails when it is built, not when the hook runs.
549
817
  */
550
818
  function defineHook(definition) {
551
- const { name, kinds, check, packageName = name, statusMessage = `Running ${name}`, timeout = 10 } = definition;
819
+ const { name, kinds, check, packageName = name, statusMessage = `Running ${name}`, timeout = 10, ownCommands = [], tools = [], events = {}, onInstall, onUninstall } = definition;
552
820
  if (typeof name !== "string" || !NAME.test(name)) throw new TypeError(`name ${JSON.stringify(name)} is not kebab-case (lowercase letters and digits, joined by single hyphens)`);
553
821
  if (typeof packageName !== "string" || !PACKAGE.test(packageName)) throw new TypeError(`packageName ${JSON.stringify(packageName)} is not an npm package name`);
554
822
  if (!Array.isArray(kinds) || kinds.length === 0 || kinds.some((kind) => !KINDS.includes(kind))) throw new TypeError(`kinds must list one or more of ${KINDS.join(", ")}`);
555
823
  if (typeof check !== "function") throw new TypeError("check is not a function");
556
- if (typeof statusMessage !== "string" || statusMessage === "") throw new TypeError("statusMessage is not a non-empty string");
557
- if (!Number.isInteger(timeout) || timeout < 1) throw new TypeError(`timeout ${JSON.stringify(timeout)} is not a whole number of seconds`);
824
+ checkStatus(statusMessage, "statusMessage");
825
+ checkTimeout(timeout, "timeout");
826
+ const patterns = Array.isArray(ownCommands) ? [...ownCommands] : null;
827
+ if (patterns === null || patterns.some((pattern) => !(pattern instanceof RegExp) || pattern.global || pattern.multiline || pattern.sticky)) throw new TypeError("ownCommands must list regular expressions without the g, m or y flag");
828
+ const names = Array.isArray(tools) ? [...tools] : null;
829
+ if (names === null || names.some((tool) => typeof tool !== "string" || !TOOL_NAME.test(tool))) throw new TypeError("tools must list tool names of letters, digits, _ and -");
830
+ const builtIn = names.find((tool) => KIND_OF_TOOL.has(tool));
831
+ if (builtIn !== void 0) throw new TypeError(`${builtIn} is one of the agents' own tools; list its kind instead`);
832
+ if (kinds.includes("tool") !== names.length > 0) throw new TypeError("kinds lists tool exactly when tools names one or more tools");
833
+ for (const [key, hook] of [["onInstall", onInstall], ["onUninstall", onUninstall]]) if (hook !== void 0 && typeof hook !== "function") throw new TypeError(`${key} is not a function`);
558
834
  return Object.freeze({
559
835
  name,
560
836
  packageName,
561
837
  kinds: Object.freeze([...new Set(kinds)]),
562
838
  statusMessage,
563
839
  timeout,
840
+ ownCommands: Object.freeze(patterns),
841
+ tools: Object.freeze([...new Set(names)]),
842
+ events: eventHooks(events, statusMessage, timeout),
843
+ onInstall,
844
+ onUninstall,
564
845
  check
565
846
  });
566
847
  }
848
+ function checkStatus(value, field) {
849
+ if (typeof value !== "string" || value === "") throw new TypeError(`${field} is not a non-empty string`);
850
+ }
851
+ function checkTimeout(value, field) {
852
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`${field} ${JSON.stringify(value)} is not a whole number of seconds`);
853
+ }
854
+ function eventHook(event, definition, statusMessage, timeout) {
855
+ if (typeof definition !== "object" || definition === null || typeof definition.check !== "function") throw new TypeError(`events.${event}.check is not a function`);
856
+ const hook = {
857
+ check: definition.check,
858
+ statusMessage: definition.statusMessage ?? statusMessage,
859
+ timeout: definition.timeout ?? timeout
860
+ };
861
+ checkStatus(hook.statusMessage, `events.${event}.statusMessage`);
862
+ checkTimeout(hook.timeout, `events.${event}.timeout`);
863
+ return hook;
864
+ }
865
+ function eventHooks(events, statusMessage, timeout) {
866
+ if (typeof events !== "object" || events === null || Array.isArray(events)) throw new TypeError("events is not an object");
867
+ const unknown = Object.keys(events).find((event) => !EVENTS.includes(event));
868
+ if (unknown !== void 0) throw new TypeError(`events.${unknown} is not one of ${EVENTS.join(", ")}`);
869
+ const { Stop, UserPromptSubmit } = events;
870
+ const hidePrompt = UserPromptSubmit?.hidePrompt ?? false;
871
+ if (typeof hidePrompt !== "boolean") throw new TypeError("events.UserPromptSubmit.hidePrompt is not a boolean");
872
+ return Object.freeze({
873
+ ...Stop ? { Stop: Object.freeze(eventHook("Stop", Stop, statusMessage, timeout)) } : {},
874
+ ...UserPromptSubmit ? { UserPromptSubmit: Object.freeze({
875
+ ...eventHook("UserPromptSubmit", UserPromptSubmit, statusMessage, timeout),
876
+ hidePrompt
877
+ }) } : {}
878
+ });
879
+ }
567
880
  export { claudePluginFiles, configFile, createCli, defineHook, install, matcherFor, readStdin, runCli, runHook, uninstall, writeClaudePlugin };