plugnz 0.0.22 → 0.0.23

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.
@@ -1,12 +1,22 @@
1
1
  // src/hooks.ts
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { lstatSync, readFileSync } from "node:fs";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
5
  var HOOK_EVENTS = ["session.start", "turn.stop", "tool.before", "tool.after"];
6
6
  var HOOK_CONFIG_FILE = "plugnz.hooks.json";
7
7
  var DEFAULT_HOOK_TIMEOUT_MS = 1e4;
8
8
  var MAX_HOOK_TIMEOUT_MS = 60000;
9
9
  var MAX_HOOK_OUTPUT_BYTES = 1048576;
10
+ var HOOK_EFFECTS = ["context", "continue"];
11
+ function hookEffectGap(required, supported) {
12
+ for (const effect of HOOK_EFFECTS) {
13
+ if (required?.[effect] !== true)
14
+ continue;
15
+ if (supported?.[effect] !== true)
16
+ return { effect, status: supported?.[effect] === false ? "unsupported" : "unverified" };
17
+ }
18
+ return null;
19
+ }
10
20
  function record(value, label) {
11
21
  if (typeof value !== "object" || value === null || Array.isArray(value))
12
22
  throw new Error(label + " must be an object");
@@ -31,8 +41,9 @@ function isHookEvent(value) {
31
41
  function validateHookConfig(value) {
32
42
  const root = record(value, "Hook configuration");
33
43
  keys(root, ["version", "hooks"], "Hook configuration");
34
- if (root["version"] !== 1)
35
- throw new Error("Hook configuration version must be 1");
44
+ const version = root["version"];
45
+ if (version !== 1 && version !== 2)
46
+ throw new Error("Hook configuration version must be 1 or 2");
36
47
  const sourceHooks = record(root["hooks"], "hooks");
37
48
  keys(sourceHooks, HOOK_EVENTS, "hooks");
38
49
  const hooks = {};
@@ -45,7 +56,22 @@ function validateHookConfig(value) {
45
56
  hooks[event] = handlers.map((value, index) => {
46
57
  const label = event + "[" + index + "]";
47
58
  const handler = record(value, label);
48
- keys(handler, ["command", "tools", "timeoutMs"], label);
59
+ keys(handler, version === 1 ? ["command", "tools", "timeoutMs"] : ["command", "tools", "timeoutMs", "requires"], label);
60
+ let requires;
61
+ if (version === 2) {
62
+ const effects = handler["requires"] === undefined ? {} : record(handler["requires"], label + ".requires");
63
+ keys(effects, HOOK_EFFECTS, label + ".requires");
64
+ for (const effect of HOOK_EFFECTS) {
65
+ if (effects[effect] !== undefined && typeof effects[effect] !== "boolean")
66
+ throw new Error(label + ".requires." + effect + " must be boolean");
67
+ }
68
+ requires = { ...effects };
69
+ if (requires.continue === true && event !== "turn.stop")
70
+ throw new Error(label + ": continue is only available at turn.stop");
71
+ if (event === "turn.stop" && requires.context === true !== (requires.continue === true)) {
72
+ throw new Error(label + ": turn.stop requires context:true and continue:true together");
73
+ }
74
+ }
49
75
  const command = handler["command"];
50
76
  if (!Array.isArray(command) || command.length === 0)
51
77
  throw new Error(label + ".command must be a nonempty argv array");
@@ -63,15 +89,25 @@ function validateHookConfig(value) {
63
89
  if (timeout !== undefined && (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > MAX_HOOK_TIMEOUT_MS)) {
64
90
  throw new Error(label + ".timeoutMs must be an integer from 1 to " + MAX_HOOK_TIMEOUT_MS);
65
91
  }
66
- return { command: argv, ...tools === undefined ? {} : { tools }, ...timeout === undefined ? {} : { timeoutMs: timeout } };
92
+ return { command: argv, ...tools === undefined ? {} : { tools }, ...timeout === undefined ? {} : { timeoutMs: timeout }, ...requires === undefined ? {} : { requires } };
67
93
  });
68
94
  }
69
- return { version: 1, hooks };
95
+ return { version, hooks };
70
96
  }
71
97
  function readHookConfig(pluginRoot) {
72
98
  const path = join(pluginRoot, HOOK_CONFIG_FILE);
73
- if (!existsSync(path))
74
- return;
99
+ let stat;
100
+ try {
101
+ stat = lstatSync(path);
102
+ } catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return;
105
+ throw error;
106
+ }
107
+ if (stat.isSymbolicLink())
108
+ throw new Error("Hook source symlinks are unsupported: " + path);
109
+ if (!stat.isFile())
110
+ throw new Error("Hook manifest must be a regular file: " + path);
75
111
  let value;
76
112
  try {
77
113
  value = JSON.parse(readFileSync(path, "utf8"));
@@ -179,6 +215,12 @@ async function runHooks(configValue, inputValue, pluginRoot) {
179
215
  }
180
216
  }
181
217
  const result = validateHookResult(raw, input);
218
+ if (config.version === 2) {
219
+ if (result.context !== undefined && handler.requires?.context !== true)
220
+ throw new Error("undeclared context effect; declare requires.context:true");
221
+ if (result.continue === true && handler.requires?.continue !== true)
222
+ throw new Error("undeclared continuation effect; declare requires.continue:true");
223
+ }
182
224
  if (result.context !== undefined)
183
225
  contexts.push(result.context);
184
226
  if (result.continue !== undefined)
@@ -277,9 +319,11 @@ function executeHandler(handler, input, pluginRoot) {
277
319
  export {
278
320
  DEFAULT_HOOK_TIMEOUT_MS,
279
321
  HOOK_CONFIG_FILE,
322
+ HOOK_EFFECTS,
280
323
  HOOK_EVENTS,
281
324
  MAX_HOOK_OUTPUT_BYTES,
282
325
  MAX_HOOK_TIMEOUT_MS,
326
+ hookEffectGap,
283
327
  readHookConfig,
284
328
  runHooks,
285
329
  validateHookConfig,