@phnx-labs/agents-cli 1.20.51 → 1.20.52

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 (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/commands/browser.js +215 -7
  3. package/dist/commands/cloud.js +6 -0
  4. package/dist/commands/events.d.ts +1 -1
  5. package/dist/commands/events.js +2 -3
  6. package/dist/commands/exec.js +17 -2
  7. package/dist/commands/factory.js +8 -0
  8. package/dist/commands/feed.d.ts +9 -0
  9. package/dist/commands/feed.js +69 -0
  10. package/dist/commands/logs.d.ts +5 -1
  11. package/dist/commands/logs.js +248 -3
  12. package/dist/commands/mcp.js +7 -0
  13. package/dist/commands/secrets.d.ts +22 -0
  14. package/dist/commands/secrets.js +173 -42
  15. package/dist/commands/teams.js +4 -0
  16. package/dist/index.js +6 -2
  17. package/dist/lib/browser/login-detection.d.ts +94 -0
  18. package/dist/lib/browser/login-detection.js +274 -0
  19. package/dist/lib/browser/profiles.d.ts +17 -8
  20. package/dist/lib/browser/profiles.js +27 -8
  21. package/dist/lib/browser/secret-ref.d.ts +10 -0
  22. package/dist/lib/browser/secret-ref.js +14 -0
  23. package/dist/lib/browser/service.js +14 -12
  24. package/dist/lib/cloud/rush.d.ts +15 -0
  25. package/dist/lib/cloud/rush.js +7 -1
  26. package/dist/lib/crabbox/lease.d.ts +6 -0
  27. package/dist/lib/crabbox/lease.js +11 -9
  28. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  29. package/dist/lib/crabbox/runtimes.js +98 -5
  30. package/dist/lib/daemon.d.ts +12 -9
  31. package/dist/lib/daemon.js +32 -17
  32. package/dist/lib/events.d.ts +31 -5
  33. package/dist/lib/events.js +288 -101
  34. package/dist/lib/exec.js +1 -0
  35. package/dist/lib/feed.d.ts +56 -0
  36. package/dist/lib/feed.js +251 -0
  37. package/dist/lib/hooks.js +7 -2
  38. package/dist/lib/hosts/passthrough.js +1 -0
  39. package/dist/lib/rotate.js +2 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/agent.d.ts +21 -0
  43. package/dist/lib/secrets/agent.js +63 -1
  44. package/dist/lib/secrets/bundles.d.ts +33 -1
  45. package/dist/lib/secrets/bundles.js +38 -8
  46. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  47. package/dist/lib/secrets/icloud-import.js +173 -0
  48. package/dist/lib/secrets/index.d.ts +36 -0
  49. package/dist/lib/secrets/index.js +99 -9
  50. package/dist/lib/secrets/remote.js +1 -1
  51. package/dist/lib/secrets/sync.js +1 -1
  52. package/dist/lib/session/discover.js +1 -2
  53. package/dist/lib/session/state.js +13 -1
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +2 -0
  56. package/dist/lib/state.d.ts +2 -0
  57. package/dist/lib/state.js +25 -8
  58. package/dist/lib/teams/agents.js +6 -3
  59. package/dist/lib/types.d.ts +10 -0
  60. package/dist/lib/whats-new.d.ts +5 -3
  61. package/dist/lib/whats-new.js +25 -5
  62. package/package.json +1 -1
@@ -1,10 +1,14 @@
1
1
  /**
2
- * `agents logs` — unified, discoverable run-log viewer.
2
+ * `agents logs` — unified, discoverable run-log viewer + audit trail.
3
3
  *
4
4
  * Resolves a run across two substrates and shows (or `-f` follows) its log:
5
5
  * - host-dispatch tasks (`agents run --host`) → combined-stdout log, offset-tailed
6
6
  * - sessions (the local index) → transcript, tailed via the sessions tailer
7
7
  *
8
+ * Subcommands:
9
+ * - `agents logs audit` — read the structured audit/event log
10
+ * - `agents logs stats` — show aggregate audit statistics
11
+ *
8
12
  * Concise by default: a bare `agents logs <id>` prints the same summary digest as
9
13
  * `agents sessions <id>` — cheap for an agent to glance at. The token-heavy full
10
14
  * transcript / raw stdout is opt-in behind `--full` (alias `-m/--markdown`).
@@ -17,12 +21,14 @@
17
21
  * the same underlying helpers (showHostTaskLog / streamSessionTail).
18
22
  */
19
23
  import chalk from 'chalk';
24
+ import * as fs from 'fs';
20
25
  import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
21
26
  import { parseAgentFilter, renderSessionLog } from './sessions.js';
22
27
  import { streamSessionTail, isTailable } from './sessions-tail.js';
23
28
  import { showHostTaskLog } from '../lib/hosts/logs.js';
24
29
  import { listTasks } from '../lib/hosts/tasks.js';
25
30
  import { itemPicker } from '../lib/picker.js';
31
+ import { query, stats, getLogsPath, rotate, levelFor, } from '../lib/events.js';
26
32
  /** Compact one-line label used by both the picker and the non-TTY list. */
27
33
  function candidateLabel(c) {
28
34
  if (c.kind === 'task') {
@@ -130,11 +136,207 @@ async function runLogs(id, opts) {
130
136
  return;
131
137
  await showCandidate(picked.item, follow, full);
132
138
  }
139
+ function parseSince(s) {
140
+ const m = s.match(/^(\d+)([smhdw])$/);
141
+ if (m) {
142
+ const n = parseInt(m[1], 10);
143
+ const unitMs = {
144
+ s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
145
+ };
146
+ return new Date(Date.now() - n * unitMs[m[2]]);
147
+ }
148
+ const ms = Date.parse(s);
149
+ if (isNaN(ms))
150
+ throw new Error(`Invalid --since value: ${s} (use e.g. 2h, 7d, or an ISO date)`);
151
+ return new Date(ms);
152
+ }
153
+ function originLabel(r) {
154
+ if (r.transport === 'ssh') {
155
+ return chalk.yellow(`ssh${r.sshClientIp ? ' ' + r.sshClientIp : ''}`);
156
+ }
157
+ return chalk.gray('local');
158
+ }
159
+ function auditDetailFor(r) {
160
+ if (r.command)
161
+ return r.command;
162
+ const bits = [];
163
+ if (typeof r.team === 'string')
164
+ bits.push(`team=${r.team}`);
165
+ if (typeof r.bundle === 'string')
166
+ bits.push(`bundle=${r.bundle}`);
167
+ if (typeof r.skill === 'string')
168
+ bits.push(`skill=${r.skill}`);
169
+ if (typeof r.version === 'string')
170
+ bits.push(`v=${r.version}`);
171
+ if (typeof r.profile === 'string')
172
+ bits.push(`profile=${r.profile}`);
173
+ if (typeof r.server === 'string')
174
+ bits.push(`server=${r.server}`);
175
+ if (typeof r.error === 'string')
176
+ bits.push(chalk.red(r.error));
177
+ return bits.join(' ');
178
+ }
179
+ function levelColor(level) {
180
+ if (level === 'audit')
181
+ return chalk.magenta(level);
182
+ if (level === 'warn')
183
+ return chalk.yellow(level);
184
+ if (level === 'debug')
185
+ return chalk.gray(level);
186
+ return chalk.blue(level);
187
+ }
188
+ function renderAuditRow(r) {
189
+ const time = chalk.gray(r.ts.slice(0, 19).replace('T', ' '));
190
+ const user = `${r.osUser ?? '?'}@${r.hostname}`;
191
+ const ev = r.event.startsWith('error') ? chalk.red(r.event) : chalk.cyan(r.event);
192
+ const lvl = levelColor(r.level ?? levelFor(r.event));
193
+ const agent = r.agent ? chalk.gray(` ${r.agent}`) : '';
194
+ const caller = chalk.gray(`via ${r.caller ?? 'unknown'}${r.session ? ` ${r.session}` : ''}`);
195
+ return `${time} ${lvl.padEnd(14)} ${originLabel(r).padEnd(24)} ${user.padEnd(22)} ${caller.padEnd(28)} ${ev.padEnd(26)}${agent} ${auditDetailFor(r)}`;
196
+ }
197
+ function collect(value, previous) {
198
+ return previous.concat([value]);
199
+ }
200
+ async function runAudit(opts) {
201
+ if (opts.follow) {
202
+ await followAuditLog();
203
+ return;
204
+ }
205
+ const limit = Math.max(1, parseInt(opts.limit ?? '50', 10) || 50);
206
+ let startDate;
207
+ try {
208
+ startDate = opts.since ? parseSince(opts.since) : undefined;
209
+ }
210
+ catch (err) {
211
+ console.error(chalk.red(err.message));
212
+ process.exit(2);
213
+ }
214
+ const records = query({
215
+ startDate,
216
+ eventTypes: opts.event?.length ? opts.event : undefined,
217
+ level: opts.level,
218
+ agent: opts.agent,
219
+ caller: opts.caller,
220
+ command: opts.command,
221
+ module: opts.module,
222
+ limit,
223
+ });
224
+ if (opts.json) {
225
+ console.log(JSON.stringify(records, null, 2));
226
+ return;
227
+ }
228
+ if (records.length === 0) {
229
+ console.log(chalk.gray('No matching events.'));
230
+ return;
231
+ }
232
+ for (const r of records.slice().reverse())
233
+ console.log(renderAuditRow(r));
234
+ console.log(chalk.gray(`\n${records.length} event(s). Log: ${getLogsPath()}`));
235
+ }
236
+ async function followAuditLog() {
237
+ const file = getLogsPath();
238
+ let offset = 0;
239
+ try {
240
+ offset = fs.statSync(file).size;
241
+ }
242
+ catch {
243
+ // File may not exist yet — start at 0.
244
+ }
245
+ console.log(chalk.gray(`Tailing ${file} — Ctrl-C to stop`));
246
+ const drain = () => {
247
+ let size = 0;
248
+ try {
249
+ size = fs.statSync(file).size;
250
+ }
251
+ catch {
252
+ return;
253
+ }
254
+ if (size <= offset) {
255
+ if (size < offset)
256
+ offset = 0;
257
+ return;
258
+ }
259
+ const fd = fs.openSync(file, 'r');
260
+ try {
261
+ const buf = Buffer.alloc(size - offset);
262
+ fs.readSync(fd, buf, 0, buf.length, offset);
263
+ offset = size;
264
+ for (const line of buf.toString('utf-8').split('\n').filter(Boolean)) {
265
+ try {
266
+ console.log(renderAuditRow(JSON.parse(line)));
267
+ }
268
+ catch {
269
+ // Skip malformed lines.
270
+ }
271
+ }
272
+ }
273
+ finally {
274
+ fs.closeSync(fd);
275
+ }
276
+ };
277
+ await new Promise(() => {
278
+ setInterval(drain, 500);
279
+ });
280
+ }
281
+ function humanBytes(bytes) {
282
+ if (bytes < 1024)
283
+ return `${bytes} B`;
284
+ if (bytes < 1024 * 1024)
285
+ return `${(bytes / 1024).toFixed(1)} KB`;
286
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
287
+ }
288
+ async function runStats(opts) {
289
+ let days = 7;
290
+ if (opts.since) {
291
+ try {
292
+ const d = parseSince(opts.since);
293
+ days = Math.max(1, Math.ceil((Date.now() - d.getTime()) / 86_400_000));
294
+ }
295
+ catch (err) {
296
+ console.error(chalk.red(err.message));
297
+ process.exit(2);
298
+ }
299
+ }
300
+ const s = stats({ days });
301
+ if (opts.json) {
302
+ console.log(JSON.stringify(s, null, 2));
303
+ return;
304
+ }
305
+ console.log(chalk.bold(`Audit statistics (last ${days} day${days === 1 ? '' : 's'})\n`));
306
+ console.log(` Total events: ${s.totalEvents}`);
307
+ console.log(` Log files: ${s.fileCount} (${humanBytes(s.totalBytes)})`);
308
+ console.log(` Log path: ${chalk.gray(getLogsPath())}`);
309
+ if (Object.keys(s.byLevel).length) {
310
+ console.log(chalk.bold('\n By level:'));
311
+ for (const [k, v] of Object.entries(s.byLevel).sort((a, b) => b[1] - a[1])) {
312
+ console.log(` ${levelColor(k).padEnd(20)} ${v}`);
313
+ }
314
+ }
315
+ if (Object.keys(s.byEvent).length) {
316
+ console.log(chalk.bold('\n By event (top 15):'));
317
+ for (const [k, v] of Object.entries(s.byEvent).sort((a, b) => b[1] - a[1]).slice(0, 15)) {
318
+ console.log(` ${chalk.cyan(k).padEnd(30)} ${v}`);
319
+ }
320
+ }
321
+ if (Object.keys(s.byModule).length) {
322
+ console.log(chalk.bold('\n By module:'));
323
+ for (const [k, v] of Object.entries(s.byModule).sort((a, b) => b[1] - a[1])) {
324
+ console.log(` ${k.padEnd(20)} ${v}`);
325
+ }
326
+ }
327
+ if (Object.keys(s.byUser).length) {
328
+ console.log(chalk.bold('\n By user:'));
329
+ for (const [k, v] of Object.entries(s.byUser).sort((a, b) => b[1] - a[1])) {
330
+ console.log(` ${k.padEnd(30)} ${v}`);
331
+ }
332
+ }
333
+ console.log();
334
+ }
133
335
  /** Register the top-level `agents logs` command. */
134
336
  export function registerLogsCommand(program) {
135
- program
337
+ const logsCmd = program
136
338
  .command('logs [id]')
137
- .description('Show a run’s concise summary a host-dispatch task or a session. --full for the raw transcript, -f to follow a live one.')
339
+ .description('Show a run log, audit trail, or stats. Subcommands: audit, stats, rotate.')
138
340
  .option('--host <name>', 'Scope to runs dispatched to a host')
139
341
  .option('-a, --agent <agent>', 'Filter by agent (e.g. claude, codex@0.116.0)')
140
342
  .option('--version <version>', 'Filter by agent version')
@@ -142,4 +344,47 @@ export function registerLogsCommand(program) {
142
344
  .option('-f, --follow', 'Follow live output')
143
345
  .option('-m, --full', 'Show the full raw transcript / stdout instead of the concise summary')
144
346
  .action((id, opts) => runLogs(id, opts));
347
+ logsCmd
348
+ .command('audit')
349
+ .description('Read the structured audit/event log (who ran what, from where)')
350
+ .option('--module <name>', 'Only events from this command group (e.g. teams, secrets)')
351
+ .option('--command <path>', 'Only this command path — prefix match (e.g. "teams create")')
352
+ .option('--event <type>', 'Only this typed event (repeatable)', collect, [])
353
+ .option('--agent <name>', 'Only events tagged with this agent')
354
+ .option('--caller <kind>', 'Only this caller kind (claude-code, codex, gemini, cursor, terminal, script)')
355
+ .option('--level <level>', 'Only this level: audit, warn, info, debug')
356
+ .option('--since <time>', 'Only events newer than this (e.g. 2h, 7d, or ISO date)')
357
+ .option('--limit <n>', 'Max records to show (default 50)', '50')
358
+ .option('--json', 'Output raw records as JSON')
359
+ .option('-f, --follow', "Tail today's log live")
360
+ .addHelpText('after', `
361
+ Examples:
362
+ agents logs audit Recent activity across everything
363
+ agents logs audit --module teams Team lifecycle (create / add / disband)
364
+ agents logs audit --module secrets Every secret accessed or revealed
365
+ agents logs audit --level audit Only audit-level events
366
+ agents logs audit --command "teams create" Just team creations
367
+ agents logs audit --event secrets.get --since 7d --json
368
+ agents logs audit -f Live tail`)
369
+ .action(async (options) => runAudit(options));
370
+ logsCmd
371
+ .command('stats')
372
+ .description('Show aggregate audit statistics')
373
+ .option('--since <time>', 'Window size (e.g. 7d, 30d; default 7d)')
374
+ .option('--json', 'Output stats as JSON')
375
+ .action(async (opts) => runStats(opts));
376
+ logsCmd
377
+ .command('rotate')
378
+ .description('Force log rotation — remove files older than the retention period')
379
+ .option('--days <n>', 'Retention period in days (default 7)', '7')
380
+ .action((opts) => {
381
+ const days = Math.max(1, parseInt(opts.days ?? '7', 10) || 7);
382
+ const removed = rotate(days);
383
+ if (removed > 0) {
384
+ console.log(`Removed ${removed} log file${removed === 1 ? '' : 's'} older than ${days} day${days === 1 ? '' : 's'}.`);
385
+ }
386
+ else {
387
+ console.log(chalk.gray('No log files to remove.'));
388
+ }
389
+ });
145
390
  }
@@ -3,6 +3,7 @@ import { truncate } from '../lib/format.js';
3
3
  import ora from 'ora';
4
4
  import { checkbox } from '@inquirer/prompts';
5
5
  import { capableAgents, isCapable } from '../lib/capabilities.js';
6
+ import { emit } from '../lib/events.js';
6
7
  import { AGENTS, getAllCliStates, resolveAgentName, formatAgentError, registerMcpToTargets, unregisterMcpFromTargets, listInstalledMcpsWithScope, parseMcpConfig, getMcpConfigPathForHome, agentLabel, } from '../lib/agents.js';
7
8
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
8
9
  import { listMcpServerConfigs, discoverMcpConfigsFromRepo, installMcpConfigCentrally, } from '../lib/mcp.js';
@@ -293,6 +294,7 @@ Examples:
293
294
  };
294
295
  }
295
296
  writeManifest(localPath, manifest);
297
+ emit('mcp.add', { module: 'mcp', server: name });
296
298
  console.log(chalk.green(`Added MCP server '${name}' to manifest`));
297
299
  console.log(chalk.gray('Run: agents mcp register to apply'));
298
300
  });
@@ -441,6 +443,8 @@ Examples:
441
443
  console.log(chalk.yellow('No MCP servers removed.'));
442
444
  }
443
445
  else {
446
+ for (const n of mcpsToRemove)
447
+ emit('mcp.remove', { module: 'mcp', server: n });
444
448
  console.log(chalk.green(`\nRemoved ${removed} MCP server(s).`));
445
449
  }
446
450
  });
@@ -575,6 +579,7 @@ Examples:
575
579
  targets = resolveConfiguredAgentTargets(config.agents, config.agentVersions, capableAgents('mcp'));
576
580
  }
577
581
  const results = await registerMcpToTargets(targets, mcpName, commandOrUrl, config.scope || 'user', transport, { headers: config.headers });
582
+ const applied = results.filter(r => r.success).length;
578
583
  for (const result of results) {
579
584
  if (result.success) {
580
585
  console.log(` ${chalk.green('+')} ${formatTargetLabel(result.agentId, result.version)}`);
@@ -586,6 +591,8 @@ Examples:
586
591
  console.log(` ${chalk.red('x')} ${formatTargetLabel(result.agentId, result.version)}: ${result.error}`);
587
592
  }
588
593
  }
594
+ if (applied > 0)
595
+ emit('mcp.register', { module: 'mcp', server: mcpName, applied });
589
596
  }
590
597
  });
591
598
  }
@@ -17,6 +17,28 @@ import { type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.j
17
17
  * a filesystem path.
18
18
  */
19
19
  export declare function readImportDotenv(from: string): string;
20
+ /** Where `secrets import` pulls keys from, parsed off the unified `--from`. */
21
+ export type ImportSource = {
22
+ kind: 'dotenv';
23
+ path: string;
24
+ } | {
25
+ kind: '1password';
26
+ vault?: string;
27
+ } | {
28
+ kind: 'icloud';
29
+ };
30
+ /**
31
+ * Parse the unified `--from <source>` value: a .env path (`-` reads stdin),
32
+ * `1password:<vault>` (bare `1password` prompts for the vault), or `icloud`
33
+ * (legacy iCloud Keychain bundles). The deprecated `--from-1password --vault`
34
+ * pair maps onto the 1password source. A file literally named `icloud` or
35
+ * `1password` can still be imported via an explicit path (`./icloud`).
36
+ */
37
+ export declare function parseImportSource(opts: {
38
+ from?: string;
39
+ from1password?: boolean;
40
+ vault?: string;
41
+ }): ImportSource;
20
42
  /**
21
43
  * Build the remote `agents secrets unlock` argv for `unlock --host`. `--all`
22
44
  * forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
@@ -24,6 +24,7 @@ import { parseDuration } from '../lib/hooks/cache.js';
24
24
  import { emit } from '../lib/events.js';
25
25
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
26
26
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
27
+ import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
27
28
  import { registerSecretsSyncCommands } from './secrets-sync.js';
28
29
  import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
29
30
  import { registerSecretsImportKeyringCommand } from './secrets-import.js';
@@ -135,6 +136,101 @@ async function resolveVault(vaultOpt) {
135
136
  export function readImportDotenv(from) {
136
137
  return from === '-' ? readStdinSync() : fs.readFileSync(from, 'utf-8');
137
138
  }
139
+ /**
140
+ * Parse the unified `--from <source>` value: a .env path (`-` reads stdin),
141
+ * `1password:<vault>` (bare `1password` prompts for the vault), or `icloud`
142
+ * (legacy iCloud Keychain bundles). The deprecated `--from-1password --vault`
143
+ * pair maps onto the 1password source. A file literally named `icloud` or
144
+ * `1password` can still be imported via an explicit path (`./icloud`).
145
+ */
146
+ export function parseImportSource(opts) {
147
+ if (opts.from && opts.from1password) {
148
+ throw new Error('--from and --from-1password are mutually exclusive.');
149
+ }
150
+ if (opts.from1password)
151
+ return { kind: '1password', vault: opts.vault };
152
+ if (!opts.from) {
153
+ throw new Error("Pass --from <source>: a .env path (- reads stdin), '1password:<vault>', or 'icloud'.");
154
+ }
155
+ if (opts.from === 'icloud')
156
+ return { kind: 'icloud' };
157
+ if (opts.from === '1password')
158
+ return { kind: '1password', vault: opts.vault };
159
+ if (opts.from.startsWith('1password:')) {
160
+ const vault = opts.from.slice('1password:'.length);
161
+ return { kind: '1password', vault: vault || opts.vault };
162
+ }
163
+ return { kind: 'dotenv', path: opts.from };
164
+ }
165
+ /**
166
+ * `secrets import --from icloud` — recover bundles stranded in the iCloud
167
+ * Keychain by the device-local cutover. With a bundle name, imports exactly
168
+ * that bundle; without one, interactively multi-selects from everything
169
+ * discovered (all pre-checked — the common case is "bring them all back").
170
+ */
171
+ async function importFromICloud(bundleName, opts) {
172
+ if (process.platform !== 'darwin') {
173
+ throw new Error('--from icloud reads the macOS iCloud Keychain and is only available on macOS.');
174
+ }
175
+ const candidates = discoverSyncedBundles();
176
+ if (candidates.length === 0) {
177
+ console.log('No legacy iCloud Keychain bundles found.');
178
+ return;
179
+ }
180
+ const describe = (c) => `${c.name} (${c.keys.length} key${c.keys.length === 1 ? '' : 's'}${c.hasMeta ? '' : ', no metadata'})`;
181
+ let chosen;
182
+ if (bundleName) {
183
+ const hit = candidates.find((c) => c.name === bundleName);
184
+ if (!hit) {
185
+ throw new Error(`No iCloud Keychain bundle named '${bundleName}'. Found: ${candidates.map((c) => c.name).join(', ')}`);
186
+ }
187
+ chosen = [hit];
188
+ }
189
+ else if (!isInteractiveTerminal()) {
190
+ throw new Error(`Found ${candidates.length} iCloud Keychain bundle(s): ${candidates.map((c) => c.name).join(', ')}. ` +
191
+ 'Pass a bundle name to import non-interactively.');
192
+ }
193
+ else {
194
+ const { checkbox } = await import('@inquirer/prompts');
195
+ chosen = await checkbox({
196
+ message: 'Which iCloud Keychain bundles to import?',
197
+ choices: candidates.map((c) => ({ name: describe(c), value: c, checked: true })),
198
+ });
199
+ if (chosen.length === 0) {
200
+ console.log('Nothing selected.');
201
+ return;
202
+ }
203
+ }
204
+ for (const candidate of chosen) {
205
+ const result = importSyncedBundle(candidate, opts);
206
+ const parts = [`imported ${result.added} key(s)`];
207
+ if (result.skipped)
208
+ parts.push(`skipped ${result.skipped} (already set, pass --force)`);
209
+ if (result.missing.length)
210
+ parts.push(`unreadable (left in iCloud): ${result.missing.join(', ')}`);
211
+ if (opts.purge)
212
+ parts.push(`purged ${result.purged} iCloud item(s)`);
213
+ const line = `${candidate.name}: ${parts.join(', ')}`;
214
+ console.log(result.missing.length ? chalk.yellow(line) : chalk.green(line));
215
+ }
216
+ }
217
+ /**
218
+ * Printed under a "bundle not found" failure: if the name matches a bundle
219
+ * stranded in the iCloud Keychain (pre-device-local-cutover era), point at the
220
+ * recovery command instead of leaving a dead end.
221
+ */
222
+ function maybePrintSyncedHint(name) {
223
+ if (process.platform !== 'darwin')
224
+ return;
225
+ try {
226
+ if (discoverSyncedBundles().some((c) => c.name === name)) {
227
+ console.error(chalk.yellow(`A legacy iCloud Keychain copy of '${name}' exists. Recover it with: agents secrets import ${name} --from icloud`));
228
+ }
229
+ }
230
+ catch {
231
+ // Hint only — never mask the original error.
232
+ }
233
+ }
138
234
  /**
139
235
  * Build the remote `agents secrets unlock` argv for `unlock --host`. `--all`
140
236
  * forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
@@ -525,7 +621,8 @@ export function registerSecretsCommands(program) {
525
621
  agents secrets status show held bundles + when they lock
526
622
  agents secrets rotate <bundle> <key> rotate value, preserve metadata
527
623
  agents secrets import <bundle> --from .env bulk import from .env
528
- agents secrets import <bundle> --from-1password --vault <name>
624
+ agents secrets import <bundle> --from 1password:<vault>
625
+ agents secrets import --from icloud recover legacy iCloud Keychain bundles
529
626
  agents secrets generate [length] generate a random password / PIN / hex
530
627
  agents secrets migrate-acl upgrade legacy items to the biometry ACL
531
628
  `,
@@ -610,7 +707,15 @@ export function registerSecretsCommands(program) {
610
707
  return;
611
708
  }
612
709
  const resolvedName = name ?? (await pickBundleName('view'));
613
- const bundle = readBundle(resolvedName);
710
+ let bundle;
711
+ try {
712
+ bundle = readBundle(resolvedName);
713
+ }
714
+ catch (err) {
715
+ console.error(chalk.red(err.message));
716
+ maybePrintSyncedHint(resolvedName);
717
+ process.exit(1);
718
+ }
614
719
  const entries = describeBundle(bundle);
615
720
  console.log(chalk.bold(bundle.name));
616
721
  if (bundle.description)
@@ -672,7 +777,7 @@ export function registerSecretsCommands(program) {
672
777
  emit('secrets.get', {
673
778
  module: 'secrets',
674
779
  bundle: bundle.name,
675
- caller: 'view --reveal',
780
+ operation: 'view --reveal',
676
781
  source: 'reveal',
677
782
  status: 'success',
678
783
  keyCount: exposedCount,
@@ -713,24 +818,48 @@ export function registerSecretsCommands(program) {
713
818
  }
714
819
  });
715
820
  cmd
716
- .command('get <item>')
717
- .description('Print a raw keychain item by name (for shell hooks/automation). Cross-platform; no bundle required.')
718
- .action((item) => {
821
+ .command('get <item> [key]')
822
+ .description('Print one secret value for shell hooks/automation. One arg = a raw keychain item by name; two args = one KEY out of a bundle (`get <bundle> <KEY>`). Cross-platform.')
823
+ .action((item, key) => {
824
+ if (key === undefined) {
825
+ // Raw keychain item path — unchanged.
826
+ try {
827
+ // Routes through the platform keychain layer: macOS reads bare items
828
+ // via /usr/bin/security (no Touch ID), Linux via secret-tool with the
829
+ // encrypted-file fallback. The value goes to stdout (newline-terminated
830
+ // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
831
+ // to stderr so they never pollute the captured value.
832
+ const value = getKeychainToken(item);
833
+ // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
834
+ // `item` is the keychain service name, never the value.
835
+ emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
836
+ process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
837
+ }
838
+ catch {
839
+ // Missing item is a normal, quiet outcome for a hook probe: exit 1,
840
+ // print nothing to stdout. Callers test the exit code / empty capture.
841
+ process.exit(1);
842
+ }
843
+ return;
844
+ }
845
+ // Bundle-key path: `get <bundle> <KEY>` prints exactly one resolved value.
846
+ // Ungated like the raw path (it IS the automation primitive); the
847
+ // `secrets.get` audit event is emitted inside readAndResolveBundleEnv.
719
848
  try {
720
- // Routes through the platform keychain layer: macOS reads bare items
721
- // via /usr/bin/security (no Touch ID), Linux via secret-tool with the
722
- // encrypted-file fallback. The value goes to stdout (newline-terminated
723
- // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
724
- // to stderr so they never pollute the captured value.
725
- const value = getKeychainToken(item);
726
- // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
727
- // `item` is the keychain service name, never the value.
728
- emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
849
+ if (!bundleExists(item)) {
850
+ console.error(chalk.red(`Secrets bundle '${item}' not found.`));
851
+ process.exit(1);
852
+ }
853
+ const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key] });
854
+ if (!(key in env)) {
855
+ console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
856
+ process.exit(1);
857
+ }
858
+ const value = env[key];
729
859
  process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
730
860
  }
731
- catch {
732
- // Missing item is a normal, quiet outcome for a hook probe: exit 1,
733
- // print nothing to stdout. Callers test the exit code / empty capture.
861
+ catch (err) {
862
+ console.error(chalk.red(err.message));
734
863
  process.exit(1);
735
864
  }
736
865
  });
@@ -1160,23 +1289,34 @@ Examples:
1160
1289
  });
1161
1290
  cmd
1162
1291
  .command('import [bundle]')
1163
- .description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
1164
- .option('--from <path>', 'Path to a .env file (use - to read the .env from stdin)')
1165
- .option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
1166
- .option('--vault <name>', '1Password vault name (used with --from-1password)')
1292
+ .description('Import keys into a bundle from a .env file, a 1Password vault, or legacy iCloud Keychain bundles. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
1293
+ .option('--from <source>', "Source: a .env path (- reads stdin), '1password:<vault>', or 'icloud' (legacy iCloud Keychain bundles)")
1294
+ .addOption(new Option('--from-1password', 'deprecated alias for --from 1password:<vault>').hideHelp())
1295
+ .addOption(new Option('--vault <name>', 'deprecated: name the vault in --from 1password:<vault>').hideHelp())
1167
1296
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
1168
1297
  .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
1169
1298
  .option('--force', 'Overwrite an existing key in the bundle')
1299
+ .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
1170
1300
  .action(async (bundleName, opts) => {
1171
1301
  try {
1172
- if (!opts.from && !opts.from1password) {
1173
- throw new Error('Pass --from <path> to import a .env file, or --from-1password to import from a 1Password vault.');
1302
+ const source = parseImportSource(opts);
1303
+ if (opts.purge && source.kind !== 'icloud') {
1304
+ throw new Error('--purge only applies to --from icloud.');
1174
1305
  }
1175
- if (opts.from && opts.from1password) {
1176
- throw new Error('--from and --from-1password are mutually exclusive.');
1306
+ if (opts.from1password) {
1307
+ console.log(chalk.yellow('--from-1password is deprecated; use --from 1password:<vault>.'));
1177
1308
  }
1178
- const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1179
1309
  const requestedBackend = parseBackendOpt(opts.backend);
1310
+ if (source.kind === 'icloud') {
1311
+ await importFromICloud(bundleName, {
1312
+ force: opts.force,
1313
+ allPlaintext: opts.allPlaintext,
1314
+ backend: requestedBackend === 'file' ? 'file' : undefined,
1315
+ purge: opts.purge,
1316
+ });
1317
+ return;
1318
+ }
1319
+ const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1180
1320
  // Read the bundle if it exists (inheriting its backend); otherwise
1181
1321
  // create it with the requested backend so a single `import --backend
1182
1322
  // file` works (this is what `export --host ... --remote-backend file`
@@ -1199,9 +1339,9 @@ Examples:
1199
1339
  const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
1200
1340
  let added = 0;
1201
1341
  let skipped = 0;
1202
- if (opts.from1password) {
1342
+ if (source.kind === '1password') {
1203
1343
  assertOpAvailable();
1204
- const vault = await resolveVault(opts.vault);
1344
+ const vault = await resolveVault(source.vault);
1205
1345
  const items = listItems(vault);
1206
1346
  const { secrets, skipped: opSkipped } = extractSecrets(items, vault);
1207
1347
  for (const { envKey, value } of secrets) {
@@ -1226,7 +1366,7 @@ Examples:
1226
1366
  console.log(chalk.green(`Imported ${added} key(s) from 1Password vault '${vault}'${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1227
1367
  }
1228
1368
  else {
1229
- const raw = readImportDotenv(opts.from);
1369
+ const raw = readImportDotenv(source.path);
1230
1370
  const pairs = parseDotenv(raw);
1231
1371
  for (const [key, value] of Object.entries(pairs)) {
1232
1372
  if (!opts.force && key in bundle.vars) {
@@ -1720,20 +1860,11 @@ Examples:
1720
1860
  console.error(chalk.yellow('Aborted.'));
1721
1861
  return;
1722
1862
  }
1723
- const wasDaily = bundlePolicy(bundle) === 'daily';
1724
1863
  bundle.policy = next;
1864
+ // writeBundle evicts any broker-held copy, so tightening daily ->
1865
+ // always/never takes effect NOW: the next read re-prompts (`always`)
1866
+ // or reads its no-ACL item directly (`never`).
1725
1867
  writeBundle(bundle);
1726
- // Tightening daily -> always/never must take effect NOW, not up to the
1727
- // ~7d hold later. If the broker is already serving this bundle silently
1728
- // (auto-cached under the old `daily` policy), evict it so the next read
1729
- // re-prompts (`always`) or reads its no-ACL item directly (`never`).
1730
- // macOS-only + best-effort; agentLock no-ops off darwin / with no broker.
1731
- if (wasDaily && next !== 'daily') {
1732
- try {
1733
- await agentLock(bundle.name);
1734
- }
1735
- catch { /* broker down — nothing held */ }
1736
- }
1737
1868
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
1738
1869
  if (next === 'daily') {
1739
1870
  console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));