@phnx-labs/agents-cli 1.14.2 → 1.14.3

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 (101) hide show
  1. package/README.md +17 -7
  2. package/dist/commands/browser.d.ts +2 -0
  3. package/dist/commands/browser.js +388 -0
  4. package/dist/commands/daemon.js +1 -1
  5. package/dist/commands/doctor.d.ts +16 -9
  6. package/dist/commands/doctor.js +248 -12
  7. package/dist/commands/prune.js +9 -3
  8. package/dist/commands/refresh-rules.d.ts +15 -0
  9. package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
  10. package/dist/commands/routines.js +1 -1
  11. package/dist/commands/rules.js +100 -4
  12. package/dist/commands/secrets.js +198 -11
  13. package/dist/commands/sync.js +19 -0
  14. package/dist/commands/teams.js +162 -22
  15. package/dist/commands/trash.d.ts +10 -0
  16. package/dist/commands/trash.js +187 -0
  17. package/dist/commands/view.js +46 -13
  18. package/dist/index.js +62 -4
  19. package/dist/lib/agents.js +2 -2
  20. package/dist/lib/browser/cdp.d.ts +24 -0
  21. package/dist/lib/browser/cdp.js +94 -0
  22. package/dist/lib/browser/chrome.d.ts +16 -0
  23. package/dist/lib/browser/chrome.js +157 -0
  24. package/dist/lib/browser/drivers/local.d.ts +8 -0
  25. package/dist/lib/browser/drivers/local.js +22 -0
  26. package/dist/lib/browser/drivers/ssh.d.ts +9 -0
  27. package/dist/lib/browser/drivers/ssh.js +129 -0
  28. package/dist/lib/browser/index.d.ts +5 -0
  29. package/dist/lib/browser/index.js +5 -0
  30. package/dist/lib/browser/input.d.ts +6 -0
  31. package/dist/lib/browser/input.js +52 -0
  32. package/dist/lib/browser/ipc.d.ts +12 -0
  33. package/dist/lib/browser/ipc.js +223 -0
  34. package/dist/lib/browser/profiles.d.ts +11 -0
  35. package/dist/lib/browser/profiles.js +61 -0
  36. package/dist/lib/browser/refs.d.ts +21 -0
  37. package/dist/lib/browser/refs.js +88 -0
  38. package/dist/lib/browser/service.d.ts +45 -0
  39. package/dist/lib/browser/service.js +404 -0
  40. package/dist/lib/browser/types.d.ts +73 -0
  41. package/dist/lib/browser/types.js +7 -0
  42. package/dist/lib/cloud/codex.js +1 -1
  43. package/dist/lib/cloud/registry.js +2 -2
  44. package/dist/lib/cloud/rush.js +2 -2
  45. package/dist/lib/cloud/store.js +2 -2
  46. package/dist/lib/daemon.d.ts +1 -1
  47. package/dist/lib/daemon.js +47 -11
  48. package/dist/lib/diff-text.d.ts +25 -0
  49. package/dist/lib/diff-text.js +47 -0
  50. package/dist/lib/doctor-diff.d.ts +64 -0
  51. package/dist/lib/doctor-diff.js +497 -0
  52. package/dist/lib/git.js +3 -3
  53. package/dist/lib/hooks.d.ts +6 -0
  54. package/dist/lib/hooks.js +6 -1
  55. package/dist/lib/migrate.js +77 -0
  56. package/dist/lib/pty-client.js +3 -3
  57. package/dist/lib/pty-server.js +36 -7
  58. package/dist/lib/resources.js +1 -1
  59. package/dist/lib/rotate.d.ts +8 -1
  60. package/dist/lib/rotate.js +17 -4
  61. package/dist/lib/rules/compile.d.ts +104 -0
  62. package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
  63. package/dist/lib/rules/compose.d.ts +78 -0
  64. package/dist/lib/rules/compose.js +170 -0
  65. package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
  66. package/dist/lib/{memory.js → rules/rules.js} +10 -10
  67. package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
  68. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  69. package/dist/lib/secrets/bundles.d.ts +61 -4
  70. package/dist/lib/secrets/bundles.js +222 -54
  71. package/dist/lib/secrets/index.d.ts +24 -5
  72. package/dist/lib/secrets/index.js +70 -41
  73. package/dist/lib/session/active.js +5 -5
  74. package/dist/lib/session/db.js +4 -4
  75. package/dist/lib/session/discover.js +2 -2
  76. package/dist/lib/session/render.js +21 -7
  77. package/dist/lib/shims.d.ts +28 -4
  78. package/dist/lib/shims.js +72 -14
  79. package/dist/lib/state.d.ts +22 -28
  80. package/dist/lib/state.js +83 -76
  81. package/dist/lib/sync-manifest.d.ts +2 -2
  82. package/dist/lib/sync-manifest.js +5 -5
  83. package/dist/lib/teams/agents.d.ts +4 -2
  84. package/dist/lib/teams/agents.js +11 -4
  85. package/dist/lib/teams/api.d.ts +1 -1
  86. package/dist/lib/teams/api.js +2 -2
  87. package/dist/lib/teams/index.d.ts +1 -0
  88. package/dist/lib/teams/index.js +1 -0
  89. package/dist/lib/teams/persistence.js +3 -3
  90. package/dist/lib/teams/registry.d.ts +8 -1
  91. package/dist/lib/teams/registry.js +8 -2
  92. package/dist/lib/teams/worktree.d.ts +30 -0
  93. package/dist/lib/teams/worktree.js +96 -0
  94. package/dist/lib/types.d.ts +12 -6
  95. package/dist/lib/types.js +3 -3
  96. package/dist/lib/versions.d.ts +30 -2
  97. package/dist/lib/versions.js +127 -105
  98. package/package.json +1 -1
  99. package/scripts/postinstall.js +29 -0
  100. package/dist/commands/refresh-memory.d.ts +0 -15
  101. package/dist/lib/memory-compile.d.ts +0 -66
@@ -1,21 +1,51 @@
1
1
  /**
2
2
  * Secret bundles -- named sets of keychain-backed environment variables.
3
3
  *
4
- * Each bundle is a YAML file in ~/.agents/secrets/ declaring key names.
5
- * Values live in the macOS Keychain and are injected into the agent's
6
- * environment at spawn time via `agents run --secrets <bundle>`.
4
+ * Bundle metadata (name, description, vars map) is stored in the macOS
5
+ * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Bundles created
6
+ * with `--icloud-sync` write the metadata to the iCloud-synced keychain so
7
+ * the full bundle definition (not just secret values) propagates across
8
+ * the user's Macs. Nothing about secrets ever lives in plaintext on disk.
9
+ *
10
+ * Secret values keep their old layout: one keychain item per key under
11
+ * `agents-cli.secrets.<bundle>.<key>`, sync-state matching the bundle's
12
+ * `icloud_sync` flag.
7
13
  */
8
14
  import * as fs from 'fs';
15
+ import * as os from 'os';
9
16
  import * as path from 'path';
10
17
  import * as yaml from 'yaml';
11
- import { getSecretsDir, getUserSecretsDir } from '../state.js';
12
- import { parseBundleValue, resolveRef, secretsKeychainItem, } from './index.js';
13
- const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,48}$/i;
18
+ import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
19
+ /** Allowed values for a secret's `type` metadata field. */
20
+ export const SECRET_TYPES = [
21
+ 'api-key',
22
+ 'token',
23
+ 'password',
24
+ 'url',
25
+ 'database-url',
26
+ 'ssh-key',
27
+ 'certificate',
28
+ 'webhook',
29
+ 'note',
30
+ ];
31
+ const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
14
32
  const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+ const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
34
+ export const RESERVED_ENV_NAMES = new Set([
35
+ 'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
36
+ 'TERM', 'LANG', 'LC_ALL', 'DISPLAY', 'EDITOR', 'VISUAL',
37
+ 'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
38
+ ]);
39
+ export function bundleToEnvPrefix(name) {
40
+ return name.replace(/[-\.]/g, '_').toUpperCase();
41
+ }
42
+ export function isReservedEnvName(key) {
43
+ return RESERVED_ENV_NAMES.has(key);
44
+ }
15
45
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
16
46
  export function validateBundleName(name) {
17
47
  if (!BUNDLE_NAME_PATTERN.test(name)) {
18
- throw new Error(`Invalid bundle name '${name}'. Use letters, digits, dash, underscore (max 48 chars).`);
48
+ throw new Error(`Invalid bundle name '${name}'. Use letters, digits, dash, underscore, dot (max 48 chars).`);
19
49
  }
20
50
  }
21
51
  export function validateEnvKey(key) {
@@ -23,37 +53,64 @@ export function validateEnvKey(key) {
23
53
  throw new Error(`Invalid environment variable name '${key}'. Must match [A-Za-z_][A-Za-z0-9_]*.`);
24
54
  }
25
55
  }
26
- function bundlePath(name) {
27
- // Check user dir first (for reads), write to user dir
28
- const userPath = path.join(getUserSecretsDir(), `${name}.yml`);
29
- if (fs.existsSync(userPath))
30
- return userPath;
31
- const systemPath = path.join(getSecretsDir(), `${name}.yml`);
32
- if (fs.existsSync(systemPath))
33
- return systemPath;
34
- return userPath; // default write location
56
+ /** Assert that `t` is one of the known SECRET_TYPES. Throws with the allowed list otherwise. */
57
+ export function validateSecretType(t) {
58
+ if (!SECRET_TYPES.includes(t)) {
59
+ throw new Error(`Invalid type '${t}'. One of: ${SECRET_TYPES.join(', ')}.`);
60
+ }
61
+ }
62
+ /**
63
+ * Validate an `expires` value. Accepts strict 'YYYY-MM-DD' only and rejects
64
+ * any date <= now. We compare against end-of-day UTC for the chosen date so
65
+ * "today" is treated as past (per spec).
66
+ */
67
+ export function validateExpiresFutureDated(iso) {
68
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
69
+ throw new Error(`Invalid --expires '${iso}'. Use YYYY-MM-DD.`);
70
+ }
71
+ const target = new Date(iso + 'T23:59:59Z');
72
+ if (Number.isNaN(target.getTime()))
73
+ throw new Error(`Invalid --expires date '${iso}'.`);
74
+ if (target.getTime() <= Date.now()) {
75
+ throw new Error(`--expires must be future-dated. Got '${iso}'.`);
76
+ }
77
+ }
78
+ function bundleMetaItem(name) {
79
+ return BUNDLE_META_PREFIX + name;
35
80
  }
36
81
  export function bundleExists(name) {
37
- return fs.existsSync(bundlePath(name));
82
+ validateBundleName(name);
83
+ return hasKeychainToken(bundleMetaItem(name));
38
84
  }
39
85
  export function readBundle(name) {
40
86
  validateBundleName(name);
41
- const file = bundlePath(name);
42
- if (!fs.existsSync(file)) {
87
+ let json;
88
+ try {
89
+ json = getKeychainToken(bundleMetaItem(name));
90
+ }
91
+ catch {
43
92
  throw new Error(`Secrets bundle '${name}' not found.`);
44
93
  }
45
- const raw = fs.readFileSync(file, 'utf-8');
46
- const parsed = yaml.parse(raw);
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(json);
97
+ }
98
+ catch {
99
+ throw new Error(`Bundle '${name}' is malformed.`);
100
+ }
47
101
  if (!parsed || typeof parsed !== 'object') {
48
102
  throw new Error(`Bundle '${name}' is malformed.`);
49
103
  }
50
104
  const bundle = {
51
- name: parsed.name || name,
105
+ name,
52
106
  description: parsed.description,
53
107
  allow_exec: Boolean(parsed.allow_exec),
54
108
  icloud_sync: Boolean(parsed.icloud_sync),
55
109
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
56
110
  };
111
+ if (parsed.meta && typeof parsed.meta === 'object') {
112
+ bundle.meta = parsed.meta;
113
+ }
57
114
  for (const key of Object.keys(bundle.vars)) {
58
115
  validateEnvKey(key);
59
116
  }
@@ -64,48 +121,59 @@ export function writeBundle(bundle) {
64
121
  for (const key of Object.keys(bundle.vars)) {
65
122
  validateEnvKey(key);
66
123
  }
67
- const dir = getUserSecretsDir();
68
- fs.mkdirSync(dir, { recursive: true });
69
- const body = yaml.stringify({
70
- name: bundle.name,
124
+ // Strip empty/all-undefined meta entries so the JSON stays tidy.
125
+ let meta;
126
+ if (bundle.meta) {
127
+ for (const [key, m] of Object.entries(bundle.meta)) {
128
+ const cleaned = {};
129
+ if (m.type)
130
+ cleaned.type = m.type;
131
+ if (m.expires)
132
+ cleaned.expires = m.expires;
133
+ if (m.note)
134
+ cleaned.note = m.note;
135
+ if (Object.keys(cleaned).length > 0) {
136
+ if (!meta)
137
+ meta = {};
138
+ meta[key] = cleaned;
139
+ }
140
+ }
141
+ }
142
+ const payload = {
71
143
  description: bundle.description,
72
144
  allow_exec: bundle.allow_exec ? true : undefined,
73
145
  icloud_sync: bundle.icloud_sync ? true : undefined,
74
146
  vars: bundle.vars,
75
- });
76
- const file = bundlePath(bundle.name);
77
- const tmp = `${file}.tmp-${process.pid}`;
78
- fs.writeFileSync(tmp, body, 'utf-8');
79
- fs.renameSync(tmp, file);
147
+ meta,
148
+ };
149
+ const json = JSON.stringify(payload);
150
+ setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
80
151
  }
81
152
  export function deleteBundle(name) {
82
153
  validateBundleName(name);
83
- const file = bundlePath(name);
84
- if (!fs.existsSync(file))
85
- return false;
86
- fs.unlinkSync(file);
87
- return true;
154
+ return deleteKeychainToken(bundleMetaItem(name));
88
155
  }
89
156
  export function listBundles() {
90
- const seen = new Set();
91
- const bundles = [];
92
- for (const dir of [getUserSecretsDir(), getSecretsDir()]) {
93
- if (!fs.existsSync(dir))
94
- continue;
95
- for (const entry of fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))) {
96
- const name = entry.replace(/\.(yml|yaml)$/, '');
97
- if (seen.has(name))
98
- continue;
99
- seen.add(name);
100
- try {
101
- bundles.push(readBundle(name));
102
- }
103
- catch {
104
- // Skip malformed bundles; surfaced via `agents secrets view <name>`.
105
- }
157
+ let services;
158
+ try {
159
+ services = listKeychainItems(BUNDLE_META_PREFIX);
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ const names = services
165
+ .map((s) => s.slice(BUNDLE_META_PREFIX.length))
166
+ .filter((n) => BUNDLE_NAME_PATTERN.test(n));
167
+ const out = [];
168
+ for (const name of names) {
169
+ try {
170
+ out.push(readBundle(name));
171
+ }
172
+ catch {
173
+ // Skip malformed bundles; surfaced via `agents secrets view <name>`.
106
174
  }
107
175
  }
108
- return bundles.sort((a, b) => a.name.localeCompare(b.name));
176
+ return out.sort((a, b) => a.name.localeCompare(b.name));
109
177
  }
110
178
  export function describeBundle(bundle) {
111
179
  const out = [];
@@ -149,10 +217,49 @@ export function resolveBundleEnv(bundle) {
149
217
  }
150
218
  return env;
151
219
  }
152
- // Build a keychain ref expression from a bundle+key pair, for storage in YAML.
220
+ // Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
153
221
  export function keychainRef(key) {
154
222
  return `keychain:${key}`;
155
223
  }
224
+ /**
225
+ * Rotate a keychain-backed secret in `bundle`. Errors if `key` is not present
226
+ * in the bundle (use `add` to introduce a new key). Preserves existing meta
227
+ * unless `clearMeta` or a `meta` patch is supplied.
228
+ */
229
+ export function rotateBundleSecret(bundle, key, opts) {
230
+ validateBundleName(bundle.name);
231
+ validateEnvKey(key);
232
+ if (!(key in bundle.vars)) {
233
+ throw new Error(`Key '${key}' not in bundle '${bundle.name}'. Use 'agents secrets add' to add a new key.`);
234
+ }
235
+ const raw = bundle.vars[key];
236
+ // We only rotate keychain-backed values. Literals/refs aren't "secrets" in
237
+ // the same sense — pivot the user back to add/remove.
238
+ if (typeof raw !== 'string' || !raw.startsWith('keychain:')) {
239
+ throw new Error(`Key '${key}' in bundle '${bundle.name}' is not keychain-backed; cannot rotate.`);
240
+ }
241
+ const shortId = raw.slice('keychain:'.length);
242
+ const item = secretsKeychainItem(bundle.name, shortId);
243
+ setKeychainToken(item, opts.newValue, bundle.icloud_sync);
244
+ if (opts.clearMeta) {
245
+ if (bundle.meta)
246
+ delete bundle.meta[key];
247
+ }
248
+ else if (opts.meta && Object.keys(opts.meta).length > 0) {
249
+ if (!bundle.meta)
250
+ bundle.meta = {};
251
+ const current = bundle.meta[key] ?? {};
252
+ const patched = { ...current };
253
+ if (opts.meta.type !== undefined)
254
+ patched.type = opts.meta.type;
255
+ if (opts.meta.expires !== undefined)
256
+ patched.expires = opts.meta.expires;
257
+ if (opts.meta.note !== undefined)
258
+ patched.note = opts.meta.note;
259
+ bundle.meta[key] = patched;
260
+ }
261
+ writeBundle(bundle);
262
+ }
156
263
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
157
264
  export function keychainItemsForBundle(bundle) {
158
265
  const items = [];
@@ -187,3 +294,64 @@ export function parseDotenv(content) {
187
294
  }
188
295
  return out;
189
296
  }
297
+ /**
298
+ * One-shot migration: move legacy YAML bundles into the keychain. Scans both
299
+ * `~/.agents/secrets/` and `~/.agents-system/secrets/` — past versions of the
300
+ * CLI sometimes wrote bundles into the system repo even though that's never
301
+ * been a legitimate location. After migration the directories are removed so
302
+ * the system repo never carries a `secrets/` subdir again.
303
+ *
304
+ * Idempotent: re-runs after the dirs are gone are no-ops. Called eagerly at
305
+ * the top of every `agents secrets` subcommand. Skipped on the latency-
306
+ * sensitive `agents run` path.
307
+ */
308
+ export function migrateLegacyBundles() {
309
+ const home = os.homedir();
310
+ const dirs = [
311
+ path.join(home, '.agents', 'secrets'),
312
+ path.join(home, '.agents-system', 'secrets'),
313
+ ];
314
+ let migrated = 0;
315
+ for (const dir of dirs) {
316
+ let entries;
317
+ try {
318
+ entries = fs.readdirSync(dir);
319
+ }
320
+ catch {
321
+ continue;
322
+ }
323
+ const ymls = entries.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
324
+ for (const entry of ymls) {
325
+ const file = path.join(dir, entry);
326
+ const name = entry.replace(/\.(yml|yaml)$/, '');
327
+ try {
328
+ validateBundleName(name);
329
+ const raw = fs.readFileSync(file, 'utf-8');
330
+ const parsed = yaml.parse(raw);
331
+ if (!parsed || typeof parsed !== 'object')
332
+ continue;
333
+ const bundle = {
334
+ name,
335
+ description: parsed.description,
336
+ allow_exec: Boolean(parsed.allow_exec),
337
+ icloud_sync: Boolean(parsed.icloud_sync),
338
+ vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
339
+ };
340
+ writeBundle(bundle);
341
+ fs.unlinkSync(file);
342
+ migrated++;
343
+ }
344
+ catch {
345
+ // Leave malformed YAMLs in place so the user can inspect them.
346
+ }
347
+ }
348
+ try {
349
+ if (fs.readdirSync(dir).length === 0)
350
+ fs.rmdirSync(dir);
351
+ }
352
+ catch { /* not empty or already gone */ }
353
+ }
354
+ if (migrated > 0) {
355
+ console.log(`Migrated ${migrated} legacy bundle${migrated === 1 ? '' : 's'} into keychain.`);
356
+ }
357
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * macOS Keychain integration for secure credential storage.
3
3
  *
4
- * Calls a compiled Swift helper (keychain-helper.swift) to store and retrieve
5
- * API keys and tokens via the Security framework, with kSecAttrSynchronizable
6
- * set so iCloud Keychain syncs them across the user's Macs.
4
+ * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
+ * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
+ * that grants the application-identifier + keychain-access-groups entitlement
7
+ * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
+ * For device-local writes the helper is invoked with the `nosync` arg.
7
9
  */
8
10
  /** Supported secret resolution backends. */
9
11
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
@@ -13,14 +15,14 @@ export interface SecretRef {
13
15
  value: string;
14
16
  }
15
17
  /**
16
- * A bundle YAML value: either a string (literal or provider-prefixed ref) or
18
+ * A bundle value: either a string (literal or provider-prefixed ref) or
17
19
  * an object `{value: string}` used to escape a literal that would otherwise
18
20
  * be parsed as a ref (e.g. a URL that happens to start with 'env:').
19
21
  */
20
22
  export type BundleValue = string | {
21
23
  value: string;
22
24
  };
23
- /** Parse a bundle YAML value into either a literal string or a typed secret ref. */
25
+ /** Parse a bundle value into either a literal string or a typed secret ref. */
24
26
  export declare function parseBundleValue(raw: BundleValue): {
25
27
  literal: string;
26
28
  } | {
@@ -32,6 +34,21 @@ export declare function serializeRef(ref: SecretRef): string;
32
34
  export declare function profileKeychainItem(provider: string): string;
33
35
  /** Build the keychain item name for a secrets-bundle key. */
34
36
  export declare function secretsKeychainItem(bundle: string, key: string): string;
37
+ /**
38
+ * Test seam: lets bundle storage tests swap the keychain backend for an
39
+ * in-memory map without touching the user's real keychain. Mocking is
40
+ * justified here because the alternative (touching real keychain in unit
41
+ * tests) is destructive and would require an interactive Keychain unlock.
42
+ */
43
+ export interface KeychainBackend {
44
+ has(item: string, sync: boolean): boolean;
45
+ get(item: string, sync: boolean): string;
46
+ set(item: string, value: string, sync: boolean): void;
47
+ delete(item: string, sync: boolean): boolean;
48
+ list(prefix: string): string[];
49
+ }
50
+ /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
51
+ export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
35
52
  /** Check if a keychain item exists (macOS only). */
36
53
  export declare function hasKeychainToken(item: string, sync?: boolean): boolean;
37
54
  /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
@@ -40,6 +57,8 @@ export declare function getKeychainToken(item: string, sync?: boolean): string;
40
57
  export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
41
58
  /** Delete a keychain item. Returns true if it existed. */
42
59
  export declare function deleteKeychainToken(item: string, sync?: boolean): boolean;
60
+ /** Enumerate keychain item service names whose name starts with the given prefix. */
61
+ export declare function listKeychainItems(prefix: string): string[];
43
62
  /** Options controlling how secret refs are resolved. */
44
63
  export interface ResolveOptions {
45
64
  /** Translate a short keychain ID to a fully namespaced item name. */
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * macOS Keychain integration for secure credential storage.
3
3
  *
4
- * Calls a compiled Swift helper (keychain-helper.swift) to store and retrieve
5
- * API keys and tokens via the Security framework, with kSecAttrSynchronizable
6
- * set so iCloud Keychain syncs them across the user's Macs.
4
+ * All reads/writes go through a signed Swift helper (keychain-helper.swift)
5
+ * compiled into AgentsKeychain.app. The .app embeds a provisioning profile
6
+ * that grants the application-identifier + keychain-access-groups entitlement
7
+ * macOS requires for kSecAttrSynchronizable writes (iCloud Keychain).
8
+ * For device-local writes the helper is invoked with the `nosync` arg.
7
9
  */
8
10
  import { fileURLToPath } from 'url';
9
11
  import { execFileSync, spawnSync } from 'child_process';
@@ -12,7 +14,7 @@ import * as os from 'os';
12
14
  import * as path from 'path';
13
15
  const SERVICE_PREFIX = 'agents-cli';
14
16
  const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
15
- /** Parse a bundle YAML value into either a literal string or a typed secret ref. */
17
+ /** Parse a bundle value into either a literal string or a typed secret ref. */
16
18
  export function parseBundleValue(raw) {
17
19
  if (typeof raw === 'object' && raw !== null && typeof raw.value === 'string') {
18
20
  return { literal: raw.value };
@@ -53,58 +55,64 @@ function ensureKeychainHelper() {
53
55
  const here = path.dirname(fileURLToPath(import.meta.url));
54
56
  const binPath = path.join(here, 'AgentsKeychain.app', 'Contents', 'MacOS', 'AgentsKeychain');
55
57
  if (!fs.existsSync(binPath)) {
56
- throw new Error(`iCloud Keychain helper missing at ${binPath}. ` +
57
- 'This npm package was built without the signed helper bundle. ' +
58
- 'Reinstall agents-cli, or create the bundle without --icloud-sync to use device-local storage.');
58
+ throw new Error(`Keychain helper missing at ${binPath}. ` +
59
+ 'This npm package was built without the signed helper bundle. Reinstall agents-cli.');
59
60
  }
60
61
  return binPath;
61
62
  }
62
- // iCloud Keychain sync is opt-in per bundle. When sync=true we route through
63
- // the Swift helper (kSecAttrSynchronizable=true). When sync is false/undefined
64
- // we use /usr/bin/security, which is always present on macOS — so a user who
65
- // never opts in to iCloud sync never needs Xcode Command Line Tools.
63
+ let backend = null;
64
+ /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
65
+ export function setKeychainBackendForTest(b) {
66
+ const prev = backend;
67
+ backend = b;
68
+ return prev;
69
+ }
70
+ // Backend routing: non-sync items go through /usr/bin/security so they share
71
+ // an ACL identity with items created by previous CLI versions (no prompts on
72
+ // existing data). Sync items must go through the signed .app — only the .app
73
+ // holds the keychain-access-groups entitlement macOS requires for
74
+ // kSecAttrSynchronizable. Enumeration also goes through the .app because the
75
+ // security CLI doesn't expose listing by service prefix.
66
76
  /** Check if a keychain item exists (macOS only). */
67
77
  export function hasKeychainToken(item, sync = false) {
78
+ if (backend)
79
+ return backend.has(item, sync);
68
80
  assertMacOS();
69
- if (sync) {
70
- const bin = ensureKeychainHelper();
71
- return spawnSync(bin, ['has', item, os.userInfo().username], {
72
- stdio: ['ignore', 'pipe', 'pipe'],
73
- }).status === 0;
74
- }
75
- return spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
81
+ // Try security first (no prompts for local items), fall back to binary for synced items.
82
+ if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ }).status === 0)
85
+ return true;
86
+ // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
87
+ const bin = ensureKeychainHelper();
88
+ return spawnSync(bin, ['has', item, os.userInfo().username], {
76
89
  stdio: ['ignore', 'pipe', 'pipe'],
77
90
  }).status === 0;
78
91
  }
79
92
  /** Retrieve a secret value from the macOS Keychain. Throws if not found. */
80
93
  export function getKeychainToken(item, sync = false) {
94
+ if (backend)
95
+ return backend.get(item, sync);
81
96
  assertMacOS();
82
- if (sync) {
83
- const bin = ensureKeychainHelper();
84
- const result = spawnSync(bin, ['get', item, os.userInfo().username], {
85
- stdio: ['ignore', 'pipe', 'pipe'],
86
- });
87
- if (result.status === 1)
88
- throw new Error(`Keychain item '${item}' not found.`);
89
- if (result.status !== 0) {
90
- const msg = result.stderr?.toString().trim();
91
- throw new Error(msg || `Failed to read keychain item '${item}'.`);
92
- }
93
- const token = result.stdout?.toString().trim();
94
- if (!token)
95
- throw new Error(`Keychain item '${item}' exists but is empty.`);
96
- return token;
97
+ // Try security first (no prompts for local items)
98
+ const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ });
101
+ if (secResult.status === 0) {
102
+ const token = secResult.stdout?.toString().trim();
103
+ if (token)
104
+ return token;
97
105
  }
98
- const result = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
106
+ // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
107
+ const bin = ensureKeychainHelper();
108
+ const result = spawnSync(bin, ['get', item, os.userInfo().username], {
99
109
  stdio: ['ignore', 'pipe', 'pipe'],
100
110
  });
101
- if (result.status === 44)
111
+ if (result.status === 1)
102
112
  throw new Error(`Keychain item '${item}' not found.`);
103
113
  if (result.status !== 0) {
104
- const stderr = result.stderr?.toString() || '';
105
- if (/could not be found/i.test(stderr))
106
- throw new Error(`Keychain item '${item}' not found.`);
107
- throw new Error(`Failed to read keychain item '${item}': ${stderr.trim() || `exit ${result.status}`}`);
114
+ const msg = result.stderr?.toString().trim();
115
+ throw new Error(msg || `Failed to read keychain item '${item}'.`);
108
116
  }
109
117
  const token = result.stdout?.toString().trim();
110
118
  if (!token)
@@ -113,6 +121,10 @@ export function getKeychainToken(item, sync = false) {
113
121
  }
114
122
  /** Store or update a secret value in the macOS Keychain. iCloud-synced when sync=true. */
115
123
  export function setKeychainToken(item, value, sync = false) {
124
+ if (backend) {
125
+ backend.set(item, value, sync);
126
+ return;
127
+ }
116
128
  assertMacOS();
117
129
  if (!value || !value.trim())
118
130
  throw new Error('Secret value is empty.');
@@ -130,7 +142,7 @@ export function setKeychainToken(item, value, sync = false) {
130
142
  }
131
143
  return;
132
144
  }
133
- // The `security -i` interactive form keeps the value out of argv (and `ps`).
145
+ // `security -i` keeps the value out of argv (and `ps`).
134
146
  const user = os.userInfo().username;
135
147
  const cmd = `add-generic-password -a ${quoteForSecurityCli(user)} -s ${quoteForSecurityCli(item)} -w ${quoteForSecurityCli(value)} -U\n`;
136
148
  const result = spawnSync('security', ['-i'], {
@@ -143,6 +155,8 @@ export function setKeychainToken(item, value, sync = false) {
143
155
  }
144
156
  /** Delete a keychain item. Returns true if it existed. */
145
157
  export function deleteKeychainToken(item, sync = false) {
158
+ if (backend)
159
+ return backend.delete(item, sync);
146
160
  assertMacOS();
147
161
  if (sync) {
148
162
  const bin = ensureKeychainHelper();
@@ -154,10 +168,25 @@ export function deleteKeychainToken(item, sync = false) {
154
168
  stdio: ['ignore', 'pipe', 'pipe'],
155
169
  }).status === 0;
156
170
  }
157
- // Quote a value for `security -i`'s shell-like tokenizer so it stays out of argv.
158
171
  function quoteForSecurityCli(s) {
159
172
  return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
160
173
  }
174
+ /** Enumerate keychain item service names whose name starts with the given prefix. */
175
+ export function listKeychainItems(prefix) {
176
+ if (backend)
177
+ return backend.list(prefix);
178
+ assertMacOS();
179
+ const bin = ensureKeychainHelper();
180
+ const result = spawnSync(bin, ['list', prefix], {
181
+ stdio: ['ignore', 'pipe', 'pipe'],
182
+ });
183
+ if (result.status !== 0) {
184
+ const msg = result.stderr?.toString().trim();
185
+ throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
186
+ }
187
+ const out = result.stdout?.toString() || '';
188
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
189
+ }
161
190
  function expandHome(p) {
162
191
  if (p.startsWith('~/') || p === '~') {
163
192
  return path.join(os.homedir(), p.slice(1));
@@ -2,12 +2,12 @@
2
2
  * Active-session detection across every context an agent can run in:
3
3
  *
4
4
  * - `terminal` — agents launched from VS Code / Cursor / Codium via the
5
- * agents-cli extension. Published to `~/.agents-system/runtime/live-terminals.json`
5
+ * agents-cli extension. Published to `~/.agents/runtime/live-terminals.json`
6
6
  * with PID + session UUID per entry.
7
7
  * - `teams` — agents spawned by `agents teams add`, tracked in
8
- * `~/.agents-system/teams/agents/<id>/meta.json` with a PID the manager polls.
8
+ * `~/.agents/teams/agents/<id>/meta.json` with a PID the manager polls.
9
9
  * - `cloud` — dispatched to Rush / Codex Cloud / Factory, tracked in
10
- * the SQLite cache at `~/.agents-system/cloud/tasks.db`.
10
+ * the SQLite cache at `~/.agents/cloud/tasks.db`.
11
11
  * - `headless` — bare `claude` / `codex` / `gemini` / `cursor-agent` /
12
12
  * `opencode` processes that don't belong to any of the above. Detected
13
13
  * by `ps` minus the PIDs we've already attributed.
@@ -23,10 +23,10 @@ import { execFile } from 'child_process';
23
23
  import { promisify } from 'util';
24
24
  import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
- import { getAgentsDir } from '../state.js';
26
+ import { getUserAgentsDir } from '../state.js';
27
27
  const execFileAsync = promisify(execFile);
28
28
  const HOME = os.homedir();
29
- const LIVE_TERMINALS_FILE = path.join(getAgentsDir(), 'runtime', 'live-terminals.json');
29
+ const LIVE_TERMINALS_FILE = path.join(getUserAgentsDir(), 'runtime', 'live-terminals.json');
30
30
  /**
31
31
  * A process is classified `running` if its session file was touched in the
32
32
  * last 2 minutes. Every Claude/Codex tool-call appends an event, so a
@@ -9,8 +9,8 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
- import { getAgentsDir } from '../state.js';
13
- const SESSIONS_DIR = path.join(getAgentsDir(), 'sessions');
12
+ import { getUserAgentsDir } from '../state.js';
13
+ const SESSIONS_DIR = path.join(getUserAgentsDir(), 'sessions');
14
14
  const DB_PATH = path.join(SESSIONS_DIR, 'sessions.db');
15
15
  /** Current schema version; bumped when migrations are added. */
16
16
  const SCHEMA_VERSION = 5;
@@ -18,7 +18,7 @@ const SCHEMA_VERSION = 5;
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
20
20
  * (when `~/.claude` is a symlink to a versioned home) and
21
- * `~/.agents-system/versions/claude/<v>/home/.claude/projects/x.jsonl`. Keying the
21
+ * `~/.agents/versions/claude/<v>/home/.claude/projects/x.jsonl`. Keying the
22
22
  * ledger by the raw path means switching between these aliases (e.g. via
23
23
  * `agents use`) misses the cache and forces a full re-parse. Realpath collapses
24
24
  * all aliases to one stable key.
@@ -213,7 +213,7 @@ export function getScanStampsForPaths(filePaths) {
213
213
  return result;
214
214
  const db = getDB();
215
215
  // Multiple input paths can resolve to the same canonical key (e.g. the same
216
- // session JSONL reachable via `~/.claude/...` and `~/.agents-system/versions/...`).
216
+ // session JSONL reachable via `~/.claude/...` and `~/.agents/versions/...`).
217
217
  // We query DB by canonical key, then fan results back out to every original
218
218
  // alias so callers can `.get(filePath)` with the path they passed in.
219
219
  const canonicalToOriginals = new Map();