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