@phnx-labs/agents-cli 1.20.12 → 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 +13 -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 +4 -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
@@ -35,7 +35,7 @@ import { discoverPlugins } from './plugins.js';
35
35
  import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from './staleness/index.js';
36
36
  import { emit } from './events.js';
37
37
  import { safeJoin } from './paths.js';
38
- import { listCommandSkillsInVersion, shouldInstallCommandAsSkill } from './command-skills.js';
38
+ import { listCommandSkillsInVersion, readSkillSourceCommandMarker, shouldInstallCommandAsSkill } from './command-skills.js';
39
39
  import { getWriter, getDetector } from './staleness/registry.js';
40
40
  /** Promisified exec for running shell commands. */
41
41
  const execAsync = promisify(exec);
@@ -804,10 +804,7 @@ export function getVersionDir(agent, version) {
804
804
  export function getBinaryPath(agent, version) {
805
805
  const agentConfig = AGENTS[agent];
806
806
  if (agent === 'grok') {
807
- // Grok binaries live in the global ~/.grok/downloads, not per-version node_modules.
808
- // We return a best-effort path (used for display / checks). Real resolution
809
- // happens in agents.ts resolveGrokBinary + the generated shims.
810
- const grokDownloads = path.join(os.homedir(), '.grok', 'downloads');
807
+ const grokDownloads = path.join(getVersionHomePath(agent, version), '.grok', 'downloads');
811
808
  // Best effort: first matching file for this version
812
809
  try {
813
810
  const entries = fs.readdirSync(grokDownloads);
@@ -853,6 +850,26 @@ export async function getLatestNpmVersion(agent) {
853
850
  return null;
854
851
  }
855
852
  }
853
+ /**
854
+ * Get the oldest published version from npm for an agent.
855
+ */
856
+ export async function getOldestNpmVersion(agent) {
857
+ const agentConfig = AGENTS[agent];
858
+ if (!agentConfig.npmPackage)
859
+ return null;
860
+ try {
861
+ const { stdout } = await execFileAsync('npm', ['view', agentConfig.npmPackage, 'versions', '--json'], { shell: process.platform === 'win32' });
862
+ const parsed = JSON.parse(stdout.trim());
863
+ // `npm view ... versions --json` returns an array (multiple versions) or a
864
+ // bare string (single published version). Normalize to an array.
865
+ const versions = Array.isArray(parsed) ? parsed : [parsed];
866
+ const sorted = versions.filter((v) => VERSION_RE.test(v)).sort(compareVersions);
867
+ return sorted[0] ?? null;
868
+ }
869
+ catch {
870
+ return null;
871
+ }
872
+ }
856
873
  /**
857
874
  * Check if 'latest' version is already installed (by resolving to actual version).
858
875
  */
@@ -863,6 +880,16 @@ export async function isLatestInstalled(agent) {
863
880
  }
864
881
  return { installed: isVersionInstalled(agent, latestVersion), version: latestVersion };
865
882
  }
883
+ /**
884
+ * Check if 'oldest' published version is already installed (by resolving to actual version).
885
+ */
886
+ export async function isOldestInstalled(agent) {
887
+ const oldestVersion = await getOldestNpmVersion(agent);
888
+ if (!oldestVersion) {
889
+ return { installed: false, version: null };
890
+ }
891
+ return { installed: isVersionInstalled(agent, oldestVersion), version: oldestVersion };
892
+ }
866
893
  /**
867
894
  * List all installed versions for an agent.
868
895
  */
@@ -996,6 +1023,20 @@ export async function installVersion(agent, version, onProgress) {
996
1023
  emit('version.install', { agent, version: installedVersion });
997
1024
  return { success: true, installedVersion };
998
1025
  }
1026
+ // Resolve the `oldest` alias to a concrete npm version up front so the rest
1027
+ // of the install path treats it as an ordinary pinned install. (`latest`
1028
+ // keeps its bare-package-name + post-install-rename handling below.)
1029
+ if (version === 'oldest') {
1030
+ const oldest = await getOldestNpmVersion(agent);
1031
+ if (!oldest) {
1032
+ return {
1033
+ success: false,
1034
+ installedVersion: version,
1035
+ error: `Could not resolve the oldest published version for ${agentConfig.name} from npm.`,
1036
+ };
1037
+ }
1038
+ version = oldest;
1039
+ }
999
1040
  ensureAgentsDir();
1000
1041
  const versionDir = getVersionDir(agent, version);
1001
1042
  // Create version directory and isolated home
@@ -1012,6 +1053,8 @@ export async function installVersion(agent, version, onProgress) {
1012
1053
  const packageSpec = version === 'latest'
1013
1054
  ? agentConfig.npmPackage
1014
1055
  : `${agentConfig.npmPackage}@${version}`;
1056
+ // The `${agentConfig.npmPackage}@` prefix is load-bearing: it ensures `version`
1057
+ // (which VERSION_RE permits to start with `-`) is never passed as a standalone npm CLI flag.
1015
1058
  try {
1016
1059
  // Check npm is available
1017
1060
  const winShell = process.platform === 'win32';
@@ -1120,7 +1163,17 @@ export function softDeleteVersionDir(agent, version) {
1120
1163
  const trashDest = path.join(trashAgentDir, stamp);
1121
1164
  try {
1122
1165
  fs.mkdirSync(trashAgentDir, { recursive: true, mode: 0o700 });
1123
- fs.renameSync(versionDir, trashDest);
1166
+ try {
1167
+ fs.renameSync(versionDir, trashDest);
1168
+ }
1169
+ catch (renameErr) {
1170
+ // On Windows, rename fails with EPERM/EACCES when any file in the tree
1171
+ // is locked by a running process. Fall back to recursive copy + delete.
1172
+ if (renameErr.code !== 'EPERM' && renameErr.code !== 'EACCES')
1173
+ throw renameErr;
1174
+ fs.cpSync(versionDir, trashDest, { recursive: true });
1175
+ fs.rmSync(versionDir, { recursive: true, force: true });
1176
+ }
1124
1177
  return trashDest;
1125
1178
  }
1126
1179
  catch {
@@ -1182,10 +1235,10 @@ export function printTrashFooter(moved) {
1182
1235
  console.log(chalk.gray('Sessions remain accessible via `agents sessions`.'));
1183
1236
  if (moved.length === 1) {
1184
1237
  const { agent, version } = moved[0];
1185
- console.log(chalk.gray(`Restore with: agents trash restore ${agent}@${version}`));
1238
+ console.log(chalk.gray(`Restore with: agents restore ${agent}@${version}`));
1186
1239
  }
1187
1240
  else {
1188
- console.log(chalk.gray('Restore with: agents trash restore <agent>@<version> (run `agents trash list` to see)'));
1241
+ console.log(chalk.gray('Restore with: agents restore <agent>@<version> (run `agents trash list` to see)'));
1189
1242
  }
1190
1243
  }
1191
1244
  /**
@@ -1223,6 +1276,7 @@ export function resolveVersion(agent, projectPath) {
1223
1276
  *
1224
1277
  * undefined / "" / "default" -> undefined (caller falls back to project pin or global default)
1225
1278
  * "latest" -> highest installed version (process.exit if none installed)
1279
+ * "oldest" -> lowest installed version (process.exit if none installed)
1226
1280
  * "x.y.z" (installed) -> "x.y.z"
1227
1281
  * "x.y.z" (not installed) -> process.exit with installed-list hint
1228
1282
  *
@@ -1234,14 +1288,14 @@ export function resolveVersion(agent, projectPath) {
1234
1288
  export function resolveVersionAlias(agent, raw) {
1235
1289
  if (!raw || raw === 'default')
1236
1290
  return undefined;
1237
- if (raw === 'latest') {
1291
+ if (raw === 'latest' || raw === 'oldest') {
1238
1292
  const installed = listInstalledVersions(agent);
1239
1293
  if (installed.length === 0) {
1240
1294
  console.error(chalk.red(`No ${agent} versions installed.`));
1241
1295
  console.error(chalk.gray(`Install one: agents versions install ${agent}`));
1242
1296
  process.exit(1);
1243
1297
  }
1244
- return installed[installed.length - 1];
1298
+ return raw === 'oldest' ? installed[0] : installed[installed.length - 1];
1245
1299
  }
1246
1300
  if (!isVersionInstalled(agent, raw)) {
1247
1301
  const installed = listInstalledVersions(agent);
@@ -1256,16 +1310,18 @@ export function resolveVersionAlias(agent, raw) {
1256
1310
  }
1257
1311
  /**
1258
1312
  * Loose variant of resolveVersionAlias for record-filter contexts (sessions,
1259
- * team history). Same `default`/`latest` semantics, but explicit versions
1260
- * pass through unchanged so historical records of uninstalled versions remain
1261
- * queryable.
1313
+ * team history). Same `default`/`latest`/`oldest` semantics, but explicit
1314
+ * versions pass through unchanged so historical records of uninstalled versions
1315
+ * remain queryable.
1262
1316
  */
1263
1317
  export function resolveVersionAliasLoose(agent, raw) {
1264
1318
  if (!raw || raw === 'default')
1265
1319
  return undefined;
1266
- if (raw === 'latest') {
1320
+ if (raw === 'latest' || raw === 'oldest') {
1267
1321
  const installed = listInstalledVersions(agent);
1268
- return installed.length > 0 ? installed[installed.length - 1] : undefined;
1322
+ if (installed.length === 0)
1323
+ return undefined;
1324
+ return raw === 'oldest' ? installed[0] : installed[installed.length - 1];
1269
1325
  }
1270
1326
  return raw;
1271
1327
  }
@@ -1643,10 +1699,10 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1643
1699
  const commandsToSync = selection
1644
1700
  ? resolveSelection(selection.commands, available.commands)
1645
1701
  : available.commands; // No selection = sync all
1702
+ const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
1646
1703
  if (commandsToSync.length > 0 && commandsWriter) {
1647
1704
  const commandsTarget = path.join(agentDir, agentConfig.commandsSubdir);
1648
- const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
1649
- if (commandsAsSkills) {
1705
+ if (commandsAsSkills && agentConfig.commandsSubdir) {
1650
1706
  removePath(commandsTarget);
1651
1707
  }
1652
1708
  const r = commandsWriter.write({ version, versionHome, selection: commandsToSync, cwd });
@@ -1680,9 +1736,22 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1680
1736
  // ~/.agents/skills/ (Gemini) are not registered; we clear the version-home
1681
1737
  // skills dir for them so a stale per-version copy never shadows central.
1682
1738
  const skillsWriter = getWriter('skills', agent);
1683
- const skillsToSync = selection
1739
+ let skillsToSync = selection
1684
1740
  ? resolveSelection(selection.skills, available.skills)
1685
1741
  : available.skills;
1742
+ if (commandsAsSkills && commandsToSync.length > 0 && skillsToSync.length > 0) {
1743
+ const commandNames = new Set(commandsToSync);
1744
+ const skillRoots = [
1745
+ path.join(getUserAgentsDir(), 'skills'),
1746
+ getSkillsDir(),
1747
+ ...getEnabledExtraRepos().map((e) => path.join(e.dir, 'skills')),
1748
+ ];
1749
+ skillsToSync = skillsToSync.filter((skill) => {
1750
+ if (!commandNames.has(skill))
1751
+ return true;
1752
+ return readSkillSourceCommandMarker(skill, skillRoots) !== skill;
1753
+ });
1754
+ }
1686
1755
  if (agentConfig.nativeAgentsSkillsDir) {
1687
1756
  removePath(path.join(agentDir, 'skills'));
1688
1757
  }
@@ -1745,7 +1814,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1745
1814
  // CAPABLE_AGENTS list and silently skipped it). Project rules are NOT
1746
1815
  // synced into the version home — they are composed into the workspace at
1747
1816
  // agents-run time (see compileRulesForProject).
1748
- const skipMemory = selection && (selection.memory === undefined || (Array.isArray(selection.memory) && selection.memory.length === 0));
1817
+ const skipMemory = selection && Array.isArray(selection.memory) && selection.memory.length === 0;
1749
1818
  const rulesWriter = getWriter('rules', agent);
1750
1819
  if (!skipMemory && rulesWriter) {
1751
1820
  try {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Wallet: device-local credit-card vault.
3
+ *
4
+ * Two-tier storage so listing the wallet doesn't pop Touch ID, but reading
5
+ * a card always does:
6
+ *
7
+ * Card metadata (id, nickname, brand, last4, expiry, created_at, kind)
8
+ * -> ~/.agents/wallet/cards.json, mode 0600
9
+ * -> Display-only data, equivalent of Apple Wallet's "card art" tier.
10
+ *
11
+ * Card secret (PAN, CVC, cardholder)
12
+ * -> Keychain item `agents-cli.secrets.wallet.<id>` (JSON-encoded)
13
+ * -> Routed through the signed helper, so the OS gates decryption with
14
+ * Touch ID + biometryCurrentSet. Re-enrolling Touch ID invalidates
15
+ * the item, matching Apple Pay's AR-value rotation behavior.
16
+ *
17
+ * Not Apple Pay: we store a real PAN, not a network DPAN, and we do not
18
+ * generate per-transaction cryptograms. Callers must surface this clearly.
19
+ */
20
+ /** Test seam: override the index path. Returns the previous override (or null). */
21
+ export declare function _setIndexPathForTest(p: string | null): string | null;
22
+ export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'diners' | 'jcb' | 'unionpay' | 'unknown';
23
+ /** Storage kind discriminator. Reserved for future kind: 'stripe_token'. */
24
+ export type CardKind = 'pan_encrypted';
25
+ /** Non-sensitive card metadata. Safe to display without biometric auth. */
26
+ export interface CardMetadata {
27
+ id: string;
28
+ nickname: string;
29
+ brand: CardBrand;
30
+ last4: string;
31
+ exp_month: string;
32
+ exp_year: string;
33
+ created_at: string;
34
+ kind: CardKind;
35
+ }
36
+ /** Sensitive card fields. Keychain-stored, Touch ID required to read. */
37
+ export interface CardSecret {
38
+ pan: string;
39
+ cvc: string;
40
+ cardholder: string;
41
+ }
42
+ /** Card metadata plus its secret payload. Returned by show() only. */
43
+ export interface CardFull extends CardMetadata, CardSecret {
44
+ }
45
+ /** Input shape for add(). */
46
+ export interface AddCardInput {
47
+ nickname: string;
48
+ pan: string;
49
+ cvc: string;
50
+ cardholder: string;
51
+ exp_month: string;
52
+ exp_year: string;
53
+ }
54
+ /** Compute the Luhn checksum and verify a candidate PAN. */
55
+ export declare function isValidLuhn(pan: string): boolean;
56
+ /** Detect card brand from the BIN (first 6 digits). */
57
+ export declare function detectBrand(pan: string): CardBrand;
58
+ /** Atomically read the index file. Returns [] when missing. */
59
+ export declare function readIndex(): CardMetadata[];
60
+ /** List all stored cards. Does NOT trigger biometric auth. */
61
+ export declare function listCards(): CardMetadata[];
62
+ /** Look up a card by id (or by case-insensitive nickname). Returns undefined when missing. */
63
+ export declare function findCard(idOrNickname: string): CardMetadata | undefined;
64
+ /**
65
+ * Add a new card. Validates Luhn + expiry. Returns the metadata for the
66
+ * stored card. The PAN/CVC/cardholder are written to Keychain in a single
67
+ * JSON blob; only metadata is mirrored to the index file.
68
+ */
69
+ export declare function addCard(input: AddCardInput): CardMetadata;
70
+ /**
71
+ * Reveal a card by id. **Triggers Touch ID on macOS.** Throws if the card
72
+ * isn't in the index or if the keychain entry is missing/cancelled.
73
+ */
74
+ export declare function showCard(idOrNickname: string): CardFull;
75
+ /** Delete a card from both the index and Keychain. Returns the removed metadata or undefined. */
76
+ export declare function removeCard(idOrNickname: string): CardMetadata | undefined;
77
+ /** Rename a card. Throws if the new nickname collides with an existing card. */
78
+ export declare function renameCard(idOrNickname: string, newNickname: string): CardMetadata;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Wallet: device-local credit-card vault.
3
+ *
4
+ * Two-tier storage so listing the wallet doesn't pop Touch ID, but reading
5
+ * a card always does:
6
+ *
7
+ * Card metadata (id, nickname, brand, last4, expiry, created_at, kind)
8
+ * -> ~/.agents/wallet/cards.json, mode 0600
9
+ * -> Display-only data, equivalent of Apple Wallet's "card art" tier.
10
+ *
11
+ * Card secret (PAN, CVC, cardholder)
12
+ * -> Keychain item `agents-cli.secrets.wallet.<id>` (JSON-encoded)
13
+ * -> Routed through the signed helper, so the OS gates decryption with
14
+ * Touch ID + biometryCurrentSet. Re-enrolling Touch ID invalidates
15
+ * the item, matching Apple Pay's AR-value rotation behavior.
16
+ *
17
+ * Not Apple Pay: we store a real PAN, not a network DPAN, and we do not
18
+ * generate per-transaction cryptograms. Callers must surface this clearly.
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as os from 'os';
22
+ import * as path from 'path';
23
+ import * as crypto from 'crypto';
24
+ import { deleteKeychainToken, getKeychainToken, secretsKeychainItem, setKeychainToken, } from '../secrets/index.js';
25
+ const WALLET_BUNDLE = 'wallet';
26
+ const DEFAULT_INDEX_DIR = path.join(os.homedir(), '.agents', 'wallet');
27
+ const DEFAULT_INDEX_PATH = path.join(DEFAULT_INDEX_DIR, 'cards.json');
28
+ let indexPathOverride = null;
29
+ function indexPath() {
30
+ return indexPathOverride ?? DEFAULT_INDEX_PATH;
31
+ }
32
+ function indexDir() {
33
+ return path.dirname(indexPath());
34
+ }
35
+ /** Test seam: override the index path. Returns the previous override (or null). */
36
+ export function _setIndexPathForTest(p) {
37
+ const prev = indexPathOverride;
38
+ indexPathOverride = p;
39
+ return prev;
40
+ }
41
+ /** Compute the Luhn checksum and verify a candidate PAN. */
42
+ export function isValidLuhn(pan) {
43
+ const digits = pan.replace(/\D/g, '');
44
+ if (digits.length < 12 || digits.length > 19)
45
+ return false;
46
+ let sum = 0;
47
+ let alt = false;
48
+ for (let i = digits.length - 1; i >= 0; i--) {
49
+ let n = digits.charCodeAt(i) - 48;
50
+ if (alt) {
51
+ n *= 2;
52
+ if (n > 9)
53
+ n -= 9;
54
+ }
55
+ sum += n;
56
+ alt = !alt;
57
+ }
58
+ return sum % 10 === 0;
59
+ }
60
+ /** Detect card brand from the BIN (first 6 digits). */
61
+ export function detectBrand(pan) {
62
+ const d = pan.replace(/\D/g, '');
63
+ if (/^4/.test(d))
64
+ return 'visa';
65
+ if (/^(5[1-5]|2[2-7])/.test(d))
66
+ return 'mastercard';
67
+ if (/^3[47]/.test(d))
68
+ return 'amex';
69
+ if (/^6(011|5|4[4-9])/.test(d))
70
+ return 'discover';
71
+ if (/^3(0[0-5]|[689])/.test(d))
72
+ return 'diners';
73
+ if (/^35(2[89]|[3-8])/.test(d))
74
+ return 'jcb';
75
+ if (/^(62|81)/.test(d))
76
+ return 'unionpay';
77
+ return 'unknown';
78
+ }
79
+ function normalizeMonth(mm) {
80
+ const n = Number(mm);
81
+ if (!Number.isInteger(n) || n < 1 || n > 12) {
82
+ throw new Error(`Invalid expiration month: ${mm}`);
83
+ }
84
+ return n.toString().padStart(2, '0');
85
+ }
86
+ function normalizeYear(yy) {
87
+ const d = yy.replace(/\D/g, '');
88
+ if (d.length === 2)
89
+ return '20' + d;
90
+ if (d.length === 4) {
91
+ const n = Number(d);
92
+ if (n < 2000 || n > 2100)
93
+ throw new Error(`Invalid expiration year: ${yy}`);
94
+ return d;
95
+ }
96
+ throw new Error(`Invalid expiration year: ${yy}`);
97
+ }
98
+ function ensureIndexDir() {
99
+ fs.mkdirSync(indexDir(), { recursive: true, mode: 0o700 });
100
+ }
101
+ /** Atomically read the index file. Returns [] when missing. */
102
+ export function readIndex() {
103
+ const p = indexPath();
104
+ if (!fs.existsSync(p))
105
+ return [];
106
+ const raw = fs.readFileSync(p, 'utf-8');
107
+ if (!raw.trim())
108
+ return [];
109
+ const parsed = JSON.parse(raw);
110
+ if (!Array.isArray(parsed)) {
111
+ throw new Error(`Wallet index at ${p} is not an array.`);
112
+ }
113
+ return parsed;
114
+ }
115
+ /** Atomically write the index file via tmp + rename. */
116
+ function writeIndex(cards) {
117
+ ensureIndexDir();
118
+ const p = indexPath();
119
+ const tmp = p + '.tmp.' + process.pid;
120
+ fs.writeFileSync(tmp, JSON.stringify(cards, null, 2), { mode: 0o600 });
121
+ fs.renameSync(tmp, indexPath());
122
+ }
123
+ function walletKeychainItem(id) {
124
+ return secretsKeychainItem(WALLET_BUNDLE, id);
125
+ }
126
+ function generateId() {
127
+ // 12 hex chars (6 bytes). Unique enough for a per-user wallet; short
128
+ // enough to type if needed.
129
+ return crypto.randomBytes(6).toString('hex');
130
+ }
131
+ /** List all stored cards. Does NOT trigger biometric auth. */
132
+ export function listCards() {
133
+ return readIndex();
134
+ }
135
+ /** Look up a card by id (or by case-insensitive nickname). Returns undefined when missing. */
136
+ export function findCard(idOrNickname) {
137
+ const cards = readIndex();
138
+ const exact = cards.find((c) => c.id === idOrNickname);
139
+ if (exact)
140
+ return exact;
141
+ const lc = idOrNickname.toLowerCase();
142
+ return cards.find((c) => c.nickname.toLowerCase() === lc);
143
+ }
144
+ /**
145
+ * Add a new card. Validates Luhn + expiry. Returns the metadata for the
146
+ * stored card. The PAN/CVC/cardholder are written to Keychain in a single
147
+ * JSON blob; only metadata is mirrored to the index file.
148
+ */
149
+ export function addCard(input) {
150
+ const pan = input.pan.replace(/\s+/g, '');
151
+ if (!/^\d+$/.test(pan))
152
+ throw new Error('PAN must contain only digits.');
153
+ if (!isValidLuhn(pan))
154
+ throw new Error('PAN failed Luhn checksum.');
155
+ const cvc = input.cvc.replace(/\s+/g, '');
156
+ if (!/^\d{3,4}$/.test(cvc))
157
+ throw new Error('CVC must be 3 or 4 digits.');
158
+ const nickname = input.nickname.trim();
159
+ if (!nickname)
160
+ throw new Error('Nickname is required.');
161
+ const cardholder = input.cardholder.trim();
162
+ if (!cardholder)
163
+ throw new Error('Cardholder name is required.');
164
+ if (/[\r\n]/.test(cardholder))
165
+ throw new Error('Cardholder name contains newlines.');
166
+ const exp_month = normalizeMonth(input.exp_month);
167
+ const exp_year = normalizeYear(input.exp_year);
168
+ const last4 = pan.slice(-4);
169
+ const brand = detectBrand(pan);
170
+ const cards = readIndex();
171
+ if (cards.some((c) => c.nickname.toLowerCase() === nickname.toLowerCase())) {
172
+ throw new Error(`A card named '${nickname}' already exists. Pick a different nickname.`);
173
+ }
174
+ const id = generateId();
175
+ const meta = {
176
+ id,
177
+ nickname,
178
+ brand,
179
+ last4,
180
+ exp_month,
181
+ exp_year,
182
+ created_at: new Date().toISOString(),
183
+ kind: 'pan_encrypted',
184
+ };
185
+ const secret = { pan, cvc, cardholder };
186
+ // Keychain item first; if it succeeds, mirror to index. If the index
187
+ // write fails afterward, attempt to roll back the keychain insertion
188
+ // so callers don't see ghost secrets.
189
+ setKeychainToken(walletKeychainItem(id), JSON.stringify(secret));
190
+ try {
191
+ writeIndex([...cards, meta]);
192
+ }
193
+ catch (err) {
194
+ try {
195
+ deleteKeychainToken(walletKeychainItem(id));
196
+ }
197
+ catch { /* best-effort rollback */ }
198
+ throw err;
199
+ }
200
+ return meta;
201
+ }
202
+ /**
203
+ * Reveal a card by id. **Triggers Touch ID on macOS.** Throws if the card
204
+ * isn't in the index or if the keychain entry is missing/cancelled.
205
+ */
206
+ export function showCard(idOrNickname) {
207
+ const meta = findCard(idOrNickname);
208
+ if (!meta)
209
+ throw new Error(`No card found matching '${idOrNickname}'.`);
210
+ const raw = getKeychainToken(walletKeychainItem(meta.id));
211
+ let secret;
212
+ try {
213
+ secret = JSON.parse(raw);
214
+ }
215
+ catch (err) {
216
+ throw new Error(`Card secret for '${meta.nickname}' is corrupted (not valid JSON).`);
217
+ }
218
+ if (!secret.pan || !secret.cvc || !secret.cardholder) {
219
+ throw new Error(`Card secret for '${meta.nickname}' is missing required fields.`);
220
+ }
221
+ return { ...meta, ...secret };
222
+ }
223
+ /** Delete a card from both the index and Keychain. Returns the removed metadata or undefined. */
224
+ export function removeCard(idOrNickname) {
225
+ const cards = readIndex();
226
+ const meta = findCard(idOrNickname);
227
+ if (!meta)
228
+ return undefined;
229
+ const remaining = cards.filter((c) => c.id !== meta.id);
230
+ writeIndex(remaining);
231
+ try {
232
+ deleteKeychainToken(walletKeychainItem(meta.id));
233
+ }
234
+ catch { /* keychain may already be gone */ }
235
+ return meta;
236
+ }
237
+ /** Rename a card. Throws if the new nickname collides with an existing card. */
238
+ export function renameCard(idOrNickname, newNickname) {
239
+ const nickname = newNickname.trim();
240
+ if (!nickname)
241
+ throw new Error('Nickname is required.');
242
+ const cards = readIndex();
243
+ const meta = findCard(idOrNickname);
244
+ if (!meta)
245
+ throw new Error(`No card found matching '${idOrNickname}'.`);
246
+ if (cards.some((c) => c.id !== meta.id && c.nickname.toLowerCase() === nickname.toLowerCase())) {
247
+ throw new Error(`A card named '${nickname}' already exists.`);
248
+ }
249
+ const updated = { ...meta, nickname };
250
+ const next = cards.map((c) => (c.id === meta.id ? updated : c));
251
+ writeIndex(next);
252
+ return updated;
253
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.12",
3
+ "version": "1.20.13",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -97,9 +97,9 @@
97
97
  "devDependencies": {
98
98
  "@types/diff": "8.0.0",
99
99
  "@types/marked-terminal": "6.1.1",
100
- "@types/node": "25.9.2",
100
+ "@types/node": "25.9.3",
101
101
  "tsx": "4.22.4",
102
102
  "typescript": "6.0.3",
103
- "vitest": "4.1.8"
103
+ "vitest": "4.1.9"
104
104
  }
105
105
  }
@@ -154,17 +154,45 @@ function isAlreadyConfigured(rcFile) {
154
154
  }
155
155
 
156
156
  async function main() {
157
- // Windows has no shell rc files to edit. Write the `.cmd` shorthands here; the
158
- // shims dir gets registered on the User PATH by `agents setup` (via the native
159
- // registry API), so we point the user there instead of mutating PATH from an
160
- // npm lifecycle script. The primary `agents` command is already on PATH via
161
- // npm's global bin and works immediately.
157
+ // Windows has no shell rc files to edit. Write the `.cmd` shorthands here, then
158
+ // make sure npm's global-bin dir is on the User PATH so the `agents` command
159
+ // itself resolves: Node's installer normally adds it, but winget / portable /
160
+ // nvm-windows setups often don't and then `npm i -g` succeeds yet `agents`
161
+ // is "not recognized". The shims dir (claude/codex/...) is still left to
162
+ // `agents setup`, which the user can now run because `agents` is discoverable.
162
163
  if (process.platform === 'win32') {
163
164
  console.log(`\nagents-cli installed.`);
164
165
  const written = writeAliasShims();
165
166
  console.log(` Installed shorthands: ${written.join(', ')}`);
166
- console.log(`\nNext: run agents setup — it finishes setup and adds the shims dir to your PATH`);
167
- console.log(`(so the bare shorthands ${ALIASES.join(', ')} and versioned aliases work in a new terminal).`);
167
+
168
+ // Best-effort: import the platform leaf module from the just-installed dist.
169
+ // If it's missing or PowerShell is unavailable we degrade to plain guidance.
170
+ try {
171
+ const { prependToWindowsUserPath, getEffectiveExecutionPolicy, blocksLocalScripts, npmGlobalBinFromEntry } =
172
+ await import('../dist/lib/platform/winpath.js');
173
+
174
+ const npmBinDir = npmGlobalBinFromEntry(AGENTS_BIN);
175
+ const pathResult = prependToWindowsUserPath(npmBinDir);
176
+ if (pathResult.success && !pathResult.alreadyPresent) {
177
+ console.log(` Added npm's global bin to your user PATH so 'agents' resolves:\n ${npmBinDir}`);
178
+ } else if (!pathResult.success) {
179
+ console.log(` Could not update PATH automatically. Add this to your user PATH manually:\n ${npmBinDir}`);
180
+ }
181
+
182
+ // .ps1 launchers (npm.ps1, agents.ps1) are blocked under Restricted/AllSigned;
183
+ // we can't safely weaken a security setting from an installer, so guide instead.
184
+ const policy = getEffectiveExecutionPolicy();
185
+ if (blocksLocalScripts(policy)) {
186
+ console.log(`\n PowerShell execution policy is '${policy}', which blocks the 'agents' launcher (a .ps1).`);
187
+ console.log(` Allow local scripts for your user:`);
188
+ console.log(` Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`);
189
+ }
190
+ } catch {
191
+ /* dist or PowerShell unavailable — skip; `agents setup` still wires shims */
192
+ }
193
+
194
+ console.log(`\nNext: open a new terminal, then run agents setup`);
195
+ console.log(`(adds the shims dir so bare ${ALIASES.join(', ')} and versioned aliases work).`);
168
196
  }
169
197
  // Opt-in: AGENTS_INIT_SHELL=1 npm install -g @phnx-labs/agents-cli
170
198
  else if (process.env.AGENTS_INIT_SHELL === '1') {