@phnx-labs/agents-cli 1.20.17 → 1.20.18
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 +15 -0
- package/README.md +1 -1
- package/dist/commands/budget.d.ts +14 -0
- package/dist/commands/budget.js +137 -0
- package/dist/commands/cost.d.ts +12 -0
- package/dist/commands/cost.js +139 -0
- package/dist/commands/exec.d.ts +20 -0
- package/dist/commands/exec.js +382 -5
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.js +250 -4
- package/dist/commands/sessions.js +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/budget/config.d.ts +9 -0
- package/dist/lib/budget/config.js +115 -0
- package/dist/lib/budget/enforce.d.ts +94 -0
- package/dist/lib/budget/enforce.js +151 -0
- package/dist/lib/budget/ledger.d.ts +61 -0
- package/dist/lib/budget/ledger.js +107 -0
- package/dist/lib/budget/preflight.d.ts +110 -0
- package/dist/lib/budget/preflight.js +200 -0
- package/dist/lib/checkpoint.d.ts +54 -0
- package/dist/lib/checkpoint.js +56 -0
- package/dist/lib/cloud/rush.js +18 -0
- package/dist/lib/exec.d.ts +36 -0
- package/dist/lib/exec.js +192 -4
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +67 -4
- package/dist/lib/loop.d.ts +145 -0
- package/dist/lib/loop.js +330 -0
- package/dist/lib/mcp.d.ts +7 -0
- package/dist/lib/mcp.js +24 -0
- package/dist/lib/models.d.ts +11 -0
- package/dist/lib/models.js +21 -0
- package/dist/lib/plugins.js +5 -2
- package/dist/lib/pricing/cost.d.ts +46 -0
- package/dist/lib/pricing/cost.js +71 -0
- package/dist/lib/pricing/index.d.ts +8 -0
- package/dist/lib/pricing/index.js +8 -0
- package/dist/lib/pricing/prices.json +138 -0
- package/dist/lib/pricing/table.d.ts +17 -0
- package/dist/lib/pricing/table.js +73 -0
- 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/agent.d.ts +134 -0
- package/dist/lib/secrets/agent.js +501 -0
- package/dist/lib/secrets/bundles.d.ts +21 -0
- package/dist/lib/secrets/bundles.js +43 -0
- package/dist/lib/session/db.d.ts +40 -0
- package/dist/lib/session/db.js +84 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +126 -2
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/teams/agents.d.ts +32 -0
- package/dist/lib/teams/agents.js +66 -3
- package/dist/lib/teams/api.js +20 -0
- package/dist/lib/teams/parsers.js +16 -4
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/workflows.d.ts +56 -0
- package/dist/lib/workflows.js +72 -5
- package/package.json +2 -1
package/dist/commands/secrets.js
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
|
-
import {
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import { bundleExists, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
11
12
|
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
12
13
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
14
|
+
import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, runSecretsAgent, } from '../lib/secrets/agent.js';
|
|
15
|
+
import { parseDuration } from '../lib/hooks/cache.js';
|
|
13
16
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
14
17
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
15
18
|
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
@@ -130,6 +133,39 @@ function readStdinSync() {
|
|
|
130
133
|
}
|
|
131
134
|
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
132
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* SSH target for `export --to-ssh`: a bare ssh-config host alias (e.g. `yosemite-s0`)
|
|
138
|
+
* or `user@host`. The strict allowlist blocks shell metacharacters and a leading `-`
|
|
139
|
+
* so a target can't be smuggled in as an ssh argv flag.
|
|
140
|
+
*/
|
|
141
|
+
export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
142
|
+
export function assertValidSshTarget(host) {
|
|
143
|
+
if (!SSH_TARGET_RE.test(host)) {
|
|
144
|
+
throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host (letters, digits, '.', '_', '-').`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
148
|
+
function shellQuote(s) {
|
|
149
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Serialize a resolved env map to `.env` lines that round-trip losslessly through
|
|
153
|
+
* `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
|
|
154
|
+
* quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
|
|
155
|
+
* value survives unchanged with no escaping. Newlines would break its line-based
|
|
156
|
+
* parse, so multi-line values are rejected rather than silently corrupted.
|
|
157
|
+
*/
|
|
158
|
+
export function bundleEnvToDotenv(env) {
|
|
159
|
+
const lines = [];
|
|
160
|
+
for (const [k, v] of Object.entries(env)) {
|
|
161
|
+
if (/[\r\n]/.test(v)) {
|
|
162
|
+
throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
|
|
163
|
+
`Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
|
|
164
|
+
}
|
|
165
|
+
lines.push(`${k}="${v}"`);
|
|
166
|
+
}
|
|
167
|
+
return lines.join('\n') + '\n';
|
|
168
|
+
}
|
|
133
169
|
/** Strip ANSI escape sequences so padding can be computed on visible width. */
|
|
134
170
|
function visibleWidth(s) {
|
|
135
171
|
// eslint-disable-next-line no-control-regex
|
|
@@ -346,6 +382,9 @@ export function registerSecretsCommands(program) {
|
|
|
346
382
|
# Eval the bundle into your current shell
|
|
347
383
|
eval "$(agents secrets export prod --plaintext)"
|
|
348
384
|
|
|
385
|
+
# Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
|
|
386
|
+
agents secrets export prod --to-ssh --host yosemite-s0 --host yosemite-s1 --force
|
|
387
|
+
|
|
349
388
|
# Run a one-off command with secrets injected
|
|
350
389
|
agents secrets exec prod -- ./deploy.sh
|
|
351
390
|
`,
|
|
@@ -354,7 +393,15 @@ export function registerSecretsCommands(program) {
|
|
|
354
393
|
never touch disk in plaintext. Every item is device-local and gated by Touch ID
|
|
355
394
|
or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
|
|
356
395
|
|
|
396
|
+
Touch ID noise: macOS pops a prompt per bundle per process, so concurrent
|
|
397
|
+
agents each re-prompt. 'agents secrets unlock <bundle>' holds the resolved
|
|
398
|
+
bundle in a local agent after one prompt; later runs read it silently until
|
|
399
|
+
it expires (default 24h), you 'lock' it, or the screen locks. Nothing on disk.
|
|
400
|
+
|
|
357
401
|
See also:
|
|
402
|
+
agents secrets unlock <bundle> hold a bundle after one Touch ID
|
|
403
|
+
agents secrets lock wipe held bundles (re-prompt next read)
|
|
404
|
+
agents secrets status show held bundles + when they lock
|
|
358
405
|
agents secrets rotate <bundle> <key> rotate value, preserve metadata
|
|
359
406
|
agents secrets import <bundle> --from .env bulk import from .env
|
|
360
407
|
agents secrets import <bundle> --from-1password --vault <name>
|
|
@@ -365,6 +412,7 @@ export function registerSecretsCommands(program) {
|
|
|
365
412
|
registerCommandGroups(cmd, [
|
|
366
413
|
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
|
|
367
414
|
{ title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
|
|
415
|
+
{ title: 'Agent commands', names: ['unlock', 'lock', 'status', 'tier'] },
|
|
368
416
|
{ title: 'Raw item commands', names: ['get', 'set'] },
|
|
369
417
|
{ title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
|
|
370
418
|
{ title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
|
|
@@ -401,6 +449,8 @@ export function registerSecretsCommands(program) {
|
|
|
401
449
|
console.log(chalk.gray(safePrint(bundle.description)));
|
|
402
450
|
if (bundle.allow_exec)
|
|
403
451
|
console.log(chalk.yellow('allow_exec: true'));
|
|
452
|
+
if (bundleTier(bundle) === 'session')
|
|
453
|
+
console.log(chalk.gray('tier: session (secrets-agent eligible)'));
|
|
404
454
|
if (bundle.created_at)
|
|
405
455
|
console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
|
|
406
456
|
if (bundle.updated_at)
|
|
@@ -522,11 +572,13 @@ export function registerSecretsCommands(program) {
|
|
|
522
572
|
.description('Create an empty bundle')
|
|
523
573
|
.option('--description <text>', 'Free-form description')
|
|
524
574
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
575
|
+
.option('--tier <tier>', 'secrets-agent tier: biometry (default) or session', 'biometry')
|
|
525
576
|
.option('--force', 'Overwrite an existing bundle')
|
|
526
577
|
.action(async (name, opts) => {
|
|
527
578
|
try {
|
|
528
579
|
const resolvedName = name ?? (await promptBundleName());
|
|
529
580
|
validateBundleName(resolvedName);
|
|
581
|
+
const tier = parseTierOpt(opts.tier);
|
|
530
582
|
if (bundleExists(resolvedName) && !opts.force) {
|
|
531
583
|
console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
|
|
532
584
|
process.exit(1);
|
|
@@ -535,10 +587,11 @@ export function registerSecretsCommands(program) {
|
|
|
535
587
|
name: resolvedName,
|
|
536
588
|
description: opts.description,
|
|
537
589
|
allow_exec: opts.allowExec,
|
|
590
|
+
tier,
|
|
538
591
|
vars: {},
|
|
539
592
|
};
|
|
540
593
|
writeBundle(bundle);
|
|
541
|
-
console.log(chalk.green(`Bundle '${resolvedName}' created.`));
|
|
594
|
+
console.log(chalk.green(`Bundle '${resolvedName}' created${tier === 'session' ? ' (tier: session)' : ''}.`));
|
|
542
595
|
console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
|
|
543
596
|
}
|
|
544
597
|
catch (err) {
|
|
@@ -951,15 +1004,61 @@ Examples:
|
|
|
951
1004
|
});
|
|
952
1005
|
cmd
|
|
953
1006
|
.command('export [bundle]')
|
|
954
|
-
.description('Resolve a bundle and print KEY=VALUE lines,
|
|
1007
|
+
.description('Resolve a bundle and print KEY=VALUE lines, push it to a 1Password vault with --to-1password, or push it to remote machine(s) over SSH with --to-ssh.')
|
|
955
1008
|
.option('--plaintext', 'Acknowledge that the resolved values will be printed in the clear (shell export mode)')
|
|
956
1009
|
.option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
|
|
957
1010
|
.option('--vault <name>', '1Password vault name (used with --to-1password)')
|
|
958
|
-
.option('--
|
|
1011
|
+
.option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
|
|
1012
|
+
.option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
|
|
1013
|
+
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
|
|
959
1014
|
.action(async (bundleName, opts) => {
|
|
960
1015
|
try {
|
|
961
1016
|
const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
|
|
962
1017
|
const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
|
|
1018
|
+
if (opts.toSsh) {
|
|
1019
|
+
const hosts = opts.host ?? [];
|
|
1020
|
+
if (hosts.length === 0) {
|
|
1021
|
+
throw new Error('--to-ssh requires at least one --host <target>.');
|
|
1022
|
+
}
|
|
1023
|
+
for (const h of hosts)
|
|
1024
|
+
assertValidSshTarget(h);
|
|
1025
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
|
|
1026
|
+
const dotenv = bundleEnvToDotenv(env);
|
|
1027
|
+
const keyCount = Object.keys(env).length;
|
|
1028
|
+
// Drive the remote's own `agents secrets` CLI so values land in its native
|
|
1029
|
+
// backend (Keychain on macOS, libsecret / encrypted-file on Linux). Create
|
|
1030
|
+
// the bundle if missing, then import the piped .env. `bash -lc` so the login
|
|
1031
|
+
// PATH resolves `agents`; the .env flows over ssh stdin and is never parsed
|
|
1032
|
+
// by a remote shell.
|
|
1033
|
+
const force = opts.force ? ' --force' : '';
|
|
1034
|
+
const remoteAgents = `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
|
|
1035
|
+
`agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
|
|
1036
|
+
const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
|
|
1037
|
+
let failures = 0;
|
|
1038
|
+
for (const host of hosts) {
|
|
1039
|
+
const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
|
|
1040
|
+
input: dotenv,
|
|
1041
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1042
|
+
encoding: 'utf-8',
|
|
1043
|
+
});
|
|
1044
|
+
if (res.error) {
|
|
1045
|
+
failures++;
|
|
1046
|
+
console.error(chalk.red(`${host}: ${res.error.message}`));
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (res.status !== 0) {
|
|
1050
|
+
failures++;
|
|
1051
|
+
const msg = (res.stderr || res.stdout || '').trim();
|
|
1052
|
+
console.error(chalk.red(`${host}: remote import failed (exit ${res.status ?? 'signal'})${msg ? `: ${msg}` : ''}`));
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
1056
|
+
console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
|
|
1057
|
+
}
|
|
1058
|
+
if (failures > 0)
|
|
1059
|
+
process.exit(1);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
963
1062
|
if (opts.to1password) {
|
|
964
1063
|
assertOpAvailable();
|
|
965
1064
|
const vault = await resolveVault(opts.vault);
|
|
@@ -1118,9 +1217,156 @@ Examples:
|
|
|
1118
1217
|
console.log(password);
|
|
1119
1218
|
}
|
|
1120
1219
|
});
|
|
1220
|
+
cmd
|
|
1221
|
+
.command('unlock [names...]')
|
|
1222
|
+
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
|
|
1223
|
+
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default 24h.')
|
|
1224
|
+
.option('--all', 'Unlock every configured bundle')
|
|
1225
|
+
.action(async (names, opts) => {
|
|
1226
|
+
if (process.platform !== 'darwin') {
|
|
1227
|
+
console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
|
|
1228
|
+
process.exit(1);
|
|
1229
|
+
}
|
|
1230
|
+
let targets = opts.all ? listBundles().map((b) => b.name) : names;
|
|
1231
|
+
if (!targets || targets.length === 0) {
|
|
1232
|
+
console.error(chalk.red('Specify one or more bundle names, or --all.'));
|
|
1233
|
+
process.exit(1);
|
|
1234
|
+
}
|
|
1235
|
+
let ttlMs = DEFAULT_TTL_MS;
|
|
1236
|
+
if (opts.ttl) {
|
|
1237
|
+
const secs = parseDuration(opts.ttl);
|
|
1238
|
+
if (!secs) {
|
|
1239
|
+
console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
ttlMs = secs * 1000;
|
|
1243
|
+
}
|
|
1244
|
+
if (!(await ensureAgentRunning())) {
|
|
1245
|
+
console.error(chalk.red('Could not start the secrets-agent.'));
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
let loaded = 0;
|
|
1249
|
+
for (const name of targets) {
|
|
1250
|
+
try {
|
|
1251
|
+
// noAgent: read the real keychain (one Touch ID) rather than the
|
|
1252
|
+
// agent we're about to populate.
|
|
1253
|
+
const { bundle, env } = readAndResolveBundleEnv(name, { noAgent: true, caller: 'unlock' });
|
|
1254
|
+
if (await agentLoad(name, bundle, env, ttlMs)) {
|
|
1255
|
+
loaded++;
|
|
1256
|
+
console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
console.error(chalk.red(`Failed to load '${name}' into the agent.`));
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
catch (err) {
|
|
1263
|
+
if (isPromptCancelled(err)) {
|
|
1264
|
+
console.error(chalk.yellow(`Cancelled unlocking '${name}'.`));
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
console.error(chalk.red(`${name}: ${err.message}`));
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (loaded === 0)
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
});
|
|
1273
|
+
cmd
|
|
1274
|
+
.command('lock [names...]')
|
|
1275
|
+
.description('Wipe bundles from the secrets-agent (forces Touch ID again next read). Default: all.')
|
|
1276
|
+
.option('--all', 'Wipe every unlocked bundle (same as no names)')
|
|
1277
|
+
.action(async (names, opts) => {
|
|
1278
|
+
if (process.platform !== 'darwin')
|
|
1279
|
+
return; // nothing to lock off darwin
|
|
1280
|
+
if (names && names.length > 0 && !opts.all) {
|
|
1281
|
+
let total = 0;
|
|
1282
|
+
for (const name of names)
|
|
1283
|
+
total += await agentLock(name);
|
|
1284
|
+
console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
const wiped = await agentLock();
|
|
1288
|
+
console.log(wiped > 0 ? chalk.green(`Locked ${wiped} bundle(s).`) : chalk.gray('Nothing to lock.'));
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
cmd
|
|
1292
|
+
.command('status')
|
|
1293
|
+
.description('Show which bundles the secrets-agent currently holds and when they lock.')
|
|
1294
|
+
.action(async () => {
|
|
1295
|
+
if (process.platform !== 'darwin') {
|
|
1296
|
+
console.log(chalk.gray('secrets-agent is macOS-only.'));
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const entries = await agentStatus();
|
|
1300
|
+
if (entries.length === 0) {
|
|
1301
|
+
console.log(chalk.gray('No bundles unlocked. The secrets-agent is idle or not running.'));
|
|
1302
|
+
console.log(chalk.gray('Try: agents secrets unlock <bundle>'));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
|
|
1306
|
+
for (const e of entries) {
|
|
1307
|
+
console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
cmd
|
|
1311
|
+
.command('tier <bundle> [tier]')
|
|
1312
|
+
.description("Show or set a bundle's secrets-agent tier: biometry (default) or session.")
|
|
1313
|
+
.action((bundleName, tier) => {
|
|
1314
|
+
try {
|
|
1315
|
+
const bundle = readBundle(bundleName);
|
|
1316
|
+
if (tier === undefined) {
|
|
1317
|
+
console.log(`${chalk.cyan(bundle.name)} tier: ${chalk.bold(bundleTier(bundle))}`);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const next = parseTierOpt(tier);
|
|
1321
|
+
bundle.tier = next;
|
|
1322
|
+
writeBundle(bundle);
|
|
1323
|
+
console.log(chalk.green(`${bundle.name} tier set to ${next}.`));
|
|
1324
|
+
if (next === 'session') {
|
|
1325
|
+
console.log(chalk.gray('Eligible for the secrets-agent: unlock it, or enable auto-cache with `secrets.agent.auto: true` in agents.yaml.'));
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
catch (err) {
|
|
1329
|
+
console.error(chalk.red(err.message));
|
|
1330
|
+
process.exit(1);
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
cmd
|
|
1334
|
+
.command('_agent-run', { hidden: true })
|
|
1335
|
+
.description('Run the secrets-agent broker in the foreground (internal)')
|
|
1336
|
+
.action(async () => {
|
|
1337
|
+
await runSecretsAgent();
|
|
1338
|
+
});
|
|
1121
1339
|
registerSecretsSyncCommands(cmd);
|
|
1122
1340
|
registerSecretsMigrateAclCommand(cmd);
|
|
1123
1341
|
}
|
|
1342
|
+
/** Validate a --tier value, exiting with a clear message on a bad one. `none`
|
|
1343
|
+
* is rejected explicitly: it would require storing items without the biometry
|
|
1344
|
+
* ACL (a separate signed-helper change), so it isn't offered yet. */
|
|
1345
|
+
function parseTierOpt(raw) {
|
|
1346
|
+
const v = (raw ?? 'biometry').toLowerCase();
|
|
1347
|
+
if (v === 'biometry' || v === 'session')
|
|
1348
|
+
return v;
|
|
1349
|
+
if (v === 'none') {
|
|
1350
|
+
console.error(chalk.red("tier 'none' (no biometry ACL) is not available yet — use 'biometry' or 'session'."));
|
|
1351
|
+
process.exit(1);
|
|
1352
|
+
}
|
|
1353
|
+
console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
|
|
1354
|
+
process.exit(1);
|
|
1355
|
+
}
|
|
1356
|
+
/** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
|
|
1357
|
+
function humanRemaining(expiresAt) {
|
|
1358
|
+
const ms = expiresAt - Date.now();
|
|
1359
|
+
if (ms <= 0)
|
|
1360
|
+
return 'expired';
|
|
1361
|
+
const mins = Math.round(ms / 60000);
|
|
1362
|
+
if (mins < 60)
|
|
1363
|
+
return `locks in ${mins} minute${mins === 1 ? '' : 's'}`;
|
|
1364
|
+
const hours = Math.round(mins / 60);
|
|
1365
|
+
if (hours < 24)
|
|
1366
|
+
return `locks in ${hours} hour${hours === 1 ? '' : 's'}`;
|
|
1367
|
+
const days = Math.round(hours / 24);
|
|
1368
|
+
return `locks in ${days} day${days === 1 ? '' : 's'}`;
|
|
1369
|
+
}
|
|
1124
1370
|
/**
|
|
1125
1371
|
* Copy text to the system clipboard, cross-platform.
|
|
1126
1372
|
* macOS: `pbcopy`. Windows: `clip`. Linux: tries `wl-copy` (Wayland), then
|
|
@@ -376,6 +376,8 @@ async function sessionsAction(query, options) {
|
|
|
376
376
|
// Without this, a dev dir with heavy SDK spawn activity (Task subagents,
|
|
377
377
|
// `agents run`, team agents) can fill the top-N window entirely with
|
|
378
378
|
// hidden rows and make real CLI sessions appear to vanish.
|
|
379
|
+
// 'recent' is the user-facing alias for the default timestamp sort.
|
|
380
|
+
const sortBy = options.sort === 'cost' ? 'cost' : options.sort === 'duration' ? 'duration' : 'timestamp';
|
|
379
381
|
const scope = {
|
|
380
382
|
agent,
|
|
381
383
|
version,
|
|
@@ -385,6 +387,7 @@ async function sessionsAction(query, options) {
|
|
|
385
387
|
project: options.project,
|
|
386
388
|
since,
|
|
387
389
|
until: options.until,
|
|
390
|
+
sortBy,
|
|
388
391
|
};
|
|
389
392
|
let sessions = await discoverSessions({
|
|
390
393
|
...scope,
|
|
@@ -1102,6 +1105,7 @@ export function registerSessionsCommands(program) {
|
|
|
1102
1105
|
.option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
|
|
1103
1106
|
.option('--until <time>', 'Only sessions older than this (ISO timestamp)')
|
|
1104
1107
|
.option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
|
|
1108
|
+
.option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
|
|
1105
1109
|
.option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
|
|
1106
1110
|
.option('--no-redact', 'Disable default secret redaction in markdown session output')
|
|
1107
1111
|
.option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
|
package/dist/index.js
CHANGED
|
@@ -97,6 +97,8 @@ import { registerWalletCommands } from './commands/wallet.js';
|
|
|
97
97
|
import { registerHelperCommand } from './commands/helper.js';
|
|
98
98
|
import { registerFactoryCommands } from './commands/factory.js';
|
|
99
99
|
import { registerUsageCommand } from './commands/usage.js';
|
|
100
|
+
import { registerCostCommand } from './commands/cost.js';
|
|
101
|
+
import { registerBudgetCommand } from './commands/budget.js';
|
|
100
102
|
import { registerAliasCommand } from './commands/alias.js';
|
|
101
103
|
import { registerBetaCommands } from './commands/beta.js';
|
|
102
104
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
@@ -679,6 +681,8 @@ registerRefreshRulesCommand(program);
|
|
|
679
681
|
registerDriveCommands(program);
|
|
680
682
|
registerFactoryCommands(program);
|
|
681
683
|
registerUsageCommand(program);
|
|
684
|
+
registerCostCommand(program);
|
|
685
|
+
registerBudgetCommand(program);
|
|
682
686
|
registerAliasCommand(program);
|
|
683
687
|
registerPtyCommands(program);
|
|
684
688
|
registerTmuxCommands(program);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BudgetConfig } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Effective budget for `cwd`: user/global base, then each project-local block
|
|
4
|
+
* from farthest ancestor to nearest, nearest winning. `on_exceed` defaults to
|
|
5
|
+
* `block` when nothing sets it (fail-closed: the safe default is to enforce).
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveBudgetConfig(cwd?: string): BudgetConfig;
|
|
8
|
+
/** True when at least one enforceable cap is set. No caps => budget feature is dormant. */
|
|
9
|
+
export declare function hasAnyCap(cfg: BudgetConfig): boolean;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget config resolution (issue #346).
|
|
3
|
+
*
|
|
4
|
+
* The `budget:` block can live in the user/global agents.yaml (`readMeta().budget`)
|
|
5
|
+
* and in any project-local agents.yaml walked from cwd upward. Precedence is
|
|
6
|
+
* project > user, matching `run:` resolution (lib/run-config.ts). Caps merge
|
|
7
|
+
* field-by-field — a project that sets only `per_run` inherits the user's
|
|
8
|
+
* `per_day`/`per_project`/`per_agent` rather than wiping them.
|
|
9
|
+
*
|
|
10
|
+
* This is the single resolver the pre-flight gate, the live watcher, and the
|
|
11
|
+
* `agents budget` command all route through, so the effective cap set is
|
|
12
|
+
* computed in exactly one place.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import * as yaml from 'yaml';
|
|
17
|
+
import { getUserAgentsDir, readMeta } from '../state.js';
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Coerce a raw parsed `budget:` block into a typed BudgetConfig, dropping any
|
|
23
|
+
* field whose value is the wrong shape. Malformed entries are ignored, not
|
|
24
|
+
* thrown — a typo in one cap must never crash a run (no-fallbacks applies to
|
|
25
|
+
* the data path, not to user-typed config we choose to be lenient about).
|
|
26
|
+
*/
|
|
27
|
+
function coerceBudget(raw) {
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
return {};
|
|
30
|
+
const out = {};
|
|
31
|
+
if (typeof raw.currency === 'string')
|
|
32
|
+
out.currency = raw.currency;
|
|
33
|
+
if (typeof raw.per_run === 'number' && raw.per_run >= 0)
|
|
34
|
+
out.per_run = raw.per_run;
|
|
35
|
+
if (typeof raw.per_day === 'number' && raw.per_day >= 0)
|
|
36
|
+
out.per_day = raw.per_day;
|
|
37
|
+
if (typeof raw.per_project === 'number' && raw.per_project >= 0)
|
|
38
|
+
out.per_project = raw.per_project;
|
|
39
|
+
if (raw.on_exceed === 'block' || raw.on_exceed === 'warn')
|
|
40
|
+
out.on_exceed = raw.on_exceed;
|
|
41
|
+
if (typeof raw.require_confirm_over === 'number' && raw.require_confirm_over >= 0) {
|
|
42
|
+
out.require_confirm_over = raw.require_confirm_over;
|
|
43
|
+
}
|
|
44
|
+
if (isRecord(raw.per_agent)) {
|
|
45
|
+
const perAgent = {};
|
|
46
|
+
for (const [k, v] of Object.entries(raw.per_agent)) {
|
|
47
|
+
if (typeof v === 'number' && v >= 0)
|
|
48
|
+
perAgent[k] = v;
|
|
49
|
+
}
|
|
50
|
+
if (Object.keys(perAgent).length > 0)
|
|
51
|
+
out.per_agent = perAgent;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** Merge a higher-precedence budget over a base. Set fields win; per_agent merges key-by-key. */
|
|
56
|
+
function mergeBudget(base, over) {
|
|
57
|
+
const merged = { ...base, ...stripUndefined(over) };
|
|
58
|
+
if (base.per_agent || over.per_agent) {
|
|
59
|
+
merged.per_agent = { ...(base.per_agent ?? {}), ...(over.per_agent ?? {}) };
|
|
60
|
+
}
|
|
61
|
+
return merged;
|
|
62
|
+
}
|
|
63
|
+
function stripUndefined(cfg) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
66
|
+
if (v !== undefined)
|
|
67
|
+
out[k] = v;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** Read project-local `budget:` blocks from nearest dir upward, nearest LAST (highest precedence). */
|
|
72
|
+
function getProjectBudgets(startPath) {
|
|
73
|
+
const configs = [];
|
|
74
|
+
let dir = path.resolve(startPath);
|
|
75
|
+
const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
|
|
76
|
+
while (dir !== path.dirname(dir)) {
|
|
77
|
+
const manifestPath = path.join(dir, 'agents.yaml');
|
|
78
|
+
if (manifestPath !== userAgentsYaml && fs.existsSync(manifestPath)) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = yaml.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
81
|
+
if (isRecord(parsed) && parsed.budget !== undefined) {
|
|
82
|
+
configs.push(coerceBudget(parsed.budget));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Malformed project config — ignore and keep walking.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
dir = path.dirname(dir);
|
|
90
|
+
}
|
|
91
|
+
// configs[0] is the nearest dir. Reverse so the nearest applies LAST (wins).
|
|
92
|
+
return configs.reverse();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Effective budget for `cwd`: user/global base, then each project-local block
|
|
96
|
+
* from farthest ancestor to nearest, nearest winning. `on_exceed` defaults to
|
|
97
|
+
* `block` when nothing sets it (fail-closed: the safe default is to enforce).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveBudgetConfig(cwd = process.cwd()) {
|
|
100
|
+
const userBudget = coerceBudget(readMeta().budget);
|
|
101
|
+
let merged = userBudget;
|
|
102
|
+
for (const projectBudget of getProjectBudgets(cwd)) {
|
|
103
|
+
merged = mergeBudget(merged, projectBudget);
|
|
104
|
+
}
|
|
105
|
+
if (merged.on_exceed === undefined)
|
|
106
|
+
merged.on_exceed = 'block';
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
/** True when at least one enforceable cap is set. No caps => budget feature is dormant. */
|
|
110
|
+
export function hasAnyCap(cfg) {
|
|
111
|
+
return (cfg.per_run !== undefined ||
|
|
112
|
+
cfg.per_day !== undefined ||
|
|
113
|
+
cfg.per_project !== undefined ||
|
|
114
|
+
(cfg.per_agent !== undefined && Object.keys(cfg.per_agent).length > 0));
|
|
115
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live spend watcher + cap math (issue #346).
|
|
3
|
+
*
|
|
4
|
+
* This is the provider-agnostic shared surface the loop driver (#332) will
|
|
5
|
+
* reuse for its budget guard. It knows nothing about child processes, agents,
|
|
6
|
+
* or the ledger — it accepts parsed usage events and a caps object, accumulates
|
|
7
|
+
* cost via the canonical pricing module, and fires `onBreach` exactly once when
|
|
8
|
+
* any active cap is crossed.
|
|
9
|
+
*
|
|
10
|
+
* The accumulation is the cross-vendor primitive: feed Claude usage and Codex
|
|
11
|
+
* usage to the same watcher under one `per_project` / `per_run` cap and the
|
|
12
|
+
* spend aggregates across both — no single-vendor control can do that.
|
|
13
|
+
*/
|
|
14
|
+
import type { AgentId, BudgetConfig } from '../types.js';
|
|
15
|
+
/** A parsed usage event from any agent's stream (fields match session/parse). */
|
|
16
|
+
export interface UsageEvent {
|
|
17
|
+
agent?: AgentId | string;
|
|
18
|
+
model?: string;
|
|
19
|
+
inputTokens?: number;
|
|
20
|
+
outputTokens?: number;
|
|
21
|
+
cacheReadTokens?: number;
|
|
22
|
+
cacheCreationTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Caps the watcher enforces. `priorDaySpend` / `priorProjectSpend` seed the
|
|
26
|
+
* accumulators with spend already on the ledger BEFORE this run started, so a
|
|
27
|
+
* per_day cap counts today's earlier runs too — not just this process. Per-cap
|
|
28
|
+
* fields are USD; undefined means "not enforced".
|
|
29
|
+
*/
|
|
30
|
+
export interface LiveCaps {
|
|
31
|
+
perRun?: number;
|
|
32
|
+
perDay?: number;
|
|
33
|
+
perProject?: number;
|
|
34
|
+
/** Per-agent daily caps. Each agent's running spend is checked against its own cap. */
|
|
35
|
+
perAgent?: Partial<Record<string, number>>;
|
|
36
|
+
/** Day spend already on the ledger before this run (cross-vendor). */
|
|
37
|
+
priorDaySpend?: number;
|
|
38
|
+
/** Project spend already on the ledger before this run (cross-vendor). */
|
|
39
|
+
priorProjectSpend?: number;
|
|
40
|
+
/** Per-agent day spend already on the ledger before this run, keyed by agent. */
|
|
41
|
+
priorAgentDaySpend?: Partial<Record<string, number>>;
|
|
42
|
+
}
|
|
43
|
+
/** Which cap tripped, and the spend figures at the moment of the breach. */
|
|
44
|
+
export interface BreachInfo {
|
|
45
|
+
cap: 'per_run' | 'per_day' | 'per_project' | 'per_agent';
|
|
46
|
+
/** The configured limit that was crossed (USD). */
|
|
47
|
+
limit: number;
|
|
48
|
+
/** The spend that crossed it (USD). */
|
|
49
|
+
spend: number;
|
|
50
|
+
/** Agent attributed to the breach (only meaningful for per_agent). */
|
|
51
|
+
agent?: string;
|
|
52
|
+
/** This run's accumulated spend so far (USD). */
|
|
53
|
+
runSpend: number;
|
|
54
|
+
}
|
|
55
|
+
/** Public watcher surface. `feedUsage` is idempotent after a breach (no double-fire). */
|
|
56
|
+
export interface LiveSpendWatcher {
|
|
57
|
+
/** Feed one parsed usage event; accrues cost and may fire onBreach. */
|
|
58
|
+
feedUsage(event: UsageEvent): void;
|
|
59
|
+
/** Total USD this run has accumulated across all fed events. */
|
|
60
|
+
runSpend(): number;
|
|
61
|
+
/** True once a cap has been breached. */
|
|
62
|
+
breached(): boolean;
|
|
63
|
+
/** Stop accepting events / release references. Idempotent. */
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
/** Convert a resolved BudgetConfig + prior ledger spend into the caps the watcher needs. */
|
|
67
|
+
export declare function capsFromConfig(cfg: BudgetConfig, prior?: {
|
|
68
|
+
daySpend?: number;
|
|
69
|
+
projectSpend?: number;
|
|
70
|
+
agentDaySpend?: Partial<Record<string, number>>;
|
|
71
|
+
}): LiveCaps;
|
|
72
|
+
/**
|
|
73
|
+
* Create a live spend watcher. `onBreach` fires at most once, on the first
|
|
74
|
+
* event that pushes any active cap from at-or-under to over. After it fires the
|
|
75
|
+
* watcher keeps accumulating (so `runSpend()` stays accurate for the final
|
|
76
|
+
* ledger record) but never calls `onBreach` again.
|
|
77
|
+
*/
|
|
78
|
+
export declare function makeLiveSpendWatcher(args: {
|
|
79
|
+
caps: LiveCaps;
|
|
80
|
+
onBreach: (breach: BreachInfo) => void;
|
|
81
|
+
}): LiveSpendWatcher;
|
|
82
|
+
/**
|
|
83
|
+
* Incrementally extract usage events from a stream-json chunk. Buffers a partial
|
|
84
|
+
* trailing line across calls (returned in `rest`), parses each complete line,
|
|
85
|
+
* and yields one UsageEvent per line that carries token counts. Provider shapes
|
|
86
|
+
* handled: Claude/`--json` assistant turns (`message.usage` with
|
|
87
|
+
* `input_tokens`/`output_tokens`/`cache_*_input_tokens`) and the flatter
|
|
88
|
+
* `usage.record` shape (`usage.input_tokens`/`output`). Lines that aren't JSON
|
|
89
|
+
* or carry no usage are skipped — this never throws on agent output.
|
|
90
|
+
*/
|
|
91
|
+
export declare function extractUsageEvents(chunk: string, pending: string, fallbackModel?: string, fallbackAgent?: string): {
|
|
92
|
+
events: UsageEvent[];
|
|
93
|
+
rest: string;
|
|
94
|
+
};
|