@phnx-labs/agents-cli 1.20.17 → 1.20.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/commands/budget.d.ts +14 -0
  4. package/dist/commands/budget.js +137 -0
  5. package/dist/commands/cost.d.ts +12 -0
  6. package/dist/commands/cost.js +139 -0
  7. package/dist/commands/exec.d.ts +20 -0
  8. package/dist/commands/exec.js +382 -5
  9. package/dist/commands/secrets.d.ts +15 -0
  10. package/dist/commands/secrets.js +343 -16
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/index.js +4 -0
  13. package/dist/lib/budget/config.d.ts +9 -0
  14. package/dist/lib/budget/config.js +115 -0
  15. package/dist/lib/budget/enforce.d.ts +94 -0
  16. package/dist/lib/budget/enforce.js +151 -0
  17. package/dist/lib/budget/ledger.d.ts +61 -0
  18. package/dist/lib/budget/ledger.js +107 -0
  19. package/dist/lib/budget/preflight.d.ts +110 -0
  20. package/dist/lib/budget/preflight.js +200 -0
  21. package/dist/lib/checkpoint.d.ts +54 -0
  22. package/dist/lib/checkpoint.js +56 -0
  23. package/dist/lib/cloud/rush.js +18 -0
  24. package/dist/lib/exec.d.ts +36 -0
  25. package/dist/lib/exec.js +192 -4
  26. package/dist/lib/git.d.ts +18 -0
  27. package/dist/lib/git.js +67 -4
  28. package/dist/lib/loop.d.ts +145 -0
  29. package/dist/lib/loop.js +330 -0
  30. package/dist/lib/mcp.d.ts +7 -0
  31. package/dist/lib/mcp.js +24 -0
  32. package/dist/lib/models.d.ts +11 -0
  33. package/dist/lib/models.js +21 -0
  34. package/dist/lib/plugins.js +5 -2
  35. package/dist/lib/pricing/cost.d.ts +46 -0
  36. package/dist/lib/pricing/cost.js +71 -0
  37. package/dist/lib/pricing/index.d.ts +8 -0
  38. package/dist/lib/pricing/index.js +8 -0
  39. package/dist/lib/pricing/prices.json +138 -0
  40. package/dist/lib/pricing/table.d.ts +17 -0
  41. package/dist/lib/pricing/table.js +73 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/agent.d.ts +147 -0
  45. package/dist/lib/secrets/agent.js +500 -0
  46. package/dist/lib/secrets/bundles.d.ts +58 -7
  47. package/dist/lib/secrets/bundles.js +264 -75
  48. package/dist/lib/secrets/filestore.d.ts +82 -0
  49. package/dist/lib/secrets/filestore.js +295 -0
  50. package/dist/lib/secrets/linux.d.ts +6 -24
  51. package/dist/lib/secrets/linux.js +22 -265
  52. package/dist/lib/session/db.d.ts +40 -0
  53. package/dist/lib/session/db.js +84 -2
  54. package/dist/lib/session/discover.d.ts +2 -0
  55. package/dist/lib/session/discover.js +126 -2
  56. package/dist/lib/session/render.d.ts +2 -0
  57. package/dist/lib/session/render.js +1 -1
  58. package/dist/lib/session/types.d.ts +4 -0
  59. package/dist/lib/teams/agents.d.ts +32 -0
  60. package/dist/lib/teams/agents.js +66 -3
  61. package/dist/lib/teams/api.js +20 -0
  62. package/dist/lib/teams/parsers.js +16 -4
  63. package/dist/lib/types.d.ts +48 -0
  64. package/dist/lib/workflows.d.ts +56 -0
  65. package/dist/lib/workflows.js +72 -5
  66. package/package.json +2 -1
@@ -7,9 +7,12 @@
7
7
  */
8
8
  import chalk from 'chalk';
9
9
  import * as fs from 'fs';
10
- import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
11
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
10
+ import { spawnSync } from 'child_process';
11
+ import { bundleExists, bundleItemStore, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
12
+ import { 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, runAgentLoadFromStdin, 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
@@ -199,7 +235,11 @@ function renderBundleRow(b) {
199
235
  `${padVisible(created, 9)} ` +
200
236
  `${padVisible(updated, 9)} ` +
201
237
  `${padVisible(used, 7)}`;
202
- return b.description ? `${head} ${chalk.gray(safePrint(b.description))}` : head.trimEnd();
238
+ // Mark file-backed bundles so `list` distinguishes them from keychain ones.
239
+ const tag = b.backend === 'file' ? chalk.magenta('[file] ') : '';
240
+ const desc = b.description ? chalk.gray(safePrint(b.description)) : '';
241
+ const trailer = `${tag}${desc}`.trimEnd();
242
+ return trailer ? `${head} ${trailer}` : head.trimEnd();
203
243
  }
204
244
  /** Colorize a variable source kind (literal, keychain, env, file, exec). */
205
245
  function kindLabel(kind) {
@@ -346,6 +386,9 @@ export function registerSecretsCommands(program) {
346
386
  # Eval the bundle into your current shell
347
387
  eval "$(agents secrets export prod --plaintext)"
348
388
 
389
+ # Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
390
+ agents secrets export prod --to-ssh --host yosemite-s0 --host yosemite-s1 --force
391
+
349
392
  # Run a one-off command with secrets injected
350
393
  agents secrets exec prod -- ./deploy.sh
351
394
  `,
@@ -354,7 +397,15 @@ export function registerSecretsCommands(program) {
354
397
  never touch disk in plaintext. Every item is device-local and gated by Touch ID
355
398
  or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
356
399
 
400
+ Touch ID noise: macOS pops a prompt per bundle per process, so concurrent
401
+ agents each re-prompt. 'agents secrets unlock <bundle>' holds the resolved
402
+ bundle in a local agent after one prompt; later runs read it silently until
403
+ it expires (default 24h), you 'lock' it, or the screen locks. Nothing on disk.
404
+
357
405
  See also:
406
+ agents secrets unlock <bundle> hold a bundle after one Touch ID
407
+ agents secrets lock wipe held bundles (re-prompt next read)
408
+ agents secrets status show held bundles + when they lock
358
409
  agents secrets rotate <bundle> <key> rotate value, preserve metadata
359
410
  agents secrets import <bundle> --from .env bulk import from .env
360
411
  agents secrets import <bundle> --from-1password --vault <name>
@@ -365,6 +416,7 @@ export function registerSecretsCommands(program) {
365
416
  registerCommandGroups(cmd, [
366
417
  { title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
367
418
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
419
+ { title: 'Agent commands', names: ['unlock', 'lock', 'status', 'tier'] },
368
420
  { title: 'Raw item commands', names: ['get', 'set'] },
369
421
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
370
422
  { title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
@@ -401,6 +453,10 @@ export function registerSecretsCommands(program) {
401
453
  console.log(chalk.gray(safePrint(bundle.description)));
402
454
  if (bundle.allow_exec)
403
455
  console.log(chalk.yellow('allow_exec: true'));
456
+ if (bundle.backend === 'file')
457
+ console.log(chalk.gray('backend: file (passphrase-encrypted; reads need AGENTS_SECRETS_PASSPHRASE, no Touch ID)'));
458
+ if (bundleTier(bundle) === 'session')
459
+ console.log(chalk.gray('tier: session (secrets-agent eligible)'));
404
460
  if (bundle.created_at)
405
461
  console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
406
462
  if (bundle.updated_at)
@@ -522,11 +578,15 @@ export function registerSecretsCommands(program) {
522
578
  .description('Create an empty bundle')
523
579
  .option('--description <text>', 'Free-form description')
524
580
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
581
+ .option('--tier <tier>', 'secrets-agent tier: biometry (default) or session', 'biometry')
582
+ .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
525
583
  .option('--force', 'Overwrite an existing bundle')
526
584
  .action(async (name, opts) => {
527
585
  try {
528
586
  const resolvedName = name ?? (await promptBundleName());
529
587
  validateBundleName(resolvedName);
588
+ const tier = parseTierOpt(opts.tier);
589
+ const backend = parseBackendOpt(opts.backend);
530
590
  if (bundleExists(resolvedName) && !opts.force) {
531
591
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
532
592
  process.exit(1);
@@ -535,10 +595,16 @@ export function registerSecretsCommands(program) {
535
595
  name: resolvedName,
536
596
  description: opts.description,
537
597
  allow_exec: opts.allowExec,
598
+ backend: backend === 'file' ? 'file' : undefined,
599
+ tier,
538
600
  vars: {},
539
601
  };
540
602
  writeBundle(bundle);
541
- console.log(chalk.green(`Bundle '${resolvedName}' created.`));
603
+ const tags = [tier === 'session' ? 'tier: session' : null, backend === 'file' ? 'backend: file' : null].filter(Boolean);
604
+ console.log(chalk.green(`Bundle '${resolvedName}' created${tags.length ? ` (${tags.join(', ')})` : ''}.`));
605
+ if (backend === 'file') {
606
+ console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted under AGENTS_SECRETS_PASSPHRASE (no Touch ID).'));
607
+ }
542
608
  console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
543
609
  }
544
610
  catch (err) {
@@ -658,7 +724,7 @@ export function registerSecretsCommands(program) {
658
724
  console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} = <literal>`));
659
725
  return;
660
726
  }
661
- // Default path: keychain-backed.
727
+ // Default path: stored in the bundle's backend (keychain or file).
662
728
  let secretValue;
663
729
  if (opts.valueStdin) {
664
730
  secretValue = readStdinSync();
@@ -669,11 +735,12 @@ export function registerSecretsCommands(program) {
669
735
  secretValue = await promptForSecret(`Enter value for ${resolvedBundleName}.${resolvedKey}`);
670
736
  }
671
737
  const item = secretsKeychainItem(resolvedBundleName, resolvedKey);
672
- setKeychainToken(item, secretValue);
738
+ bundleItemStore(bundle.backend).set(item, secretValue);
673
739
  bundle.vars[resolvedKey] = keychainRef(resolvedKey);
674
740
  applyMeta();
675
741
  writeBundle(bundle);
676
- console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in keychain (${item}).`));
742
+ const where = bundle.backend === 'file' ? 'encrypted file store' : 'keychain';
743
+ console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in ${where} (${item}).`));
677
744
  }
678
745
  catch (err) {
679
746
  if (isPromptCancelled(err))
@@ -778,9 +845,10 @@ Examples:
778
845
  writeBundle(bundle);
779
846
  if (willPurge) {
780
847
  const item = secretsKeychainItem(resolvedBundleName, raw.slice('keychain:'.length));
781
- const removed = deleteKeychainToken(item);
848
+ const removed = bundleItemStore(bundle.backend).delete(item);
782
849
  if (removed) {
783
- console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged keychain item.`));
850
+ const where = bundle.backend === 'file' ? 'encrypted file item' : 'keychain item';
851
+ console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged ${where}.`));
784
852
  return;
785
853
  }
786
854
  }
@@ -822,8 +890,9 @@ Examples:
822
890
  }
823
891
  }
824
892
  if (!opts.keepSecrets) {
893
+ const store = bundleItemStore(bundle.backend);
825
894
  for (const { item } of keychainItemsForBundle(bundle)) {
826
- deleteKeychainToken(item);
895
+ store.delete(item);
827
896
  }
828
897
  }
829
898
  const existed = deleteBundle(resolvedName);
@@ -876,11 +945,12 @@ Examples:
876
945
  });
877
946
  cmd
878
947
  .command('import [bundle]')
879
- .description('Import keys from a .env file or a 1Password vault into a bundle. By default every key is stored in keychain.')
948
+ .description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
880
949
  .option('--from <path>', 'Path to a .env file')
881
950
  .option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
882
951
  .option('--vault <name>', '1Password vault name (used with --from-1password)')
883
952
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
953
+ .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
884
954
  .option('--force', 'Overwrite an existing key in the bundle')
885
955
  .action(async (bundleName, opts) => {
886
956
  try {
@@ -891,7 +961,27 @@ Examples:
891
961
  throw new Error('--from and --from-1password are mutually exclusive.');
892
962
  }
893
963
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
894
- const bundle = readBundle(resolvedBundleName);
964
+ const requestedBackend = parseBackendOpt(opts.backend);
965
+ // Read the bundle if it exists (inheriting its backend); otherwise
966
+ // create it with the requested backend so a single `import --backend
967
+ // file` works (this is what `export --to-ssh --remote-backend file`
968
+ // drives on the remote).
969
+ let bundle;
970
+ if (bundleExists(resolvedBundleName)) {
971
+ bundle = readBundle(resolvedBundleName);
972
+ if (requestedBackend === 'file' && bundle.backend !== 'file') {
973
+ throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
974
+ `--backend file cannot change it. Delete it first to recreate as file-backed.`);
975
+ }
976
+ }
977
+ else {
978
+ bundle = {
979
+ name: resolvedBundleName,
980
+ backend: requestedBackend === 'file' ? 'file' : undefined,
981
+ vars: {},
982
+ };
983
+ }
984
+ const store = bundleItemStore(bundle.backend);
895
985
  let added = 0;
896
986
  let skipped = 0;
897
987
  if (opts.from1password) {
@@ -909,7 +999,7 @@ Examples:
909
999
  }
910
1000
  else {
911
1001
  const item = secretsKeychainItem(resolvedBundleName, envKey);
912
- setKeychainToken(item, value);
1002
+ store.set(item, value);
913
1003
  bundle.vars[envKey] = keychainRef(envKey);
914
1004
  }
915
1005
  added++;
@@ -933,7 +1023,7 @@ Examples:
933
1023
  }
934
1024
  else {
935
1025
  const item = secretsKeychainItem(resolvedBundleName, key);
936
- setKeychainToken(item, value);
1026
+ store.set(item, value);
937
1027
  bundle.vars[key] = keychainRef(key);
938
1028
  }
939
1029
  added++;
@@ -951,15 +1041,91 @@ Examples:
951
1041
  });
952
1042
  cmd
953
1043
  .command('export [bundle]')
954
- .description('Resolve a bundle and print KEY=VALUE lines, or push it to a 1Password vault with --to-1password.')
1044
+ .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
1045
  .option('--plaintext', 'Acknowledge that the resolved values will be printed in the clear (shell export mode)')
956
1046
  .option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
957
1047
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
958
- .option('--force', 'Overwrite existing 1Password items (used with --to-1password)')
1048
+ .option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
1049
+ .option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
1050
+ .option('--remote-backend <backend>', 'Backend for the bundle on the remote: keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1051
+ .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
959
1052
  .action(async (bundleName, opts) => {
960
1053
  try {
961
1054
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
962
1055
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1056
+ if (opts.toSsh) {
1057
+ const hosts = opts.host ?? [];
1058
+ if (hosts.length === 0) {
1059
+ throw new Error('--to-ssh requires at least one --host <target>.');
1060
+ }
1061
+ for (const h of hosts)
1062
+ assertValidSshTarget(h);
1063
+ const remoteBackend = parseBackendOpt(opts.remoteBackend);
1064
+ // For a file-backed remote bundle the remote must encrypt at rest with
1065
+ // a passphrase. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE — the
1066
+ // operator unlocks it once on this (trusted, biometry-gated) machine —
1067
+ // and ship it as the FIRST stdin line so it never lands in argv / `ps`
1068
+ // / the remote shell history. The remote `read -r` consumes that line;
1069
+ // `agents secrets import --from /dev/stdin` reads the .env remainder.
1070
+ let remotePassphrase = '';
1071
+ if (remoteBackend === 'file') {
1072
+ remotePassphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1073
+ if (!remotePassphrase) {
1074
+ throw new Error('--remote-backend file needs AGENTS_SECRETS_PASSPHRASE set locally to encrypt the ' +
1075
+ 'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
1076
+ }
1077
+ }
1078
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1079
+ const dotenv = bundleEnvToDotenv(env);
1080
+ const keyCount = Object.keys(env).length;
1081
+ // Drive the remote's own `agents secrets` CLI so values land in its
1082
+ // chosen backend. `bash -lc` so the login PATH resolves `agents`; the
1083
+ // .env (and, for file, the passphrase) flow over ssh stdin and are
1084
+ // never parsed by a remote shell.
1085
+ const force = opts.force ? ' --force' : '';
1086
+ const backendFlag = remoteBackend === 'file' ? ' --backend file' : '';
1087
+ let remoteAgents;
1088
+ let input;
1089
+ if (remoteBackend === 'file') {
1090
+ // import --backend file auto-creates the file-backed bundle; no
1091
+ // separate `create` needed.
1092
+ remoteAgents =
1093
+ `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
1094
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${backendFlag}${force}`;
1095
+ input = `${remotePassphrase}\n${dotenv}`;
1096
+ }
1097
+ else {
1098
+ remoteAgents =
1099
+ `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1100
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1101
+ input = dotenv;
1102
+ }
1103
+ const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
1104
+ let failures = 0;
1105
+ for (const host of hosts) {
1106
+ const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
1107
+ input,
1108
+ stdio: ['pipe', 'pipe', 'pipe'],
1109
+ encoding: 'utf-8',
1110
+ });
1111
+ if (res.error) {
1112
+ failures++;
1113
+ console.error(chalk.red(`${host}: ${res.error.message}`));
1114
+ continue;
1115
+ }
1116
+ if (res.status !== 0) {
1117
+ failures++;
1118
+ const msg = (res.stderr || res.stdout || '').trim();
1119
+ console.error(chalk.red(`${host}: remote import failed (exit ${res.status ?? 'signal'})${msg ? `: ${msg}` : ''}`));
1120
+ continue;
1121
+ }
1122
+ const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
1123
+ console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
1124
+ }
1125
+ if (failures > 0)
1126
+ process.exit(1);
1127
+ return;
1128
+ }
963
1129
  if (opts.to1password) {
964
1130
  assertOpAvailable();
965
1131
  const vault = await resolveVault(opts.vault);
@@ -1118,9 +1284,170 @@ Examples:
1118
1284
  console.log(password);
1119
1285
  }
1120
1286
  });
1287
+ cmd
1288
+ .command('unlock [names...]')
1289
+ .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
1290
+ .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default 24h.')
1291
+ .option('--all', 'Unlock every configured bundle')
1292
+ .action(async (names, opts) => {
1293
+ if (process.platform !== 'darwin') {
1294
+ console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
1295
+ process.exit(1);
1296
+ }
1297
+ let targets = opts.all ? listBundles().map((b) => b.name) : names;
1298
+ if (!targets || targets.length === 0) {
1299
+ console.error(chalk.red('Specify one or more bundle names, or --all.'));
1300
+ process.exit(1);
1301
+ }
1302
+ let ttlMs = DEFAULT_TTL_MS;
1303
+ if (opts.ttl) {
1304
+ const secs = parseDuration(opts.ttl);
1305
+ if (!secs) {
1306
+ console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
1307
+ process.exit(1);
1308
+ }
1309
+ ttlMs = secs * 1000;
1310
+ }
1311
+ if (!(await ensureAgentRunning())) {
1312
+ console.error(chalk.red('Could not start the secrets-agent.'));
1313
+ process.exit(1);
1314
+ }
1315
+ let loaded = 0;
1316
+ for (const name of targets) {
1317
+ try {
1318
+ // noAgent: read the real keychain (one Touch ID) rather than the
1319
+ // agent we're about to populate.
1320
+ const { bundle, env } = readAndResolveBundleEnv(name, { noAgent: true, caller: 'unlock' });
1321
+ if (await agentLoad(name, bundle, env, ttlMs)) {
1322
+ loaded++;
1323
+ console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
1324
+ }
1325
+ else {
1326
+ console.error(chalk.red(`Failed to load '${name}' into the agent.`));
1327
+ }
1328
+ }
1329
+ catch (err) {
1330
+ if (isPromptCancelled(err)) {
1331
+ console.error(chalk.yellow(`Cancelled unlocking '${name}'.`));
1332
+ continue;
1333
+ }
1334
+ console.error(chalk.red(`${name}: ${err.message}`));
1335
+ }
1336
+ }
1337
+ if (loaded === 0)
1338
+ process.exit(1);
1339
+ });
1340
+ cmd
1341
+ .command('lock [names...]')
1342
+ .description('Wipe bundles from the secrets-agent (forces Touch ID again next read). Default: all.')
1343
+ .option('--all', 'Wipe every unlocked bundle (same as no names)')
1344
+ .action(async (names, opts) => {
1345
+ if (process.platform !== 'darwin')
1346
+ return; // nothing to lock off darwin
1347
+ if (names && names.length > 0 && !opts.all) {
1348
+ let total = 0;
1349
+ for (const name of names)
1350
+ total += await agentLock(name);
1351
+ console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
1352
+ }
1353
+ else {
1354
+ const wiped = await agentLock();
1355
+ console.log(wiped > 0 ? chalk.green(`Locked ${wiped} bundle(s).`) : chalk.gray('Nothing to lock.'));
1356
+ }
1357
+ });
1358
+ cmd
1359
+ .command('status')
1360
+ .description('Show which bundles the secrets-agent currently holds and when they lock.')
1361
+ .action(async () => {
1362
+ if (process.platform !== 'darwin') {
1363
+ console.log(chalk.gray('secrets-agent is macOS-only.'));
1364
+ return;
1365
+ }
1366
+ const entries = await agentStatus();
1367
+ if (entries.length === 0) {
1368
+ console.log(chalk.gray('No bundles unlocked. The secrets-agent is idle or not running.'));
1369
+ console.log(chalk.gray('Try: agents secrets unlock <bundle>'));
1370
+ return;
1371
+ }
1372
+ console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
1373
+ for (const e of entries) {
1374
+ console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
1375
+ }
1376
+ });
1377
+ cmd
1378
+ .command('tier <bundle> [tier]')
1379
+ .description("Show or set a bundle's secrets-agent tier: biometry (default) or session.")
1380
+ .action((bundleName, tier) => {
1381
+ try {
1382
+ const bundle = readBundle(bundleName);
1383
+ if (tier === undefined) {
1384
+ console.log(`${chalk.cyan(bundle.name)} tier: ${chalk.bold(bundleTier(bundle))}`);
1385
+ return;
1386
+ }
1387
+ const next = parseTierOpt(tier);
1388
+ bundle.tier = next;
1389
+ writeBundle(bundle);
1390
+ console.log(chalk.green(`${bundle.name} tier set to ${next}.`));
1391
+ if (next === 'session') {
1392
+ console.log(chalk.gray('Eligible for the secrets-agent: unlock it, or enable auto-cache with `secrets.agent.auto: true` in agents.yaml.'));
1393
+ }
1394
+ }
1395
+ catch (err) {
1396
+ console.error(chalk.red(err.message));
1397
+ process.exit(1);
1398
+ }
1399
+ });
1400
+ cmd
1401
+ .command('_agent-run', { hidden: true })
1402
+ .description('Run the secrets-agent broker in the foreground (internal)')
1403
+ .action(async () => {
1404
+ await runSecretsAgent();
1405
+ });
1406
+ cmd
1407
+ .command('_agent-load', { hidden: true })
1408
+ .description('Detached auto-cache worker: load a bundle from stdin into the broker (internal)')
1409
+ .action(async () => {
1410
+ await runAgentLoadFromStdin();
1411
+ });
1121
1412
  registerSecretsSyncCommands(cmd);
1122
1413
  registerSecretsMigrateAclCommand(cmd);
1123
1414
  }
1415
+ /** Validate a --tier value, exiting with a clear message on a bad one. `none`
1416
+ * is rejected explicitly: it would require storing items without the biometry
1417
+ * ACL (a separate signed-helper change), so it isn't offered yet. */
1418
+ function parseTierOpt(raw) {
1419
+ const v = (raw ?? 'biometry').toLowerCase();
1420
+ if (v === 'biometry' || v === 'session')
1421
+ return v;
1422
+ if (v === 'none') {
1423
+ console.error(chalk.red("tier 'none' (no biometry ACL) is not available yet — use 'biometry' or 'session'."));
1424
+ process.exit(1);
1425
+ }
1426
+ console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
1427
+ process.exit(1);
1428
+ }
1429
+ /** Validate a --backend value, exiting with a clear message on a bad one. */
1430
+ function parseBackendOpt(raw) {
1431
+ const v = (raw ?? 'keychain').toLowerCase();
1432
+ if (v === 'keychain' || v === 'file')
1433
+ return v;
1434
+ console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'.`));
1435
+ process.exit(1);
1436
+ }
1437
+ /** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
1438
+ function humanRemaining(expiresAt) {
1439
+ const ms = expiresAt - Date.now();
1440
+ if (ms <= 0)
1441
+ return 'expired';
1442
+ const mins = Math.round(ms / 60000);
1443
+ if (mins < 60)
1444
+ return `locks in ${mins} minute${mins === 1 ? '' : 's'}`;
1445
+ const hours = Math.round(mins / 60);
1446
+ if (hours < 24)
1447
+ return `locks in ${hours} hour${hours === 1 ? '' : 's'}`;
1448
+ const days = Math.round(hours / 24);
1449
+ return `locks in ${days} day${days === 1 ? '' : 's'}`;
1450
+ }
1124
1451
  /**
1125
1452
  * Copy text to the system clipboard, cross-platform.
1126
1453
  * 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;