@phnx-labs/agents-cli 1.20.16 → 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 +19 -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/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- 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/hooks.js +12 -0
- 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/plugin-marketplace.js +16 -6
- 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/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- 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/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -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/commands/sync.d.ts
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
* `agents sync` — synchronize central resources into an installed agent version.
|
|
3
3
|
*
|
|
4
4
|
* Forms:
|
|
5
|
-
* agents sync
|
|
6
|
-
* agents sync
|
|
7
|
-
* agents sync
|
|
5
|
+
* agents sync # umbrella: fetch remote (repos+secrets+sessions) -> reconcile all
|
|
6
|
+
* agents sync --repos|--secrets|--sessions # umbrella: fetch only those, then reconcile
|
|
7
|
+
* agents sync --cloud # umbrella: fetch all, skip reconcile
|
|
8
|
+
* agents sync --local # umbrella: reconcile all, no fetch
|
|
9
|
+
* agents sync claude # one agent: uses default/sole installed version
|
|
10
|
+
* agents sync claude@2.1.142 # one agent: explicit version
|
|
11
|
+
* agents sync claude@latest # one agent: newest installed
|
|
8
12
|
* agents sync --agent claude --agent-version 2.1.142 # legacy form, still supported
|
|
9
13
|
*
|
|
14
|
+
* The umbrella stages live in lib/sync-umbrella.ts; this file dispatches to them
|
|
15
|
+
* when no agent is given.
|
|
16
|
+
*
|
|
10
17
|
* In a TTY the command previews available/new resources and lets the user
|
|
11
18
|
* select what to sync (same prompts shown after `agents add`). Pass
|
|
12
19
|
* --yes for non-interactive auto-sync, --force to re-sync when nothing
|
package/dist/commands/sync.js
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
* `agents sync` — synchronize central resources into an installed agent version.
|
|
3
3
|
*
|
|
4
4
|
* Forms:
|
|
5
|
-
* agents sync
|
|
6
|
-
* agents sync
|
|
7
|
-
* agents sync
|
|
5
|
+
* agents sync # umbrella: fetch remote (repos+secrets+sessions) -> reconcile all
|
|
6
|
+
* agents sync --repos|--secrets|--sessions # umbrella: fetch only those, then reconcile
|
|
7
|
+
* agents sync --cloud # umbrella: fetch all, skip reconcile
|
|
8
|
+
* agents sync --local # umbrella: reconcile all, no fetch
|
|
9
|
+
* agents sync claude # one agent: uses default/sole installed version
|
|
10
|
+
* agents sync claude@2.1.142 # one agent: explicit version
|
|
11
|
+
* agents sync claude@latest # one agent: newest installed
|
|
8
12
|
* agents sync --agent claude --agent-version 2.1.142 # legacy form, still supported
|
|
9
13
|
*
|
|
14
|
+
* The umbrella stages live in lib/sync-umbrella.ts; this file dispatches to them
|
|
15
|
+
* when no agent is given.
|
|
16
|
+
*
|
|
10
17
|
* In a TTY the command previews available/new resources and lets the user
|
|
11
18
|
* select what to sync (same prompts shown after `agents add`). Pass
|
|
12
19
|
* --yes for non-interactive auto-sync, --force to re-sync when nothing
|
|
@@ -25,12 +32,13 @@ import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVers
|
|
|
25
32
|
import { compileRulesForProject } from '../lib/rules/compile.js';
|
|
26
33
|
import { runLaunchSync } from '../lib/project-launch.js';
|
|
27
34
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
35
|
+
import { runUmbrellaSync } from '../lib/sync-umbrella.js';
|
|
28
36
|
/** Register the `agents sync` command. */
|
|
29
37
|
export function registerSyncCommand(program) {
|
|
30
38
|
program
|
|
31
39
|
.command('sync [agentSpec]')
|
|
32
|
-
.summary('
|
|
33
|
-
.description('
|
|
40
|
+
.summary('Make this machine current, or sync resources into one agent')
|
|
41
|
+
.description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude" or "claude@2.1.142".\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
|
|
34
42
|
.option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
|
|
35
43
|
.option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
|
|
36
44
|
.option('--project-dir <path>', 'Path to project-level .agents/ directory containing project-scoped resources')
|
|
@@ -39,10 +47,66 @@ export function registerSyncCommand(program) {
|
|
|
39
47
|
.option('-y, --yes', 'Skip the interactive preview and auto-sync all detected resources', false)
|
|
40
48
|
.option('--force', 'Re-sync even if no changes are detected since the last sync', false)
|
|
41
49
|
.option('--quiet', 'Suppress all output (exit code indicates success)', false)
|
|
50
|
+
// Umbrella verb (no agent given): make this machine current.
|
|
51
|
+
.option('--repos', 'Umbrella: git-pull ~/.agents + enabled ~/.agents-* extras', false)
|
|
52
|
+
.option('--secrets', 'Umbrella: pull encrypted secret bundles from the remote', false)
|
|
53
|
+
.option('--sessions', 'Umbrella: sync session transcripts across machines', false)
|
|
54
|
+
.option('--cloud', 'Umbrella: fetch all remote state but skip the local reconcile', false)
|
|
55
|
+
.option('--local', "Umbrella: reconcile resources into installed agents only (no fetch)", false)
|
|
42
56
|
.action(async (agentSpec, opts) => {
|
|
43
57
|
await runSync(agentSpec, opts);
|
|
44
58
|
});
|
|
45
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The umbrella verb: bare `agents sync` (no agent) makes this machine current.
|
|
62
|
+
* Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
|
|
63
|
+
* arrives with `agents login`) and runs the fetch+reconcile stages, then prints
|
|
64
|
+
* a one-line summary. Stage failures are non-fatal and surfaced as warnings.
|
|
65
|
+
*/
|
|
66
|
+
async function runUmbrella(opts, quiet, outLog, errLog) {
|
|
67
|
+
const flags = {
|
|
68
|
+
repos: opts.repos,
|
|
69
|
+
secrets: opts.secrets,
|
|
70
|
+
sessions: opts.sessions,
|
|
71
|
+
cloud: opts.cloud,
|
|
72
|
+
local: opts.local,
|
|
73
|
+
};
|
|
74
|
+
const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE || undefined;
|
|
75
|
+
if (!quiet)
|
|
76
|
+
outLog(chalk.bold('Syncing this machine…'));
|
|
77
|
+
try {
|
|
78
|
+
const result = await runUmbrellaSync({
|
|
79
|
+
flags,
|
|
80
|
+
yes: !!opts.yes,
|
|
81
|
+
passphrase,
|
|
82
|
+
log: (msg) => { if (!quiet)
|
|
83
|
+
outLog(chalk.gray(` ${msg}`)); },
|
|
84
|
+
});
|
|
85
|
+
if (!quiet) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
if (result.repos) {
|
|
88
|
+
parts.push(`repos ${result.repos.pulled} pulled` +
|
|
89
|
+
(result.repos.errors.length ? `, ${result.repos.errors.length} failed` : ''));
|
|
90
|
+
}
|
|
91
|
+
if (result.secrets) {
|
|
92
|
+
parts.push(result.secrets.skipped ? 'secrets skipped' : `secrets ${result.secrets.pulled} pulled`);
|
|
93
|
+
}
|
|
94
|
+
if (result.sessions) {
|
|
95
|
+
parts.push(result.sessions.ran ? `sessions ${result.sessions.merged} merged` : 'sessions off');
|
|
96
|
+
}
|
|
97
|
+
if (result.reconciled)
|
|
98
|
+
parts.push('reconciled');
|
|
99
|
+
outLog(chalk.green(`✓ sync: ${parts.join(' · ') || 'nothing to do'}`));
|
|
100
|
+
const errs = [...(result.repos?.errors ?? []), ...(result.secrets?.errors ?? [])];
|
|
101
|
+
for (const e of errs)
|
|
102
|
+
errLog(chalk.yellow(` ! ${e}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
errLog(chalk.red(`sync failed: ${err.message}`));
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
46
110
|
async function runSync(agentSpec, opts) {
|
|
47
111
|
const quiet = !!opts.quiet;
|
|
48
112
|
const errLog = (msg) => { if (!quiet)
|
|
@@ -77,10 +141,9 @@ async function runSync(agentSpec, opts) {
|
|
|
77
141
|
version = opts.agentVersion;
|
|
78
142
|
}
|
|
79
143
|
if (!agentId) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
process.exitCode = 1;
|
|
144
|
+
// No agent specified → the umbrella verb: make this machine current
|
|
145
|
+
// (fetch repos + secrets + sessions, then reconcile all installed agents).
|
|
146
|
+
await runUmbrella(opts, quiet, outLog, errLog);
|
|
84
147
|
return;
|
|
85
148
|
}
|
|
86
149
|
// ---------- 2. Resolve version (project pin → global default → sole installed) ----------
|
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
|
+
}
|