@phnx-labs/agents-cli 1.20.0 → 1.20.4
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/CHANGELOG.md +81 -0
- package/README.md +4 -4
- package/dist/commands/cli.js +3 -3
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/commands.js +24 -7
- package/dist/commands/exec.js +36 -16
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +89 -0
- package/dist/commands/helper.d.ts +12 -0
- package/dist/commands/helper.js +87 -0
- package/dist/commands/hooks.js +86 -7
- package/dist/commands/import.js +90 -37
- package/dist/commands/mcp.js +166 -10
- package/dist/commands/packages.js +196 -27
- package/dist/commands/permissions.js +21 -6
- package/dist/commands/profiles.d.ts +8 -0
- package/dist/commands/profiles.js +117 -4
- package/dist/commands/pull.js +4 -4
- package/dist/commands/routines.js +6 -6
- package/dist/commands/rules.js +8 -4
- package/dist/commands/secrets-migrate.d.ts +24 -0
- package/dist/commands/secrets-migrate.js +198 -0
- package/dist/commands/secrets-sync.d.ts +11 -0
- package/dist/commands/secrets-sync.js +155 -0
- package/dist/commands/secrets.js +74 -39
- package/dist/commands/skills.js +22 -5
- package/dist/commands/subagents.js +69 -49
- package/dist/commands/teams.js +48 -10
- package/dist/commands/utils.d.ts +33 -0
- package/dist/commands/utils.js +139 -0
- package/dist/commands/versions.js +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +169 -8
- package/dist/commands/workflows.js +29 -6
- package/dist/index.js +4 -0
- package/dist/lib/acp/client.js +6 -1
- package/dist/lib/agents.d.ts +4 -0
- package/dist/lib/agents.js +41 -17
- package/dist/lib/auto-pull-worker.js +18 -1
- package/dist/lib/browser/chrome.js +4 -0
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/browser/profiles.d.ts +3 -3
- package/dist/lib/browser/profiles.js +3 -3
- package/dist/lib/browser/service.js +19 -0
- package/dist/lib/browser/types.d.ts +4 -4
- package/dist/lib/cli-resources.d.ts +36 -8
- package/dist/lib/cli-resources.js +268 -46
- package/dist/lib/cloud/factory.d.ts +1 -1
- package/dist/lib/cloud/factory.js +1 -1
- package/dist/lib/events.d.ts +16 -2
- package/dist/lib/events.js +33 -2
- package/dist/lib/exec.d.ts +39 -11
- package/dist/lib/exec.js +90 -31
- package/dist/lib/help.js +11 -5
- package/dist/lib/hooks/cache.d.ts +38 -0
- package/dist/lib/hooks/cache.js +242 -0
- package/dist/lib/hooks/profile.d.ts +33 -0
- package/dist/lib/hooks/profile.js +129 -0
- package/dist/lib/hooks.d.ts +0 -10
- package/dist/lib/hooks.js +68 -15
- package/dist/lib/import.d.ts +21 -0
- package/dist/lib/import.js +55 -2
- package/dist/lib/mcp.d.ts +15 -0
- package/dist/lib/mcp.js +40 -0
- package/dist/lib/permissions.d.ts +13 -0
- package/dist/lib/permissions.js +51 -1
- package/dist/lib/plugin-marketplace.d.ts +10 -0
- package/dist/lib/plugin-marketplace.js +47 -1
- package/dist/lib/plugins.js +15 -1
- package/dist/lib/profiles-presets.d.ts +26 -0
- package/dist/lib/profiles-presets.js +187 -8
- package/dist/lib/profiles.d.ts +34 -0
- package/dist/lib/profiles.js +112 -1
- package/dist/lib/pty-server.js +27 -3
- package/dist/lib/routines-format.d.ts +17 -5
- package/dist/lib/routines-format.js +37 -16
- package/dist/lib/routines.d.ts +1 -1
- package/dist/lib/routines.js +2 -2
- package/dist/lib/runner.js +64 -10
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
- package/dist/lib/secrets/bundles.d.ts +18 -22
- package/dist/lib/secrets/bundles.js +75 -99
- package/dist/lib/secrets/index.d.ts +51 -27
- package/dist/lib/secrets/index.js +147 -156
- package/dist/lib/secrets/install-helper.d.ts +45 -0
- package/dist/lib/secrets/install-helper.js +165 -0
- package/dist/lib/secrets/linux.js +4 -4
- package/dist/lib/secrets/sync.d.ts +56 -0
- package/dist/lib/secrets/sync.js +180 -0
- package/dist/lib/session/render.js +4 -4
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/shims.d.ts +4 -1
- package/dist/lib/shims.js +5 -35
- package/dist/lib/state.d.ts +14 -1
- package/dist/lib/state.js +49 -5
- package/dist/lib/teams/agents.d.ts +5 -4
- package/dist/lib/teams/agents.js +47 -21
- package/dist/lib/teams/api.d.ts +2 -1
- package/dist/lib/teams/api.js +4 -3
- package/dist/lib/types.d.ts +57 -1
- package/dist/lib/types.js +2 -0
- package/dist/lib/usage.d.ts +27 -2
- package/dist/lib/usage.js +100 -17
- package/dist/lib/versions.d.ts +35 -1
- package/dist/lib/versions.js +288 -64
- package/package.json +13 -12
- package/scripts/install-helper.js +97 -0
- package/scripts/postinstall.js +16 -0
- 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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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: ['--
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
edit: [
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
+
// grok --help lists `--permission-mode plan`; the TUI defaults to ask.
|
|
299
|
+
plan: ['--permission-mode', 'plan'],
|
|
250
300
|
edit: [],
|
|
251
|
-
|
|
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
|
-
//
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 {};
|