@phnx-labs/agents-cli 1.19.0 → 1.19.2

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 (34) hide show
  1. package/README.md +1 -1
  2. package/dist/browser.js +0 -0
  3. package/dist/commands/exec.js +1 -1
  4. package/dist/commands/mcp.js +29 -0
  5. package/dist/commands/secrets.js +4 -4
  6. package/dist/commands/sessions.d.ts +8 -7
  7. package/dist/commands/sessions.js +32 -20
  8. package/dist/commands/versions.js +21 -2
  9. package/dist/computer.js +0 -0
  10. package/dist/index.js +0 -0
  11. package/dist/lib/agents.js +66 -0
  12. package/dist/lib/browser/chrome.js +1 -1
  13. package/dist/lib/exec.js +21 -0
  14. package/dist/lib/registry.d.ts +18 -0
  15. package/dist/lib/registry.js +44 -0
  16. package/dist/lib/resources/mcp.js +6 -1
  17. package/dist/lib/resources/types.d.ts +1 -1
  18. package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/CodeResources +0 -0
  19. package/dist/lib/secrets/{AgentsKeychain.app/Contents/Info.plist → Agents CLI.app/Contents/Info.plist } +4 -2
  20. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  21. package/dist/lib/secrets/bundles.d.ts +11 -1
  22. package/dist/lib/secrets/bundles.js +37 -12
  23. package/dist/lib/secrets/index.d.ts +15 -1
  24. package/dist/lib/secrets/index.js +101 -26
  25. package/dist/lib/session/db.d.ts +10 -0
  26. package/dist/lib/session/db.js +26 -0
  27. package/dist/lib/shims.d.ts +5 -1
  28. package/dist/lib/shims.js +22 -6
  29. package/dist/lib/types.d.ts +1 -1
  30. package/npm-shrinkwrap.json +890 -984
  31. package/package.json +5 -5
  32. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  33. /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/_CodeSignature/CodeResources +0 -0
  34. /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/embedded.provisionprofile +0 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-platform secure credential storage.
3
3
  *
4
- * macOS: Uses Keychain via signed Swift helper (AgentsKeychain.app) or `security` CLI.
4
+ * macOS: Uses Keychain via signed Swift helper (Agents CLI.app) or `security` CLI.
5
5
  * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
6
  * Windows: Not yet supported.
7
7
  *
@@ -56,6 +56,20 @@ export declare function setKeychainBackendForTest(b: KeychainBackend | null): Ke
56
56
  export declare function hasKeychainToken(item: string, sync?: boolean): boolean;
57
57
  /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
58
58
  export declare function getKeychainToken(item: string, sync?: boolean): string;
59
+ /**
60
+ * Batch-read multiple keychain items behind a single LocalAuthentication
61
+ * prompt. macOS shows ONE Touch ID prompt and every requested item is
62
+ * unlocked in the same process. Returns a Map keyed by item name. Missing
63
+ * items are absent from the map (caller decides whether that's an error).
64
+ *
65
+ * `reason` is shown to the user under the Touch ID prompt — e.g.
66
+ * "read 'hetzner.com' secrets for agent 'claude'". Apple's HIG recommends
67
+ * a lowercase verb phrase that completes the sentence "X is required to ___".
68
+ *
69
+ * On Linux or when a test backend is installed, falls back to individual
70
+ * `get` calls — no biometric prompt path on those platforms.
71
+ */
72
+ export declare function getKeychainTokensBatch(items: string[], _sync?: boolean, reason?: string): Map<string, string>;
59
73
  /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
60
74
  export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
61
75
  /** Delete a keychain/keyring item. Returns true if it existed. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-platform secure credential storage.
3
3
  *
4
- * macOS: Uses Keychain via signed Swift helper (AgentsKeychain.app) or `security` CLI.
4
+ * macOS: Uses Keychain via signed Swift helper (Agents CLI.app) or `security` CLI.
5
5
  * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
6
  * Windows: Not yet supported.
7
7
  *
@@ -55,7 +55,7 @@ export function profileKeychainItem(provider) {
55
55
  export function secretsKeychainItem(bundle, key) {
56
56
  return `${SERVICE_PREFIX}.secrets.${bundle}.${key}`;
57
57
  }
58
- // Resolve the bundled, signed-and-notarized AgentsKeychain.app shipped
58
+ // Resolve the bundled, signed-and-notarized Agents CLI.app shipped
59
59
  // alongside the compiled JS. The .app embeds a provisioning profile that
60
60
  // grants the application-identifier + keychain-access-groups entitlements
61
61
  // macOS requires for kSecAttrSynchronizable writes. Bare CLI binaries
@@ -64,7 +64,7 @@ export function secretsKeychainItem(bundle, key) {
64
64
  // be prebuilt by `scripts/build-keychain-helper.sh` and shipped.
65
65
  function ensureKeychainHelper() {
66
66
  const here = path.dirname(fileURLToPath(import.meta.url));
67
- const binPath = path.join(here, 'AgentsKeychain.app', 'Contents', 'MacOS', 'AgentsKeychain');
67
+ const binPath = path.join(here, 'Agents CLI.app', 'Contents', 'MacOS', 'Agents CLI');
68
68
  if (!fs.existsSync(binPath)) {
69
69
  throw new Error(`Keychain helper missing at ${binPath}. ` +
70
70
  'This npm package was built without the signed helper bundle. Reinstall agents-cli.');
@@ -118,7 +118,9 @@ export function getKeychainToken(item, sync = false) {
118
118
  if (token)
119
119
  return token;
120
120
  }
121
- // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
121
+ // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny.
122
+ // `get` is the unauthenticated path — no LocalAuthentication prompt. Used by
123
+ // profiles.ts (OAuth refresh) where biometric on every API call is too noisy.
122
124
  const bin = ensureKeychainHelper();
123
125
  const result = spawnSync(bin, ['get', item, os.userInfo().username], {
124
126
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -134,6 +136,87 @@ export function getKeychainToken(item, sync = false) {
134
136
  throw new Error(`Keychain item '${item}' exists but is empty.`);
135
137
  return token;
136
138
  }
139
+ /**
140
+ * Batch-read multiple keychain items behind a single LocalAuthentication
141
+ * prompt. macOS shows ONE Touch ID prompt and every requested item is
142
+ * unlocked in the same process. Returns a Map keyed by item name. Missing
143
+ * items are absent from the map (caller decides whether that's an error).
144
+ *
145
+ * `reason` is shown to the user under the Touch ID prompt — e.g.
146
+ * "read 'hetzner.com' secrets for agent 'claude'". Apple's HIG recommends
147
+ * a lowercase verb phrase that completes the sentence "X is required to ___".
148
+ *
149
+ * On Linux or when a test backend is installed, falls back to individual
150
+ * `get` calls — no biometric prompt path on those platforms.
151
+ */
152
+ export function getKeychainTokensBatch(items, _sync = false, reason) {
153
+ const result = new Map();
154
+ if (items.length === 0)
155
+ return result;
156
+ if (backend) {
157
+ for (const item of items) {
158
+ try {
159
+ result.set(item, backend.get(item, _sync));
160
+ }
161
+ catch { /* missing — skip */ }
162
+ }
163
+ return result;
164
+ }
165
+ assertSupportedPlatform();
166
+ if (isLinux()) {
167
+ for (const item of items) {
168
+ try {
169
+ result.set(item, linuxBackend.get(item, _sync));
170
+ }
171
+ catch { /* missing — skip */ }
172
+ }
173
+ return result;
174
+ }
175
+ // macOS: single helper invocation with Touch ID gate.
176
+ const bin = ensureKeychainHelper();
177
+ const helperArgs = ['get-batch', os.userInfo().username];
178
+ if (reason)
179
+ helperArgs.push('--reason', reason);
180
+ helperArgs.push(...items);
181
+ const child = spawnSync(bin, helperArgs, {
182
+ stdio: ['ignore', 'pipe', 'pipe'],
183
+ });
184
+ if (child.status !== 0) {
185
+ const msg = child.stderr?.toString().trim();
186
+ throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
187
+ }
188
+ const out = child.stdout?.toString() ?? '';
189
+ // Parser. Output is a sequence of records:
190
+ // "V <service>\n<value>\n" (present)
191
+ // "M <service>\n" (missing)
192
+ // Service names cannot contain '\n' (validated at write time); values are
193
+ // also newline-free (rejected by setKeychainToken). So splitting on '\n'
194
+ // and walking line-by-line is unambiguous.
195
+ const lines = out.split('\n');
196
+ // Last entry from split is the empty string after a trailing newline.
197
+ let i = 0;
198
+ while (i < lines.length) {
199
+ const line = lines[i];
200
+ if (line === '' && i === lines.length - 1)
201
+ break;
202
+ if (line.startsWith('V ')) {
203
+ const service = line.slice(2);
204
+ const value = lines[i + 1] ?? '';
205
+ result.set(service, value);
206
+ i += 2;
207
+ }
208
+ else if (line.startsWith('M ')) {
209
+ i += 1;
210
+ }
211
+ else if (line === '') {
212
+ i += 1;
213
+ }
214
+ else {
215
+ throw new Error(`Malformed get-batch output line: ${JSON.stringify(line)}`);
216
+ }
217
+ }
218
+ return result;
219
+ }
137
220
  /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
138
221
  export function setKeychainToken(item, value, sync = false) {
139
222
  if (backend) {
@@ -151,28 +234,23 @@ export function setKeychainToken(item, value, sync = false) {
151
234
  linuxBackend.set(item, value, sync);
152
235
  return;
153
236
  }
154
- // macOS path
155
- if (sync) {
156
- const bin = ensureKeychainHelper();
157
- const result = spawnSync(bin, ['set', item, os.userInfo().username], {
158
- input: value,
159
- stdio: ['pipe', 'pipe', 'pipe'],
160
- });
161
- if (result.status !== 0) {
162
- const msg = result.stderr?.toString().trim();
163
- throw new Error(msg || `Failed to write keychain item '${item}'.`);
164
- }
165
- return;
166
- }
167
- // `security -i` keeps the value out of argv (and `ps`).
168
- const user = os.userInfo().username;
169
- const cmd = `add-generic-password -a ${quoteForSecurityCli(user)} -s ${quoteForSecurityCli(item)} -w ${quoteForSecurityCli(value)} -T ${quoteForSecurityCli('')} -U\n`;
170
- const result = spawnSync('security', ['-i'], {
171
- input: cmd,
237
+ // macOS path. Both sync and non-sync writes go through the .app helper so
238
+ // the item picks up our SecAccess ACL (helper as trusted reader). That ACL
239
+ // is what stops macOS from showing the legacy "enter password" sheet on
240
+ // subsequent reads. The helper takes an optional `nosync` arg for
241
+ // device-local writes; sync writes get kSecAttrSynchronizable=true by
242
+ // default.
243
+ const bin = ensureKeychainHelper();
244
+ const args = ['set', item, os.userInfo().username];
245
+ if (!sync)
246
+ args.push('nosync');
247
+ const result = spawnSync(bin, args, {
248
+ input: value,
172
249
  stdio: ['pipe', 'pipe', 'pipe'],
173
250
  });
174
251
  if (result.status !== 0) {
175
- throw new Error(`Failed to write keychain item '${item}' (exit ${result.status}).`);
252
+ const msg = result.stderr?.toString().trim();
253
+ throw new Error(msg || `Failed to write keychain item '${item}'.`);
176
254
  }
177
255
  }
178
256
  /** Delete a keychain/keyring item. Returns true if it existed. */
@@ -193,9 +271,6 @@ export function deleteKeychainToken(item, sync = false) {
193
271
  stdio: ['ignore', 'pipe', 'pipe'],
194
272
  }).status === 0;
195
273
  }
196
- function quoteForSecurityCli(s) {
197
- return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
198
- }
199
274
  /** Enumerate keychain/keyring item names starting with the given prefix. */
200
275
  export function listKeychainItems(prefix) {
201
276
  if (backend)
@@ -157,5 +157,15 @@ export declare function getRowCount(): {
157
157
  };
158
158
  /** Count sessions older than the given timestamp (for dry-run previews). */
159
159
  export declare function countSessionsOlderThan(cutoffMs: number): number;
160
+ /**
161
+ * Rewrite file_path for all sessions whose path starts with oldPrefix, replacing
162
+ * it with newPrefix + the unchanged suffix. Also clears the matching scan_ledger
163
+ * entries so they are re-indexed from the new location on the next scan.
164
+ *
165
+ * Used by removeVersion after soft-deleting a version directory to trash, so
166
+ * that session reads (transcript view, /continue) still work from the trash path.
167
+ * Returns the number of session rows updated.
168
+ */
169
+ export declare function updateSessionFilePaths(oldPrefix: string, newPrefix: string): number;
160
170
  /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
161
171
  export declare function deleteSessionsOlderThan(cutoffMs: number): number;
@@ -749,6 +749,32 @@ export function countSessionsOlderThan(cutoffMs) {
749
749
  const row = db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE timestamp < ?`).get(cutoffIso);
750
750
  return row.n;
751
751
  }
752
+ /**
753
+ * Rewrite file_path for all sessions whose path starts with oldPrefix, replacing
754
+ * it with newPrefix + the unchanged suffix. Also clears the matching scan_ledger
755
+ * entries so they are re-indexed from the new location on the next scan.
756
+ *
757
+ * Used by removeVersion after soft-deleting a version directory to trash, so
758
+ * that session reads (transcript view, /continue) still work from the trash path.
759
+ * Returns the number of session rows updated.
760
+ */
761
+ export function updateSessionFilePaths(oldPrefix, newPrefix) {
762
+ const db = getDB();
763
+ const rows = db
764
+ .prepare(`SELECT id, file_path FROM sessions WHERE file_path LIKE ?`)
765
+ .all(oldPrefix + '%');
766
+ if (rows.length === 0)
767
+ return 0;
768
+ const txn = db.transaction(() => {
769
+ for (const { id, file_path } of rows) {
770
+ const newPath = newPrefix + file_path.slice(oldPrefix.length);
771
+ db.prepare(`UPDATE sessions SET file_path = ? WHERE id = ?`).run(newPath, id);
772
+ db.prepare(`DELETE FROM scan_ledger WHERE file_path = ?`).run(canonicalLedgerKey(file_path));
773
+ }
774
+ });
775
+ txn();
776
+ return rows.length;
777
+ }
752
778
  /** Delete sessions older than the given timestamp. Returns the number of rows deleted. */
753
779
  export function deleteSessionsOlderThan(cutoffMs) {
754
780
  const db = getDB();
@@ -55,8 +55,12 @@ export interface ConflictInfo {
55
55
  * v12 — helper calls inside generated shims use the absolute agents-cli
56
56
  * entrypoint instead of PATH-resolved `agents`.
57
57
  * v13 — validate agents.yaml version strings before constructing binary paths.
58
+ * v14 — derive `configDirName` from `agentConfig.configDir` relative to $HOME
59
+ * instead of hardcoding `.${agent}`. Backwards-compatible for every
60
+ * existing agent (their configDir is `~/.{agent}`); enables nested
61
+ * layouts like Antigravity's `~/.gemini/antigravity-cli/`.
58
62
  */
59
- export declare const SHIM_SCHEMA_VERSION = 13;
63
+ export declare const SHIM_SCHEMA_VERSION = 14;
60
64
  /**
61
65
  * Generate the full bash shim script for the given agent. The returned string
62
66
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -179,8 +179,12 @@ async function promptConflictStrategy(conflictInfos) {
179
179
  * v12 — helper calls inside generated shims use the absolute agents-cli
180
180
  * entrypoint instead of PATH-resolved `agents`.
181
181
  * v13 — validate agents.yaml version strings before constructing binary paths.
182
+ * v14 — derive `configDirName` from `agentConfig.configDir` relative to $HOME
183
+ * instead of hardcoding `.${agent}`. Backwards-compatible for every
184
+ * existing agent (their configDir is `~/.{agent}`); enables nested
185
+ * layouts like Antigravity's `~/.gemini/antigravity-cli/`.
182
186
  */
183
- export const SHIM_SCHEMA_VERSION = 13;
187
+ export const SHIM_SCHEMA_VERSION = 14;
184
188
  /** Internal marker string used to embed the schema version in shim scripts. */
185
189
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
186
190
  function shellQuote(value) {
@@ -196,7 +200,11 @@ function getAgentsBinForGeneratedShim() {
196
200
  export function generateShimScript(agent) {
197
201
  const agentConfig = AGENTS[agent];
198
202
  const cliCommand = agentConfig.cliCommand;
199
- const configDirName = `.${agent}`;
203
+ // Derive the relative config-dir path from the registry. For most agents
204
+ // this is just `.${agent}` (e.g., `.claude`, `.codex`); for nested layouts
205
+ // like Antigravity (`~/.gemini/antigravity-cli`) it carries the full
206
+ // subpath so per-version HOME symlinks reach the right place.
207
+ const configDirName = path.relative(os.homedir(), agentConfig.configDir);
200
208
  const agentsBin = shellQuote(getAgentsBinForGeneratedShim());
201
209
  const managedEnv = agent === 'claude'
202
210
  ? `
@@ -504,7 +512,9 @@ function assertSafeVersion(version) {
504
512
  export function generateVersionedAliasScript(agent, version) {
505
513
  assertSafeVersion(version);
506
514
  const agentConfig = AGENTS[agent];
507
- const configDirName = `.${agent}`;
515
+ // Same derivation as `generateShimScript` so nested layouts (e.g.,
516
+ // Antigravity's `~/.gemini/antigravity-cli`) land in the right place.
517
+ const configDirName = path.relative(os.homedir(), agentConfig.configDir);
508
518
  const managedEnv = agent === 'claude'
509
519
  ? `
510
520
  # Claude stores OAuth credentials in the macOS keychain. Scope them to this
@@ -637,7 +647,9 @@ function getAgentConfigPath(agent) {
637
647
  function getVersionConfigPath(agent, version) {
638
648
  const agentConfig = AGENTS[agent];
639
649
  const versionsDir = getVersionsDir();
640
- const configDirName = `.${agent}`; // .claude, .codex, etc.
650
+ // Carry the agent's full configDir subpath so nested layouts work.
651
+ // e.g., antigravity → `.gemini/antigravity-cli`, claude → `.claude`.
652
+ const configDirName = path.relative(os.homedir(), agentConfig.configDir);
641
653
  return path.join(versionsDir, agent, version, 'home', configDirName);
642
654
  }
643
655
  /**
@@ -709,6 +721,7 @@ export async function switchConfigSymlink(agent, version) {
709
721
  }
710
722
  // Different target - update it
711
723
  fs.unlinkSync(configPath);
724
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
712
725
  fs.symlinkSync(versionConfigPath, configPath);
713
726
  return { success: true };
714
727
  }
@@ -721,7 +734,7 @@ export async function switchConfigSymlink(agent, version) {
721
734
  const finalBackupPath = path.join(agentBackupDir, String(timestamp));
722
735
  fs.mkdirSync(agentBackupDir, { recursive: true });
723
736
  fs.renameSync(configPath, finalBackupPath);
724
- // Create symlink
737
+ // Create symlink (parent already exists since the dir we just moved was here)
725
738
  fs.symlinkSync(versionConfigPath, configPath);
726
739
  return { success: true, backupPath: finalBackupPath };
727
740
  }
@@ -731,7 +744,10 @@ export async function switchConfigSymlink(agent, version) {
731
744
  }
732
745
  catch (err) {
733
746
  if (err.code === 'ENOENT') {
734
- // Config path doesn't exist - create symlink
747
+ // Config path doesn't exist - create symlink.
748
+ // For nested layouts (e.g., ~/.gemini/antigravity-cli) the parent dir
749
+ // may also be missing if the parent agent (Gemini) is not installed.
750
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
735
751
  fs.symlinkSync(versionConfigPath, configPath);
736
752
  return { success: true };
737
753
  }
@@ -6,7 +6,7 @@
6
6
  * formats for each supported agent.
7
7
  */
8
8
  /** Unique identifier for a supported AI coding agent. */
9
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'roo';
9
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'roo' | 'antigravity' | 'grok';
10
10
  /** How `agents run <agent>` chooses an installed version when none is pinned. */
11
11
  export type RunStrategy = 'pinned' | 'available' | 'balanced';
12
12
  /** Preview features that users can opt into via `agents beta`. */