@phnx-labs/agents-cli 1.20.0 → 1.20.3

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +4 -4
  3. package/dist/commands/cli.js +3 -3
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +24 -7
  6. package/dist/commands/exec.js +36 -16
  7. package/dist/commands/feedback.d.ts +7 -0
  8. package/dist/commands/feedback.js +89 -0
  9. package/dist/commands/helper.d.ts +12 -0
  10. package/dist/commands/helper.js +87 -0
  11. package/dist/commands/hooks.js +86 -7
  12. package/dist/commands/mcp.js +166 -10
  13. package/dist/commands/packages.js +196 -27
  14. package/dist/commands/permissions.js +21 -6
  15. package/dist/commands/profiles.d.ts +8 -0
  16. package/dist/commands/profiles.js +117 -4
  17. package/dist/commands/pull.js +4 -4
  18. package/dist/commands/routines.js +6 -6
  19. package/dist/commands/rules.js +8 -4
  20. package/dist/commands/secrets-migrate.d.ts +24 -0
  21. package/dist/commands/secrets-migrate.js +198 -0
  22. package/dist/commands/secrets-sync.d.ts +11 -0
  23. package/dist/commands/secrets-sync.js +155 -0
  24. package/dist/commands/secrets.js +74 -39
  25. package/dist/commands/skills.js +22 -5
  26. package/dist/commands/subagents.js +69 -49
  27. package/dist/commands/teams.js +48 -10
  28. package/dist/commands/utils.d.ts +33 -0
  29. package/dist/commands/utils.js +139 -0
  30. package/dist/commands/versions.js +4 -4
  31. package/dist/commands/view.d.ts +6 -0
  32. package/dist/commands/view.js +164 -8
  33. package/dist/commands/workflows.js +29 -6
  34. package/dist/index.js +4 -0
  35. package/dist/lib/acp/client.js +6 -1
  36. package/dist/lib/agents.d.ts +4 -0
  37. package/dist/lib/agents.js +18 -14
  38. package/dist/lib/auto-pull-worker.js +18 -1
  39. package/dist/lib/browser/chrome.js +4 -0
  40. package/dist/lib/browser/drivers/ssh.js +1 -1
  41. package/dist/lib/browser/profiles.d.ts +3 -3
  42. package/dist/lib/browser/profiles.js +3 -3
  43. package/dist/lib/browser/service.js +19 -0
  44. package/dist/lib/browser/types.d.ts +4 -4
  45. package/dist/lib/cli-resources.d.ts +36 -8
  46. package/dist/lib/cli-resources.js +268 -46
  47. package/dist/lib/cloud/factory.d.ts +1 -1
  48. package/dist/lib/cloud/factory.js +1 -1
  49. package/dist/lib/events.d.ts +16 -2
  50. package/dist/lib/events.js +33 -2
  51. package/dist/lib/exec.d.ts +39 -11
  52. package/dist/lib/exec.js +90 -31
  53. package/dist/lib/help.js +11 -5
  54. package/dist/lib/hooks/cache.d.ts +38 -0
  55. package/dist/lib/hooks/cache.js +242 -0
  56. package/dist/lib/hooks/profile.d.ts +33 -0
  57. package/dist/lib/hooks/profile.js +129 -0
  58. package/dist/lib/hooks.d.ts +0 -10
  59. package/dist/lib/hooks.js +68 -15
  60. package/dist/lib/mcp.d.ts +15 -0
  61. package/dist/lib/mcp.js +40 -0
  62. package/dist/lib/permissions.d.ts +13 -0
  63. package/dist/lib/permissions.js +51 -1
  64. package/dist/lib/plugins.js +15 -1
  65. package/dist/lib/profiles-presets.d.ts +26 -0
  66. package/dist/lib/profiles-presets.js +187 -8
  67. package/dist/lib/profiles.d.ts +34 -0
  68. package/dist/lib/profiles.js +112 -1
  69. package/dist/lib/routines-format.d.ts +17 -5
  70. package/dist/lib/routines-format.js +37 -16
  71. package/dist/lib/routines.d.ts +1 -1
  72. package/dist/lib/routines.js +2 -2
  73. package/dist/lib/runner.js +64 -10
  74. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  77. package/dist/lib/secrets/bundles.d.ts +18 -22
  78. package/dist/lib/secrets/bundles.js +75 -99
  79. package/dist/lib/secrets/index.d.ts +51 -27
  80. package/dist/lib/secrets/index.js +147 -156
  81. package/dist/lib/secrets/install-helper.d.ts +45 -0
  82. package/dist/lib/secrets/install-helper.js +165 -0
  83. package/dist/lib/secrets/linux.js +4 -4
  84. package/dist/lib/secrets/sync.d.ts +56 -0
  85. package/dist/lib/secrets/sync.js +180 -0
  86. package/dist/lib/session/render.js +4 -4
  87. package/dist/lib/session/types.d.ts +1 -1
  88. package/dist/lib/shims.d.ts +4 -1
  89. package/dist/lib/shims.js +5 -35
  90. package/dist/lib/state.d.ts +14 -1
  91. package/dist/lib/state.js +49 -5
  92. package/dist/lib/teams/agents.d.ts +5 -4
  93. package/dist/lib/teams/agents.js +47 -21
  94. package/dist/lib/teams/api.d.ts +2 -1
  95. package/dist/lib/teams/api.js +4 -3
  96. package/dist/lib/types.d.ts +57 -1
  97. package/dist/lib/types.js +2 -0
  98. package/dist/lib/usage.d.ts +27 -2
  99. package/dist/lib/usage.js +100 -17
  100. package/dist/lib/versions.d.ts +35 -1
  101. package/dist/lib/versions.js +267 -64
  102. package/package.json +9 -8
  103. package/scripts/install-helper.js +97 -0
  104. package/scripts/postinstall.js +16 -0
  105. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
package/dist/lib/exec.js CHANGED
@@ -7,10 +7,52 @@
7
7
  import { spawn } from 'child_process';
8
8
  import { randomUUID } from 'crypto';
9
9
  import * as path from 'path';
10
+ import { ALL_MODES } from './types.js';
11
+ import { AGENTS } from './agents.js';
10
12
  import { parseTimeout } from './routines.js';
11
13
  import { getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
12
14
  import { resolveModel, buildReasoningFlags } from './models.js';
13
- import { maybeRotate, createTimer, truncate } from './events.js';
15
+ import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
16
+ import { sanitizeProcessEnv } from './secrets/bundles.js';
17
+ /**
18
+ * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
19
+ *
20
+ * Accepts the historical `full` spelling and rewrites it to `skip`. Throws on
21
+ * anything outside the four canonical values so bad input fails loud at the
22
+ * boundary rather than silently picking a wrong code path.
23
+ */
24
+ export function normalizeMode(input) {
25
+ if (!input) {
26
+ throw new Error(`Mode is required. Use one of: ${ALL_MODES.join(', ')}.`);
27
+ }
28
+ const v = input.trim().toLowerCase();
29
+ if (v === 'full')
30
+ return 'skip';
31
+ if (ALL_MODES.includes(v))
32
+ return v;
33
+ throw new Error(`Invalid mode '${input}'. Use one of: ${ALL_MODES.join(', ')} (or 'full' as a deprecated alias for 'skip').`);
34
+ }
35
+ /**
36
+ * Resolve a requested mode against an agent's capability table.
37
+ *
38
+ * - `auto` on an agent without auto support silently degrades to `edit`
39
+ * (every agent supports edit-like behavior as its default).
40
+ * - `skip` on an agent without skip support throws with a clear message
41
+ * naming the agent's supported modes. No silent fallback — the user
42
+ * explicitly asked to bypass permissions; pretending we did is unsafe.
43
+ * - `plan` on an agent without plan support throws the same way.
44
+ */
45
+ export function resolveMode(agent, requested) {
46
+ const supported = AGENTS[agent].capabilities.modes;
47
+ if (supported.includes(requested))
48
+ return requested;
49
+ if (requested === 'auto') {
50
+ // Fall back to edit — guaranteed to exist on every agent (every agent has
51
+ // at least 'edit' in its modes table, since that's the default behavior).
52
+ return 'edit';
53
+ }
54
+ throw new Error(`${agent} does not support '${requested}' mode. Supported modes: ${supported.join(', ')}.`);
55
+ }
14
56
  /** Pattern for valid environment variable names (C identifier rules). */
15
57
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
58
  /** Parse a single KEY=VALUE string into a tuple, validating the key name. */
@@ -40,7 +82,7 @@ export function parseExecEnv(entries) {
40
82
  * into unrelated invocations.
41
83
  */
42
84
  export function buildExecEnv(options) {
43
- const result = { ...process.env };
85
+ const result = { ...sanitizeProcessEnv(process.env) };
44
86
  // Config-dir env vars are agent-specific. When the caller is running inside
45
87
  // an agent-managed shell, process.env already carries one; spreading into a
46
88
  // different agent's env would leak a config pointer the target CLI doesn't
@@ -96,7 +138,12 @@ export function buildExecEnv(options) {
96
138
  ...options.env,
97
139
  };
98
140
  }
99
- /** CLI command templates for every supported agent. */
141
+ /**
142
+ * CLI command templates for every supported agent.
143
+ *
144
+ * Each agent's `modeFlags` keys MUST match the modes listed in
145
+ * AGENTS[agent].capabilities.modes. A test in exec.test.ts asserts this.
146
+ */
100
147
  export const AGENT_COMMANDS = {
101
148
  claude: {
102
149
  base: ['claude'],
@@ -104,8 +151,8 @@ export const AGENT_COMMANDS = {
104
151
  modeFlags: {
105
152
  plan: ['--permission-mode', 'plan'],
106
153
  edit: ['--permission-mode', 'acceptEdits'],
107
- full: ['--dangerously-skip-permissions'],
108
154
  auto: ['--permission-mode', 'auto'],
155
+ skip: ['--dangerously-skip-permissions'],
109
156
  },
110
157
  jsonFlags: ['--output-format', 'stream-json', '--verbose'],
111
158
  modelFlag: '--model',
@@ -116,9 +163,13 @@ export const AGENT_COMMANDS = {
116
163
  base: ['codex', 'exec'],
117
164
  promptFlag: 'positional',
118
165
  modeFlags: {
166
+ // NOTE: codex has no read-only mode in --sandbox; 'plan' here means
167
+ // "workspace-write but no auto-approval" — closer to plan-as-restraint.
168
+ // True read-only requires --sandbox read-only which we haven't wired.
119
169
  plan: ['--sandbox', 'workspace-write'],
120
170
  edit: ['--sandbox', 'workspace-write', '--full-auto'],
121
- full: ['--full-auto'],
171
+ // skip drops the sandbox entirely; --full-auto then approves anything.
172
+ skip: ['--full-auto'],
122
173
  },
123
174
  jsonFlags: ['--json'],
124
175
  modelFlag: '--model',
@@ -127,9 +178,9 @@ export const AGENT_COMMANDS = {
127
178
  base: ['gemini'],
128
179
  promptFlag: 'positional',
129
180
  modeFlags: {
130
- plan: [],
131
- edit: ['--yolo'],
132
- full: ['--yolo'],
181
+ plan: ['--approval-mode', 'plan'],
182
+ edit: ['--approval-mode', 'auto_edit'],
183
+ skip: ['--yolo'],
133
184
  },
134
185
  jsonFlags: ['--output-format', 'stream-json'],
135
186
  modelFlag: '--model',
@@ -138,9 +189,9 @@ export const AGENT_COMMANDS = {
138
189
  base: ['cursor-agent'],
139
190
  promptFlag: '-p',
140
191
  modeFlags: {
141
- plan: [],
142
- edit: ['-f'],
143
- full: ['-f'],
192
+ // cursor-agent has no read-only flag; we only expose edit + skip.
193
+ edit: [],
194
+ skip: ['-f'],
144
195
  },
145
196
  jsonFlags: ['--output-format', 'stream-json'],
146
197
  modelFlag: '--model',
@@ -151,7 +202,6 @@ export const AGENT_COMMANDS = {
151
202
  modeFlags: {
152
203
  plan: ['--agent', 'plan'],
153
204
  edit: ['--agent', 'build'],
154
- full: ['--agent', 'build'],
155
205
  },
156
206
  jsonFlags: ['--format', 'json'],
157
207
  modelFlag: '--model',
@@ -162,7 +212,7 @@ export const AGENT_COMMANDS = {
162
212
  modeFlags: {
163
213
  plan: ['--mode', 'plan'],
164
214
  edit: ['--mode', 'edit'],
165
- full: ['--mode', 'full'],
215
+ skip: ['--mode', 'full'],
166
216
  },
167
217
  jsonFlags: ['--output-format', 'stream-json'],
168
218
  modelFlag: '--model',
@@ -171,19 +221,21 @@ export const AGENT_COMMANDS = {
171
221
  // against `copilot --help` from v0.0.413+:
172
222
  // -p, --prompt <text> non-interactive one-shot
173
223
  // --mode <interactive|plan|autopilot>
224
+ // --autopilot start in autopilot (smart-classifier) mode
174
225
  // --allow-all-tools required for non-interactive tool exec
175
226
  // --allow-all (alias --yolo) tools + paths + URLs
176
227
  // --output-format <text|json> json => JSONL, one object per line
177
228
  // --model <model>
178
- // Plan mode is read-only so it does not need an allow-tools grant; edit/full
179
- // need at minimum --allow-all-tools so headless runs don't stall on prompts.
229
+ // Plan mode is read-only so it does not need an allow-tools grant; edit
230
+ // needs --allow-all-tools so headless runs don't stall on prompts.
180
231
  copilot: {
181
232
  base: ['copilot'],
182
233
  promptFlag: '-p',
183
234
  modeFlags: {
184
235
  plan: ['--mode', 'plan'],
185
236
  edit: ['--allow-all-tools'],
186
- full: ['--allow-all'],
237
+ auto: ['--autopilot'],
238
+ skip: ['--allow-all'],
187
239
  },
188
240
  jsonFlags: ['--output-format', 'json'],
189
241
  modelFlag: '--model',
@@ -194,7 +246,6 @@ export const AGENT_COMMANDS = {
194
246
  modeFlags: {
195
247
  plan: ['--mode', 'plan'],
196
248
  edit: ['--mode', 'edit'],
197
- full: ['--mode', 'edit'],
198
249
  },
199
250
  modelFlag: '--model',
200
251
  },
@@ -202,9 +253,8 @@ export const AGENT_COMMANDS = {
202
253
  base: ['kiro-cli'],
203
254
  promptFlag: 'positional',
204
255
  modeFlags: {
205
- plan: [],
256
+ // kiro-cli has no permission flags — edit is the default behavior.
206
257
  edit: [],
207
- full: [],
208
258
  },
209
259
  modelFlag: '--model',
210
260
  },
@@ -212,9 +262,8 @@ export const AGENT_COMMANDS = {
212
262
  base: ['goose', 'run'],
213
263
  promptFlag: 'positional',
214
264
  modeFlags: {
215
- plan: [],
265
+ // goose has no permission flags — edit is the default behavior.
216
266
  edit: [],
217
- full: [],
218
267
  },
219
268
  },
220
269
  roo: {
@@ -223,11 +272,9 @@ export const AGENT_COMMANDS = {
223
272
  modeFlags: {
224
273
  plan: ['--mode', 'architect'],
225
274
  edit: ['--mode', 'code'],
226
- full: ['--mode', 'code'],
227
275
  },
228
276
  modelFlag: '--model',
229
277
  },
230
- // Antigravity full mode uses --dangerously-skip-permissions (YOLO).
231
278
  // TODO: --output-format json is documented but currently broken upstream
232
279
  // ("flags provided but not defined: -output-format"). Track resolution at
233
280
  // https://github.com/google-antigravity/antigravity-cli/issues/7 before
@@ -236,19 +283,22 @@ export const AGENT_COMMANDS = {
236
283
  base: ['agy'],
237
284
  promptFlag: 'positional',
238
285
  modeFlags: {
239
- plan: [],
286
+ // agy --help shows no plan/edit flags; default behavior is edit-like
287
+ // (prompts on tool use). Only skip has an explicit flag.
240
288
  edit: [],
241
- full: ['--dangerously-skip-permissions'],
289
+ skip: ['--dangerously-skip-permissions'],
242
290
  },
291
+ printFlags: ['--print'],
243
292
  modelFlag: '--model',
244
293
  },
245
294
  grok: {
246
295
  base: ['grok'],
247
296
  promptFlag: '-p',
248
297
  modeFlags: {
249
- plan: ['--mode', 'plan'],
298
+ // grok --help lists `--permission-mode plan`; the TUI defaults to ask.
299
+ plan: ['--permission-mode', 'plan'],
250
300
  edit: [],
251
- full: ['--always-approve'],
301
+ skip: ['--always-approve'],
252
302
  },
253
303
  jsonFlags: ['--output-format', 'streaming-json'],
254
304
  modelFlag: '--model',
@@ -283,8 +333,17 @@ export function buildExecCommand(options) {
283
333
  }
284
334
  }
285
335
  }
286
- // Add mode flags. 'auto' is only defined for claude; other agents fall back to edit flags.
287
- const modeFlags = template.modeFlags[options.mode] ?? template.modeFlags.edit;
336
+ // Resolve the requested mode against the agent's capability table.
337
+ // - `auto` on an agent without auto support → silently degrades to `edit`
338
+ // - `skip`/`plan` on an unsupported agent → throws a clear error
339
+ // After resolveMode, the chosen mode is guaranteed to be in template.modeFlags.
340
+ const resolvedMode = resolveMode(options.agent, normalizeMode(options.mode));
341
+ const modeFlags = template.modeFlags[resolvedMode];
342
+ if (!modeFlags) {
343
+ // Defense in depth: would only fire if AGENTS.capabilities.modes and
344
+ // AGENT_COMMANDS.modeFlags drifted apart. Tests assert they agree.
345
+ throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
346
+ }
288
347
  cmd.push(...modeFlags);
289
348
  // Add print/headless flags only when a prompt is provided. Without a prompt
290
349
  // the caller wants an interactive REPL -- passing --print would immediately
@@ -370,9 +429,9 @@ async function spawnAgent(options) {
370
429
  model: options.model,
371
430
  interactive,
372
431
  sessionId: options.sessionId,
373
- prompt: truncate(options.prompt, 200),
432
+ ...redactPrompt(options.prompt),
374
433
  command: executable,
375
- args: args.slice(0, 10),
434
+ args: redactArgs(args.slice(0, 10)),
376
435
  });
377
436
  return new Promise((resolve, reject) => {
378
437
  // Interactive mode inherits all stdio so the CLI owns the TTY (TUI
package/dist/lib/help.js CHANGED
@@ -47,15 +47,21 @@ function formatHelpCommandsFirst(cmd, helper) {
47
47
  const helpWidth = helper.helpWidth || 80;
48
48
  const itemIndentWidth = 2;
49
49
  const itemSeparatorWidth = 2;
50
+ // commander v15 dropped `Help.wrap(str, width, indent)` in favor of
51
+ // `boxWrap(str, width)` plus a built-in `formatItem(term, termWidth,
52
+ // description, helper)` that handles the term-pad + continuation-indent
53
+ // math we used to do by hand. Delegate to it so callers get the same
54
+ // continuation-line alignment under the description column.
50
55
  function formatItem(term, description) {
51
56
  if (description) {
52
- const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`;
53
- return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth);
57
+ return helper.formatItem(term, termWidth, description, helper);
54
58
  }
55
- return term;
59
+ return ' '.repeat(itemIndentWidth) + term;
56
60
  }
57
61
  function formatList(textArray) {
58
- return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
62
+ // formatItem already prefixes each item with its 2-space indent, so just
63
+ // join. Single-line items (no description) are indented above.
64
+ return textArray.join('\n');
59
65
  }
60
66
  // Drop arguments flagged as hidden (deprecation / compat slots) from both
61
67
  // the Usage line and the Arguments section. Commander v12's Argument lacks
@@ -81,7 +87,7 @@ function formatHelpCommandsFirst(cmd, helper) {
81
87
  let output = [`Usage: ${usageLine}`, ''];
82
88
  const commandDescription = helper.commandDescription(cmd);
83
89
  if (commandDescription.length > 0) {
84
- output = output.concat([helper.wrap(commandDescription, helpWidth, 0), '']);
90
+ output = output.concat([helper.boxWrap(commandDescription, helpWidth), '']);
85
91
  }
86
92
  const sections = helpSectionRegistry.get(cmd);
87
93
  if (sections?.examples) {
@@ -0,0 +1,38 @@
1
+ import type { HookCache, HookCacheConfig } from '../types.js';
2
+ /**
3
+ * Parse a `cache:` value from hooks.yaml into the canonical config form.
4
+ * Accepts the shorthand string ("5m", "30s-bg") or the full object form.
5
+ * Returns null if the value is missing or unparseable.
6
+ */
7
+ export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
8
+ /** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
9
+ export declare function parseDuration(d: number | string | undefined): number | null;
10
+ /** Absolute path of the generated shim for a hook name. */
11
+ export declare function getHookShimPath(name: string): string;
12
+ /**
13
+ * Optional path overrides for tests that need to redirect cache + logs to a
14
+ * temp dir. Production callers omit `paths`; the shim uses real state.ts dirs.
15
+ * (state.ts captures HOME at module load, so mutating process.env.HOME in a
16
+ * test's beforeEach doesn't reach getHookCacheDir() — this is the explicit
17
+ * seam.)
18
+ */
19
+ export interface HookShimPaths {
20
+ shimsDir?: string;
21
+ cacheDir?: string;
22
+ logsDir?: string;
23
+ }
24
+ /**
25
+ * Generate (or refresh) the shim script for a hook. Idempotent — only writes
26
+ * when the content differs from what's on disk. Returns the absolute shim path.
27
+ */
28
+ export declare function generateHookShim(args: {
29
+ name: string;
30
+ scriptPath: string;
31
+ cache: HookCacheConfig;
32
+ paths?: HookShimPaths;
33
+ }): string;
34
+ /**
35
+ * Remove a hook's shim. Called by the registrar's garbage collection when a
36
+ * hook is renamed/deleted or has its `cache:` field removed.
37
+ */
38
+ export declare function removeHookShim(name: string, shimsDir?: string): void;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Declarative hook caching + timing.
3
+ *
4
+ * Hooks that opt in via `cache:` in hooks.yaml get a generated bash shim
5
+ * (~/.agents/.cache/shims/hooks/<name>.sh) registered with the agent instead
6
+ * of the raw script path. The shim handles:
7
+ *
8
+ * 1. cache lookup — reads ~/.agents/.cache/state/hooks/<name>.<key>.out
9
+ * and serves it if newer than ttl.
10
+ * 2. stale-while-revalidate — when prefetch=background, serves stale cache
11
+ * and refreshes the cache file in a detached child.
12
+ * 3. timing — appends one JSONL line per fire to events-YYYY-MM-DD.jsonl.
13
+ *
14
+ * The shim is regenerated whenever the registrar runs; if its content doesn't
15
+ * change (idempotent), mtime is preserved. Stale shims for removed hooks are
16
+ * cleaned by the registrar's garbage collection (shims dir is in
17
+ * managedPrefixes).
18
+ */
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import { getHookCacheDir, getHookShimsDir, getLogsDir } from '../state.js';
22
+ /**
23
+ * Parse a `cache:` value from hooks.yaml into the canonical config form.
24
+ * Accepts the shorthand string ("5m", "30s-bg") or the full object form.
25
+ * Returns null if the value is missing or unparseable.
26
+ */
27
+ export function parseCacheConfig(raw) {
28
+ if (raw == null)
29
+ return null;
30
+ if (typeof raw === 'string')
31
+ return parseShorthand(raw);
32
+ const ttlSec = parseDuration(raw.ttl);
33
+ if (ttlSec == null)
34
+ return null;
35
+ return {
36
+ ttl: ttlSec,
37
+ key: raw.key ?? 'global',
38
+ prefetch: raw.prefetch ?? 'none',
39
+ };
40
+ }
41
+ function parseShorthand(s) {
42
+ const trimmed = s.trim();
43
+ let prefetch = 'none';
44
+ let durationPart = trimmed;
45
+ if (trimmed.endsWith('-bg')) {
46
+ prefetch = 'background';
47
+ durationPart = trimmed.slice(0, -3);
48
+ }
49
+ const ttlSec = parseDuration(durationPart);
50
+ if (ttlSec == null)
51
+ return null;
52
+ return { ttl: ttlSec, key: 'global', prefetch };
53
+ }
54
+ /** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
55
+ export function parseDuration(d) {
56
+ if (d == null)
57
+ return null;
58
+ if (typeof d === 'number')
59
+ return Number.isFinite(d) && d > 0 ? Math.floor(d) : null;
60
+ const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i);
61
+ if (!m)
62
+ return null;
63
+ const value = parseInt(m[1], 10);
64
+ if (!Number.isFinite(value) || value <= 0)
65
+ return null;
66
+ const unit = (m[2] || 's').toLowerCase();
67
+ if (unit.startsWith('h'))
68
+ return value * 3600;
69
+ if (unit.startsWith('m'))
70
+ return value * 60;
71
+ return value;
72
+ }
73
+ /** Absolute path of the generated shim for a hook name. */
74
+ export function getHookShimPath(name) {
75
+ return path.join(getHookShimsDir(), `${name}.sh`);
76
+ }
77
+ /**
78
+ * Generate (or refresh) the shim script for a hook. Idempotent — only writes
79
+ * when the content differs from what's on disk. Returns the absolute shim path.
80
+ */
81
+ export function generateHookShim(args) {
82
+ const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
83
+ const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
84
+ const logsDir = args.paths?.logsDir ?? getLogsDir();
85
+ const shimPath = path.join(shimsDir, `${args.name}.sh`);
86
+ const content = renderShim(args.name, args.scriptPath, args.cache, { cacheDir, logsDir });
87
+ fs.mkdirSync(shimsDir, { recursive: true });
88
+ let existing = null;
89
+ if (fs.existsSync(shimPath)) {
90
+ try {
91
+ existing = fs.readFileSync(shimPath, 'utf-8');
92
+ }
93
+ catch { /* rewrite */ }
94
+ }
95
+ if (existing !== content) {
96
+ fs.writeFileSync(shimPath, content, { mode: 0o755 });
97
+ }
98
+ else {
99
+ // Ensure exec bit even when content unchanged (file mode can drift).
100
+ try {
101
+ fs.chmodSync(shimPath, 0o755);
102
+ }
103
+ catch { /* best effort */ }
104
+ }
105
+ return shimPath;
106
+ }
107
+ /**
108
+ * Render the bash shim. Bash 3.2-compatible (macOS default). Uses python3 for
109
+ * monotonic-ish nanosecond timing — already a hard dependency of other hooks
110
+ * in this repo (04-capture-session-start-metadata.sh does the same).
111
+ */
112
+ function renderShim(name, scriptPath, cache, paths) {
113
+ const ttl = typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0);
114
+ const key = cache.key ?? 'global';
115
+ const prefetch = cache.prefetch ?? 'none';
116
+ const { cacheDir, logsDir } = paths;
117
+ // sh-escape: wrap in single quotes, escape any embedded single quotes.
118
+ const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
119
+ return `#!/usr/bin/env bash
120
+ # GENERATED by agents-cli. Do not edit — re-run \`agents hooks sync\` to refresh.
121
+ # Hook: ${name}
122
+ # Source: ${scriptPath}
123
+ # Cache: key=${key} ttl=${ttl}s prefetch=${prefetch}
124
+ set -u
125
+
126
+ HOOK_NAME=${q(name)}
127
+ SOURCE=${q(scriptPath)}
128
+ CACHE_DIR=${q(cacheDir)}
129
+ LOGS_DIR=${q(logsDir)}
130
+ TTL=${ttl}
131
+ PREFETCH=${q(prefetch)}
132
+ KEY_MODE=${q(key)}
133
+
134
+ mkdir -p "$CACHE_DIR" "$LOGS_DIR"
135
+
136
+ # Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
137
+ STDIN_PAYLOAD="$(cat || true)"
138
+
139
+ # Portable sha1 — \`shasum\` is Perl, missing on minimal Linux images;
140
+ # \`sha1sum\` is coreutils, missing on macOS. Truncate to 12 hex chars.
141
+ sha1_12() { python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
142
+
143
+ # Derive cache key suffix from KEY_MODE. All untrusted inputs (cwd, session_id,
144
+ # project path) are hashed before going into the filename so a malicious stdin
145
+ # payload can't write outside $CACHE_DIR via path traversal.
146
+ cache_suffix=""
147
+ case "$KEY_MODE" in
148
+ per-cwd)
149
+ cwd_val="$(printf '%s' "$STDIN_PAYLOAD" | python3 -c 'import json,sys
150
+ try: print(json.load(sys.stdin).get("cwd","") or "")
151
+ except Exception: pass' 2>/dev/null || true)"
152
+ [ -z "$cwd_val" ] && cwd_val="$PWD"
153
+ cache_suffix=".$(printf '%s' "$cwd_val" | sha1_12)"
154
+ ;;
155
+ per-session)
156
+ sid_val="$(printf '%s' "$STDIN_PAYLOAD" | python3 -c 'import json,sys
157
+ try: print(json.load(sys.stdin).get("session_id","") or "")
158
+ except Exception: pass' 2>/dev/null || true)"
159
+ # Hash + fall back to a sentinel so missing-session doesn't silently
160
+ # collapse to the same file as KEY_MODE=global.
161
+ [ -z "$sid_val" ] && sid_val="__nosession__"
162
+ cache_suffix=".$(printf '%s' "$sid_val" | sha1_12)"
163
+ ;;
164
+ per-project)
165
+ proj_val="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || echo "")"
166
+ [ -z "$proj_val" ] && proj_val="$PWD"
167
+ cache_suffix=".$(printf '%s' "$proj_val" | sha1_12)"
168
+ ;;
169
+ global|*)
170
+ cache_suffix=""
171
+ ;;
172
+ esac
173
+ CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
174
+
175
+ # Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
176
+ now_ns() { python3 -c 'import time; print(int(time.time()*1e9))'; }
177
+ START_NS=$(now_ns)
178
+
179
+ CACHE_STATUS=miss
180
+ CACHE_AGE=-1
181
+ EXIT=0
182
+
183
+ if [ -f "$CACHE_FILE" ]; then
184
+ # python3 is already a hard dep (used for now_ns) and gives portable mtime
185
+ # without the macOS-vs-Linux \`stat\` flag divergence (-f %m vs -c %Y) that
186
+ # blew up under \`set -u\` when the wrong flag produced literal "%m".
187
+ mtime=$(python3 -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$CACHE_FILE" 2>/dev/null)
188
+ mtime=\${mtime:-0}
189
+ now_s=$(date +%s)
190
+ CACHE_AGE=$((now_s - mtime))
191
+ if [ "$CACHE_AGE" -ge 0 ] && [ "$CACHE_AGE" -lt "$TTL" ]; then
192
+ cat "$CACHE_FILE"
193
+ CACHE_STATUS=hit
194
+ fi
195
+ fi
196
+
197
+ if [ "$CACHE_STATUS" = miss ]; then
198
+ if [ -f "$CACHE_FILE" ] && [ "$PREFETCH" = background ]; then
199
+ # Stale-while-revalidate: serve stale immediately, refresh in detached child.
200
+ cat "$CACHE_FILE"
201
+ CACHE_STATUS=stale-prefetch
202
+ tmp="$CACHE_FILE.new.$$"
203
+ ( printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp" 2>/dev/null && mv -f "$tmp" "$CACHE_FILE" || rm -f "$tmp" ) >/dev/null 2>&1 &
204
+ disown 2>/dev/null || true
205
+ else
206
+ # Synchronous fetch + cache.
207
+ tmp="$CACHE_FILE.new.$$"
208
+ if printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp"; then
209
+ EXIT=0
210
+ cat "$tmp"
211
+ mv -f "$tmp" "$CACHE_FILE"
212
+ else
213
+ EXIT=$?
214
+ rm -f "$tmp"
215
+ fi
216
+ fi
217
+ fi
218
+
219
+ END_NS=$(now_ns)
220
+ MS=$(( (END_NS - START_NS) / 1000000 ))
221
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
222
+ LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
223
+ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
224
+ "$TS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
225
+
226
+ exit "$EXIT"
227
+ `;
228
+ }
229
+ /**
230
+ * Remove a hook's shim. Called by the registrar's garbage collection when a
231
+ * hook is renamed/deleted or has its `cache:` field removed.
232
+ */
233
+ export function removeHookShim(name, shimsDir) {
234
+ const dir = shimsDir ?? getHookShimsDir();
235
+ const shimPath = path.join(dir, `${name}.sh`);
236
+ if (fs.existsSync(shimPath)) {
237
+ try {
238
+ fs.unlinkSync(shimPath);
239
+ }
240
+ catch { /* best effort */ }
241
+ }
242
+ }
@@ -0,0 +1,33 @@
1
+ export interface HookProfileRow {
2
+ hook: string;
3
+ n: number;
4
+ p50Ms: number;
5
+ p99Ms: number;
6
+ meanMs: number;
7
+ maxMs: number;
8
+ cacheHitPct: number;
9
+ cacheStalePct: number;
10
+ cacheMissPct: number;
11
+ errorCount: number;
12
+ }
13
+ interface RawFireEvent {
14
+ event?: string;
15
+ hook?: string;
16
+ ms?: number;
17
+ cache?: 'hit' | 'miss' | 'stale-prefetch' | string;
18
+ exit?: number;
19
+ }
20
+ /**
21
+ * Load every `hook.fire` event from the last `days` daily log files.
22
+ * Lines that aren't JSON or aren't `hook.fire` events are silently skipped —
23
+ * the events log is multiplexed (version.switch, secrets.get, …).
24
+ */
25
+ export declare function loadHookFireEvents(days?: number, logsDir?: string): RawFireEvent[];
26
+ /** Aggregate fire events into a per-hook profile, sorted by p99 desc. */
27
+ export declare function aggregateHookProfile(events: RawFireEvent[]): HookProfileRow[];
28
+ /** Human-friendly duration: "42ms" / "1.2s" / "12s" / "2m". */
29
+ export declare function formatMs(ms: number): string;
30
+ /** Format a row's cache column: `hit:97% miss:3%` or `n/a` when nothing cached. */
31
+ export declare function formatCacheColumn(row: HookProfileRow): string;
32
+ export declare const DEFAULT_SLOW_HOOK_WARN_MS = 2000;
33
+ export {};