@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.
- 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 +343 -16
- 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 +147 -0
- package/dist/lib/secrets/agent.js +500 -0
- package/dist/lib/secrets/bundles.d.ts +58 -7
- package/dist/lib/secrets/bundles.js +264 -75
- package/dist/lib/secrets/filestore.d.ts +82 -0
- package/dist/lib/secrets/filestore.js +295 -0
- package/dist/lib/secrets/linux.d.ts +6 -24
- package/dist/lib/secrets/linux.js +22 -265
- 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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
738
|
+
bundleItemStore(bundle.backend).set(item, secretValue);
|
|
673
739
|
bundle.vars[resolvedKey] = keychainRef(resolvedKey);
|
|
674
740
|
applyMeta();
|
|
675
741
|
writeBundle(bundle);
|
|
676
|
-
|
|
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 =
|
|
848
|
+
const removed = bundleItemStore(bundle.backend).delete(item);
|
|
782
849
|
if (removed) {
|
|
783
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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('--
|
|
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;
|