@phnx-labs/agents-cli 1.20.11 → 1.20.13

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -0
  3. package/dist/commands/computer-actions.d.ts +3 -0
  4. package/dist/commands/computer-actions.js +16 -0
  5. package/dist/commands/exec.js +25 -4
  6. package/dist/commands/import.js +17 -6
  7. package/dist/commands/inspect.d.ts +11 -1
  8. package/dist/commands/inspect.js +53 -19
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/plugins.d.ts +2 -0
  11. package/dist/commands/plugins.js +69 -26
  12. package/dist/commands/sync.js +1 -1
  13. package/dist/commands/teams.js +1 -0
  14. package/dist/commands/trash.d.ts +11 -0
  15. package/dist/commands/trash.js +57 -41
  16. package/dist/commands/versions.js +68 -20
  17. package/dist/commands/view.js +1 -12
  18. package/dist/commands/wallet.d.ts +14 -0
  19. package/dist/commands/wallet.js +199 -0
  20. package/dist/index.js +22 -1
  21. package/dist/lib/agents.js +70 -22
  22. package/dist/lib/browser/ipc.d.ts +7 -0
  23. package/dist/lib/browser/ipc.js +43 -27
  24. package/dist/lib/capabilities.js +7 -1
  25. package/dist/lib/command-skills.d.ts +1 -0
  26. package/dist/lib/command-skills.js +23 -7
  27. package/dist/lib/exec.d.ts +32 -1
  28. package/dist/lib/exec.js +79 -7
  29. package/dist/lib/hooks.js +37 -5
  30. package/dist/lib/mcp.js +33 -0
  31. package/dist/lib/models.js +5 -0
  32. package/dist/lib/picker.d.ts +2 -0
  33. package/dist/lib/picker.js +96 -6
  34. package/dist/lib/platform/index.d.ts +1 -0
  35. package/dist/lib/platform/index.js +1 -0
  36. package/dist/lib/platform/winpath.d.ts +35 -0
  37. package/dist/lib/platform/winpath.js +86 -0
  38. package/dist/lib/plugins.d.ts +14 -0
  39. package/dist/lib/plugins.js +23 -0
  40. package/dist/lib/project-launch.js +110 -5
  41. package/dist/lib/registry.js +15 -2
  42. package/dist/lib/runner.js +14 -0
  43. package/dist/lib/sandbox.js +5 -2
  44. package/dist/lib/settings-manifest.d.ts +39 -0
  45. package/dist/lib/settings-manifest.js +163 -0
  46. package/dist/lib/shims.d.ts +1 -1
  47. package/dist/lib/shims.js +16 -31
  48. package/dist/lib/staleness/detectors/subagents.js +16 -0
  49. package/dist/lib/staleness/writers/subagents.js +11 -3
  50. package/dist/lib/subagents.d.ts +9 -0
  51. package/dist/lib/subagents.js +33 -0
  52. package/dist/lib/teams/agents.js +1 -1
  53. package/dist/lib/teams/parsers.d.ts +1 -1
  54. package/dist/lib/teams/parsers.js +6 -0
  55. package/dist/lib/types.d.ts +1 -1
  56. package/dist/lib/versions.d.ts +15 -3
  57. package/dist/lib/versions.js +88 -19
  58. package/dist/lib/wallet/index.d.ts +78 -0
  59. package/dist/lib/wallet/index.js +253 -0
  60. package/package.json +3 -3
  61. package/scripts/postinstall.js +35 -7
@@ -110,6 +110,61 @@ function humanSize(bytes) {
110
110
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
111
111
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
112
112
  }
113
+ /**
114
+ * Restore a soft-deleted version back into ~/.agents/.history/versions/.
115
+ * Shared by `agents trash restore` and the top-level `agents restore` alias.
116
+ * Exits the process with a non-zero code on any failure.
117
+ */
118
+ export function restoreVersion(target) {
119
+ const parsed = parseAgentVersion(target);
120
+ if (!parsed) {
121
+ console.error(chalk.red(`Expected <agent>@<version>, got: ${target}`));
122
+ process.exit(1);
123
+ }
124
+ const { agent, version } = parsed;
125
+ const entries = listTrashEntries(agent);
126
+ const entry = pickLatest(entries, agent, version);
127
+ if (!entry) {
128
+ console.error(chalk.red(`No trashed copy found for ${agent}@${version}`));
129
+ console.error(chalk.gray('Run `agents trash list` to see what exists.'));
130
+ process.exit(1);
131
+ }
132
+ const dest = getVersionDir(agent, version);
133
+ if (fs.existsSync(dest)) {
134
+ console.error(chalk.red(`Cannot restore: ${dest} already exists.`));
135
+ console.error(chalk.gray('Move or remove the existing dir first, then re-run restore.'));
136
+ process.exit(1);
137
+ }
138
+ try {
139
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
140
+ fs.renameSync(entry.trashPath, dest);
141
+ }
142
+ catch (err) {
143
+ console.error(chalk.red(`Restore failed: ${err.message}`));
144
+ process.exit(1);
145
+ }
146
+ // Best-effort cleanup of empty stamp/version parents in trash.
147
+ try {
148
+ const verDir = path.dirname(entry.trashPath);
149
+ if (fs.readdirSync(verDir).length === 0)
150
+ fs.rmdirSync(verDir);
151
+ const agentDir = path.dirname(verDir);
152
+ if (fs.readdirSync(agentDir).length === 0)
153
+ fs.rmdirSync(agentDir);
154
+ }
155
+ catch { /* best-effort */ }
156
+ console.log(chalk.green(`Restored ${agentLabel(agent)}@${version} to ${dest}`));
157
+ }
158
+ /**
159
+ * Register the top-level `agents restore` command — a shorthand for
160
+ * `agents trash restore` so users can undo a `remove`/`prune` directly.
161
+ */
162
+ export function registerRestoreCommand(program) {
163
+ program
164
+ .command('restore <target>')
165
+ .description('Restore a soft-deleted agent version (e.g. "codex@0.141.0") removed via prune/remove')
166
+ .action((target) => restoreVersion(target));
167
+ }
113
168
  export function registerTrashCommands(program) {
114
169
  const trash = program
115
170
  .command('trash')
@@ -139,49 +194,10 @@ export function registerTrashCommands(program) {
139
194
  chalk.gray(`${e.stamp} ${size} ${e.trashPath}`));
140
195
  }
141
196
  console.log();
142
- console.log(chalk.gray('Restore with: agents trash restore <agent>@<version>'));
197
+ console.log(chalk.gray('Restore with: agents restore <agent>@<version>'));
143
198
  });
144
199
  trash
145
200
  .command('restore <target>')
146
201
  .description('Restore a soft-deleted version (e.g. "claude@2.1.110") back to ~/.agents/.history/versions/')
147
- .action((target) => {
148
- const parsed = parseAgentVersion(target);
149
- if (!parsed) {
150
- console.error(chalk.red(`Expected <agent>@<version>, got: ${target}`));
151
- process.exit(1);
152
- }
153
- const { agent, version } = parsed;
154
- const entries = listTrashEntries(agent);
155
- const entry = pickLatest(entries, agent, version);
156
- if (!entry) {
157
- console.error(chalk.red(`No trashed copy found for ${agent}@${version}`));
158
- console.error(chalk.gray('Run `agents trash list` to see what exists.'));
159
- process.exit(1);
160
- }
161
- const dest = getVersionDir(agent, version);
162
- if (fs.existsSync(dest)) {
163
- console.error(chalk.red(`Cannot restore: ${dest} already exists.`));
164
- console.error(chalk.gray('Move or remove the existing dir first, then re-run restore.'));
165
- process.exit(1);
166
- }
167
- try {
168
- fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
169
- fs.renameSync(entry.trashPath, dest);
170
- }
171
- catch (err) {
172
- console.error(chalk.red(`Restore failed: ${err.message}`));
173
- process.exit(1);
174
- }
175
- // Best-effort cleanup of empty stamp/version parents in trash.
176
- try {
177
- const verDir = path.dirname(entry.trashPath);
178
- if (fs.readdirSync(verDir).length === 0)
179
- fs.rmdirSync(verDir);
180
- const agentDir = path.dirname(verDir);
181
- if (fs.readdirSync(agentDir).length === 0)
182
- fs.rmdirSync(agentDir);
183
- }
184
- catch { /* best-effort */ }
185
- console.log(chalk.green(`Restored ${agentLabel(agent)}@${version} to ${dest}`));
186
- });
202
+ .action((target) => restoreVersion(target));
187
203
  }
@@ -7,7 +7,8 @@ import { AGENTS, ALL_AGENT_IDS, getAccountEmail, getAccountInfo, agentLabel, } f
7
7
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
8
8
  import { viewAction } from './view.js';
9
9
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
10
- import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
10
+ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
11
+ import { carryForwardSettings } from '../lib/settings-manifest.js';
11
12
  import { createShim, createVersionedAlias, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
12
13
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
13
14
  import { tryAutoPull } from '../lib/git.js';
@@ -28,17 +29,6 @@ function fixSessionFilePaths(agent, version, oldVersionDir) {
28
29
  const trashPath = path.join(trashAgentDir, stamps[0]);
29
30
  updateSessionFilePaths(oldVersionDir, trashPath);
30
31
  }
31
- /**
32
- * Helper to get actual installed version for an agent.
33
- * Returns the latest installed version, or throws if none installed.
34
- */
35
- async function getInstalledVersionForAgent(agent) {
36
- const versions = listInstalledVersions(agent);
37
- if (versions.length > 0) {
38
- return versions[versions.length - 1];
39
- }
40
- throw new Error(`No versions of ${agent} installed`);
41
- }
42
32
  function formatAccountHint(info, usage) {
43
33
  const parts = [];
44
34
  if (info.email)
@@ -91,7 +81,17 @@ function warnIfShimShadowed(agent) {
91
81
  }
92
82
  console.log(chalk.yellow(` Warning: ${AGENTS[agent].cliCommand} currently resolves to ${shadowedBy}`));
93
83
  console.log(chalk.gray(` Managed shim: ${getShimPath(agent)}`));
94
- console.log(chalk.gray(` ${getPathSetupInstructions().split('\n').join('\n ')}`));
84
+ const result = addShimsToPath();
85
+ if (!result.success) {
86
+ console.log(chalk.gray(` ${getPathSetupInstructions().split('\n').join('\n ')}`));
87
+ return;
88
+ }
89
+ if (result.alreadyPresent) {
90
+ console.log(chalk.gray(` Shim PATH entry already set — ${AGENTS[agent].cliCommand} is shadowed by another binary. Remove or reorder it so ${getShimPath(agent)} takes priority.`));
91
+ return;
92
+ }
93
+ console.log(chalk.green(` Added shim directory to ${result.location}.`));
94
+ console.log(chalk.gray(` ${result.reloadHint}`));
95
95
  }
96
96
  async function versionPruneAction(specs, options, commandName) {
97
97
  const isProject = options.project;
@@ -105,7 +105,7 @@ async function versionPruneAction(specs, options, commandName) {
105
105
  }
106
106
  const { agent, version } = parsed;
107
107
  const agentConfig = AGENTS[agent];
108
- if (version === 'latest' || !spec.includes('@')) {
108
+ if (version === 'latest' || version === 'oldest' || !spec.includes('@')) {
109
109
  const versions = listInstalledVersions(agent);
110
110
  if (versions.length === 0) {
111
111
  console.log(chalk.gray(`No versions of ${agentLabel(agentConfig.id)} installed`));
@@ -139,7 +139,11 @@ async function versionPruneAction(specs, options, commandName) {
139
139
  }
140
140
  for (const v of toRemove) {
141
141
  const versionDir = getVersionDir(agent, v);
142
- removeVersion(agent, v);
142
+ const removed = removeVersion(agent, v);
143
+ if (!removed) {
144
+ console.log(chalk.red(`Failed to move ${agentLabel(agentConfig.id)}@${v} to trash — a file may be locked by a running process. Close any active sessions and try again.`));
145
+ continue;
146
+ }
143
147
  fixSessionFilePaths(agent, v, versionDir);
144
148
  console.log(chalk.green(`Moved ${agentLabel(agentConfig.id)}@${v} to trash`));
145
149
  moved.push({ agent, version: v });
@@ -166,7 +170,11 @@ async function versionPruneAction(specs, options, commandName) {
166
170
  }
167
171
  else {
168
172
  const versionDir = getVersionDir(agent, version);
169
- removeVersion(agent, version);
173
+ const removed = removeVersion(agent, version);
174
+ if (!removed) {
175
+ console.log(chalk.red(`Failed to move ${agentLabel(agentConfig.id)}@${version} to trash — a file may be locked by a running process. Close any active sessions and try again.`));
176
+ continue;
177
+ }
170
178
  fixSessionFilePaths(agent, version, versionDir);
171
179
  console.log(chalk.green(`Moved ${agentLabel(agentConfig.id)}@${version} to trash`));
172
180
  moved.push({ agent, version });
@@ -228,6 +236,9 @@ export function registerVersionsCommands(program) {
228
236
  # Install the latest version of an agent
229
237
  agents add claude@latest
230
238
 
239
+ # Install the oldest published version of an agent
240
+ agents add claude@oldest
241
+
231
242
  # Install a specific version (reproducibility)
232
243
  agents add claude@2.1.112
233
244
 
@@ -259,7 +270,7 @@ export function registerVersionsCommands(program) {
259
270
  console.log(chalk.yellow(`${agentLabel(agentConfig.id)} has no npm package. Install manually.`));
260
271
  continue;
261
272
  }
262
- // Check if already installed (handle 'latest' specially)
273
+ // Check if already installed (resolve 'latest'/'oldest' against npm first)
263
274
  let alreadyInstalled = false;
264
275
  let installedAsVersion = version;
265
276
  if (version === 'latest') {
@@ -269,6 +280,13 @@ export function registerVersionsCommands(program) {
269
280
  installedAsVersion = latestCheck.version;
270
281
  }
271
282
  }
283
+ else if (version === 'oldest') {
284
+ const oldestCheck = await isOldestInstalled(agent);
285
+ if (oldestCheck.installed && oldestCheck.version) {
286
+ alreadyInstalled = true;
287
+ installedAsVersion = oldestCheck.version;
288
+ }
289
+ }
272
290
  else {
273
291
  alreadyInstalled = isVersionInstalled(agent, version);
274
292
  }
@@ -290,6 +308,19 @@ export function registerVersionsCommands(program) {
290
308
  console.log(chalk.gray(` Created shim: ${getShimsDir()}/${agentConfig.cliCommand}`));
291
309
  }
292
310
  const installedVersion = result.installedVersion || version;
311
+ // Track the concrete version so a `--project` pin records it instead
312
+ // of the `latest`/`oldest` alias.
313
+ installedAsVersion = installedVersion;
314
+ // Seed the fresh version home with user settings from the current
315
+ // default version (settings.json, keybindings, codex config/auth).
316
+ // Gap-filling only — never overwrites what the new home has.
317
+ const carrySource = getGlobalDefault(agent);
318
+ if (carrySource && carrySource !== installedVersion) {
319
+ const carried = carryForwardSettings(agent, getVersionHomePath(agent, carrySource), getVersionHomePath(agent, installedVersion));
320
+ if (carried.applied.length > 0) {
321
+ console.log(chalk.gray(` Carried settings from ${agent}@${carrySource}: ${carried.applied.map(r => path.basename(r)).join(', ')}`));
322
+ }
323
+ }
293
324
  // Smart resource detection: compare available vs ACTUALLY synced (source of truth: files)
294
325
  const available = getAvailableResources();
295
326
  const actuallySynced = getActuallySyncedResources(agent, installedVersion);
@@ -428,14 +459,19 @@ export function registerVersionsCommands(program) {
428
459
  ? readManifest(process.cwd()) || createDefaultManifest()
429
460
  : createDefaultManifest();
430
461
  manifest.agents = manifest.agents || {};
431
- manifest.agents[agent] = version === 'latest' ? (await getInstalledVersionForAgent(agent)) : version;
462
+ manifest.agents[agent] = (version === 'latest' || version === 'oldest')
463
+ ? installedAsVersion
464
+ : version;
432
465
  writeManifest(process.cwd(), manifest);
433
466
  console.log(chalk.green(` Pinned ${agentLabel(agentConfig.id)}@${version} in .agents/agents.yaml`));
434
467
  }
435
468
  }
436
469
  });
437
470
  configureVersionPruneCommand(program.command('prune <specs...>'), 'prune');
438
- configureVersionPruneCommand(program.command('remove <specs...>', { hidden: true }), 'remove');
471
+ // `rm` and `purge` are commander aliases for `remove` (which is itself an
472
+ // alias for `prune`). Native `.aliases()` keeps them in lockstep — same
473
+ // action, same options, no duplicate registration.
474
+ configureVersionPruneCommand(program.command('remove <specs...>', { hidden: true }).aliases(['rm', 'purge']), 'remove');
439
475
  const useCmd = program
440
476
  .command('use <agent> [version]')
441
477
  .description('Switch the active version for an agent. This is the only command that sets the default.')
@@ -478,7 +514,7 @@ export function registerVersionsCommands(program) {
478
514
  return;
479
515
  }
480
516
  agent = parsed.agent;
481
- version = parsed.version === 'latest' ? undefined : parsed.version;
517
+ version = (parsed.version === 'latest' || parsed.version === 'oldest') ? undefined : parsed.version;
482
518
  }
483
519
  else {
484
520
  const agentLower = agentArg.toLowerCase();
@@ -679,6 +715,18 @@ export function registerVersionsCommands(program) {
679
715
  }
680
716
  }
681
717
  const previousDefault = getGlobalDefault(agentId);
718
+ // Carry user settings from the outgoing default into the target
719
+ // version home before switching. Gap-filling only, so versions that
720
+ // already have their own settings are left untouched.
721
+ if (previousDefault && previousDefault !== finalVersion) {
722
+ const carried = carryForwardSettings(agentId, getVersionHomePath(agentId, previousDefault), getVersionHomePath(agentId, finalVersion));
723
+ if (carried.applied.length > 0) {
724
+ console.log(chalk.gray(`Carried settings from ${agentId}@${previousDefault}: ${carried.applied.map(r => path.basename(r)).join(', ')}`));
725
+ if (carried.backupDir) {
726
+ console.log(chalk.gray(` Pre-merge backup: ${carried.backupDir}`));
727
+ }
728
+ }
729
+ }
682
730
  // Set global default
683
731
  setGlobalDefault(agentId, finalVersion);
684
732
  // Regenerate shim so it uses the latest script format
@@ -6,7 +6,7 @@ import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentNam
6
6
  import { formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
7
7
  import { readManifest } from '../lib/manifest.js';
8
8
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
9
- import { getShimsDir, isShimsInPath, ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
9
+ import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
10
10
  import { getAgentResources } from '../lib/resources.js';
11
11
  import { isCapable } from '../lib/capabilities.js';
12
12
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins.js';
@@ -537,17 +537,6 @@ async function showInstalledVersions(filterAgentId) {
537
537
  console.log(chalk.gray(' Run: agents add claude@latest'));
538
538
  console.log();
539
539
  }
540
- // Show shims path status at the end (only for full list with managed versions)
541
- if (versionManaged.length > 0 && !filterAgentId) {
542
- const shimsDir = getShimsDir();
543
- if (isShimsInPath()) {
544
- console.log(chalk.gray(`Shims: ${shimsDir} (in PATH)`));
545
- }
546
- else {
547
- console.log(chalk.yellow(`Shims: ${shimsDir} (not in PATH)`));
548
- console.log(chalk.gray('Add to PATH for automatic version switching'));
549
- }
550
- }
551
540
  // Check for new resources when viewing a specific agent
552
541
  if (filterAgentId && versionManaged.length > 0) {
553
542
  const defaultVersion = getGlobalDefault(filterAgentId);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents wallet` — a device-local credit-card vault backed by macOS Keychain.
3
+ *
4
+ * UX intent matches Apple Wallet: list cards freely (no biometric), reveal
5
+ * a card with Touch ID. Card numbers never leave the device. This is NOT
6
+ * Apple Pay — we store the real PAN, not a network DPAN, and do not generate
7
+ * per-transaction cryptograms. The help text reflects this.
8
+ *
9
+ * Bundle layout:
10
+ * ~/.agents/wallet/cards.json metadata (id, last4, brand, exp)
11
+ * agents-cli.secrets.wallet.<id> JSON {pan, cvc, cardholder}
12
+ */
13
+ import type { Command } from 'commander';
14
+ export declare function registerWalletCommands(program: Command): void;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `agents wallet` — a device-local credit-card vault backed by macOS Keychain.
3
+ *
4
+ * UX intent matches Apple Wallet: list cards freely (no biometric), reveal
5
+ * a card with Touch ID. Card numbers never leave the device. This is NOT
6
+ * Apple Pay — we store the real PAN, not a network DPAN, and do not generate
7
+ * per-transaction cryptograms. The help text reflects this.
8
+ *
9
+ * Bundle layout:
10
+ * ~/.agents/wallet/cards.json metadata (id, last4, brand, exp)
11
+ * agents-cli.secrets.wallet.<id> JSON {pan, cvc, cardholder}
12
+ */
13
+ import chalk from 'chalk';
14
+ import { addCard, detectBrand, isValidLuhn, listCards, removeCard, renameCard, showCard, } from '../lib/wallet/index.js';
15
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
+ import { setHelpSections } from '../lib/help.js';
17
+ function brandLabel(b) {
18
+ switch (b) {
19
+ case 'visa': return 'Visa';
20
+ case 'mastercard': return 'Mastercard';
21
+ case 'amex': return 'American Express';
22
+ case 'discover': return 'Discover';
23
+ case 'diners': return 'Diners Club';
24
+ case 'jcb': return 'JCB';
25
+ case 'unionpay': return 'UnionPay';
26
+ default: return 'Card';
27
+ }
28
+ }
29
+ function formatExp(month, year) {
30
+ return `${month}/${year.slice(-2)}`;
31
+ }
32
+ function renderRow(c) {
33
+ const nick = c.nickname.padEnd(24);
34
+ const brand = brandLabel(c.brand).padEnd(18);
35
+ const last4 = `•••• ${c.last4}`.padEnd(10);
36
+ const exp = formatExp(c.exp_month, c.exp_year);
37
+ return ` ${chalk.cyan(nick)} ${brand} ${last4} ${chalk.gray('exp ' + exp)} ${chalk.gray(c.id)}`;
38
+ }
39
+ async function promptString(message, validate) {
40
+ const { input } = await import('@inquirer/prompts');
41
+ return await input({ message, validate });
42
+ }
43
+ async function promptSecret(message) {
44
+ const { password } = await import('@inquirer/prompts');
45
+ return await password({ message, mask: true });
46
+ }
47
+ function requireTTY(action) {
48
+ if (!isInteractiveTerminal()) {
49
+ throw new Error(`'agents wallet ${action}' requires an interactive terminal.`);
50
+ }
51
+ }
52
+ export function registerWalletCommands(program) {
53
+ const cmd = program
54
+ .command('wallet')
55
+ .description('Device-local credit-card vault backed by macOS Keychain (Touch ID required to reveal). ' +
56
+ 'Encrypted at rest, never leaves your device. Not Apple Pay — stores real PANs, no tokenization.');
57
+ setHelpSections(cmd, {
58
+ examples: `
59
+ $ agents wallet add # interactive: PAN, CVC, expiry, nickname
60
+ $ agents wallet list # last 4 only, no Touch ID prompt
61
+ $ agents wallet show personal-amex # Touch ID required, reveals full card
62
+ $ agents wallet rename personal-amex "Travel"
63
+ $ agents wallet remove personal-amex
64
+ `,
65
+ });
66
+ cmd
67
+ .command('add')
68
+ .description('Add a card to the vault. Interactive prompt for PAN, CVC, expiry, cardholder, nickname.')
69
+ .option('--nickname <name>', 'Set the nickname non-interactively (still prompts for PAN/CVC)')
70
+ .option('--stdin-json', 'Read all fields as a JSON object on stdin (for IPC callers). Emits the new card metadata as JSON to stdout.')
71
+ .action(async (opts) => {
72
+ try {
73
+ if (opts.stdinJson) {
74
+ const raw = await new Promise((resolve, reject) => {
75
+ const chunks = [];
76
+ process.stdin.on('data', (c) => {
77
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : c);
78
+ });
79
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
80
+ process.stdin.on('error', reject);
81
+ });
82
+ const input = JSON.parse(raw);
83
+ const meta = addCard(input);
84
+ process.stdout.write(JSON.stringify({ card: meta }) + '\n');
85
+ return;
86
+ }
87
+ requireTTY('add');
88
+ const pan = (await promptSecret('Card number')).replace(/\s+/g, '');
89
+ if (!/^\d+$/.test(pan))
90
+ throw new Error('PAN must contain only digits.');
91
+ if (!isValidLuhn(pan))
92
+ throw new Error('PAN failed Luhn checksum — typo?');
93
+ const brand = detectBrand(pan);
94
+ console.log(chalk.gray(`Detected: ${brandLabel(brand)} •••• ${pan.slice(-4)}`));
95
+ const exp_month = await promptString('Expiration month (MM)', (v) => {
96
+ const n = Number(v);
97
+ return Number.isInteger(n) && n >= 1 && n <= 12 ? true : 'Month must be 1-12';
98
+ });
99
+ const exp_year = await promptString('Expiration year (YY or YYYY)', (v) => {
100
+ const d = v.replace(/\D/g, '');
101
+ return d.length === 2 || d.length === 4 ? true : 'Year must be 2 or 4 digits';
102
+ });
103
+ const cvc = (await promptSecret('CVC')).replace(/\s+/g, '');
104
+ if (!/^\d{3,4}$/.test(cvc))
105
+ throw new Error('CVC must be 3 or 4 digits.');
106
+ const cardholder = await promptString('Cardholder name (as printed)');
107
+ const nickname = opts.nickname?.trim() || await promptString('Nickname (e.g. Personal Amex)', (v) => v.trim() ? true : 'Required');
108
+ const meta = addCard({ nickname, pan, cvc, cardholder, exp_month, exp_year });
109
+ console.log(chalk.green(`Added ${brandLabel(meta.brand)} •••• ${meta.last4} as '${meta.nickname}' (id: ${meta.id})`));
110
+ }
111
+ catch (err) {
112
+ if (isPromptCancelled(err))
113
+ return;
114
+ console.error(chalk.red(err.message));
115
+ process.exit(1);
116
+ }
117
+ });
118
+ cmd
119
+ .command('list')
120
+ .alias('ls')
121
+ .description('List stored cards (metadata only — last 4, brand, expiry). No biometric prompt.')
122
+ .option('--json', 'Emit JSON to stdout')
123
+ .action((opts) => {
124
+ try {
125
+ const cards = listCards();
126
+ if (opts.json) {
127
+ process.stdout.write(JSON.stringify({ cards }, null, 2) + '\n');
128
+ return;
129
+ }
130
+ if (cards.length === 0) {
131
+ console.log(chalk.gray('No cards in wallet. Try: agents wallet add'));
132
+ return;
133
+ }
134
+ console.log(chalk.bold(` ${'NICKNAME'.padEnd(24)} ${'BRAND'.padEnd(18)} ${'LAST4'.padEnd(10)} EXP ID`));
135
+ for (const c of cards)
136
+ console.log(renderRow(c));
137
+ }
138
+ catch (err) {
139
+ console.error(chalk.red(err.message));
140
+ process.exit(1);
141
+ }
142
+ });
143
+ cmd
144
+ .command('show <id>')
145
+ .description('Reveal a card. Touch ID required. Argument is a card id or nickname.')
146
+ .option('--json', 'Emit JSON to stdout (still triggers Touch ID)')
147
+ .action((id, opts) => {
148
+ try {
149
+ const full = showCard(id);
150
+ if (opts.json) {
151
+ process.stdout.write(JSON.stringify(full, null, 2) + '\n');
152
+ return;
153
+ }
154
+ console.log();
155
+ console.log(chalk.bold(` ${full.nickname}`));
156
+ console.log(` ${chalk.gray('Brand ')} ${brandLabel(full.brand)}`);
157
+ console.log(` ${chalk.gray('Number ')} ${full.pan.match(/.{1,4}/g)?.join(' ') ?? full.pan}`);
158
+ console.log(` ${chalk.gray('CVC ')} ${full.cvc}`);
159
+ console.log(` ${chalk.gray('Expires ')} ${formatExp(full.exp_month, full.exp_year)}`);
160
+ console.log(` ${chalk.gray('Holder ')} ${full.cardholder}`);
161
+ console.log();
162
+ }
163
+ catch (err) {
164
+ console.error(chalk.red(err.message));
165
+ process.exit(1);
166
+ }
167
+ });
168
+ cmd
169
+ .command('remove <id>')
170
+ .alias('rm')
171
+ .description('Remove a card from the vault. Argument is a card id or nickname.')
172
+ .action((id) => {
173
+ try {
174
+ const meta = removeCard(id);
175
+ if (!meta) {
176
+ console.error(chalk.red(`No card found matching '${id}'.`));
177
+ process.exit(1);
178
+ }
179
+ console.log(chalk.green(`Removed '${meta.nickname}' (${brandLabel(meta.brand)} •••• ${meta.last4}).`));
180
+ }
181
+ catch (err) {
182
+ console.error(chalk.red(err.message));
183
+ process.exit(1);
184
+ }
185
+ });
186
+ cmd
187
+ .command('rename <id> <new-nickname>')
188
+ .description('Rename a card. Argument is the current id or nickname.')
189
+ .action((id, newNickname) => {
190
+ try {
191
+ const meta = renameCard(id, newNickname);
192
+ console.log(chalk.green(`Renamed to '${meta.nickname}'.`));
193
+ }
194
+ catch (err) {
195
+ console.error(chalk.red(err.message));
196
+ process.exit(1);
197
+ }
198
+ });
199
+ }
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ import { registerRunCommand } from './commands/exec.js';
78
78
  import { registerModelsCommand } from './commands/models.js';
79
79
  import { registerDefaultsCommands } from './commands/defaults.js';
80
80
  import { registerPruneCommand } from './commands/prune.js';
81
- import { registerTrashCommands } from './commands/trash.js';
81
+ import { registerTrashCommands, registerRestoreCommand } from './commands/trash.js';
82
82
  import { registerDoctorCommand } from './commands/doctor.js';
83
83
  import { registerSubagentsCommands } from './commands/subagents.js';
84
84
  import { registerPluginsCommands } from './commands/plugins.js';
@@ -93,6 +93,7 @@ import { registerBrowserCommand } from './commands/browser.js';
93
93
  import { registerComputerCommand } from './commands/computer.js';
94
94
  import { registerProfilesCommands } from './commands/profiles.js';
95
95
  import { registerSecretsCommands } from './commands/secrets.js';
96
+ import { registerWalletCommands } from './commands/wallet.js';
96
97
  import { registerHelperCommand } from './commands/helper.js';
97
98
  import { registerFactoryCommands } from './commands/factory.js';
98
99
  import { registerUsageCommand } from './commands/usage.js';
@@ -348,6 +349,24 @@ async function installResolvedPackage(metadata) {
348
349
  await installPackageIntoPrefix(`${NPM_PACKAGE_NAME}@${metadata.version}`, prefix);
349
350
  verifyInstalledVersion(packageRoot, metadata.version);
350
351
  refreshAliasShims(packageRoot);
352
+ // The npm install above runs with --ignore-scripts, so the postinstall that
353
+ // installs the macOS Keychain helper never fires on upgrade. Force-refresh the
354
+ // helper here so a user upgrading FROM a broken build (e.g. the entitlement-less
355
+ // 1.20.4 helper that fails SecItemAdd with -34018) gets the fixed, signed bundle
356
+ // immediately — instead of waiting for the lazy staleness check in
357
+ // getKeychainHelperPath() to repair it on their next secret operation. The new
358
+ // package is already on disk, so the dynamic import resolves the freshly-installed
359
+ // helper module + bundle. Best-effort: an upgrade must never fail because the
360
+ // helper could not be reinstalled (`agents helper install --force` stays available).
361
+ if (process.platform === 'darwin') {
362
+ try {
363
+ const { ensureKeychainHelperInstalled } = await import('./lib/secrets/install-helper.js');
364
+ ensureKeychainHelperInstalled({ forceReinstall: true });
365
+ }
366
+ catch {
367
+ // Non-fatal.
368
+ }
369
+ }
351
370
  }
352
371
  /** Present an interactive upgrade prompt (TTY) or a one-line hint (non-TTY). */
353
372
  async function promptUpgrade(latestVersion) {
@@ -637,6 +656,7 @@ registerDefaultsCommands(program);
637
656
  registerModelsCommand(program);
638
657
  registerPruneCommand(program);
639
658
  registerTrashCommands(program);
659
+ registerRestoreCommand(program);
640
660
  registerDoctorCommand(program);
641
661
  // Deprecated 'exec' alias for 'run'
642
662
  program
@@ -651,6 +671,7 @@ program
651
671
  });
652
672
  registerProfilesCommands(program);
653
673
  registerSecretsCommands(program);
674
+ registerWalletCommands(program);
654
675
  registerHelperCommand(program);
655
676
  registerBetaCommands(program);
656
677
  registerSyncCommand(program);