@phnx-labs/agents-cli 1.20.70 → 1.20.72

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 (80) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/commands.js +2 -0
  6. package/dist/commands/exec.js +12 -0
  7. package/dist/commands/fleet-capture.d.ts +14 -0
  8. package/dist/commands/fleet-capture.js +107 -0
  9. package/dist/commands/fork.js +13 -7
  10. package/dist/commands/hosts.js +11 -0
  11. package/dist/commands/logs.js +38 -9
  12. package/dist/commands/mcp.js +2 -0
  13. package/dist/commands/resource-view.d.ts +2 -0
  14. package/dist/commands/resource-view.js +8 -0
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +24 -4
  17. package/dist/commands/sessions-tail.js +1 -2
  18. package/dist/commands/sessions.d.ts +6 -0
  19. package/dist/commands/sessions.js +14 -6
  20. package/dist/commands/skills.js +2 -0
  21. package/dist/commands/ssh.js +24 -6
  22. package/dist/commands/status.js +8 -2
  23. package/dist/commands/subagents.js +3 -1
  24. package/dist/commands/uninstall.d.ts +11 -0
  25. package/dist/commands/uninstall.js +158 -0
  26. package/dist/commands/usage.d.ts +13 -0
  27. package/dist/commands/usage.js +50 -28
  28. package/dist/commands/utils.d.ts +29 -0
  29. package/dist/commands/utils.js +24 -0
  30. package/dist/commands/versions.js +120 -11
  31. package/dist/index.js +5 -3
  32. package/dist/lib/commands.d.ts +10 -0
  33. package/dist/lib/commands.js +31 -7
  34. package/dist/lib/daemon.d.ts +9 -0
  35. package/dist/lib/daemon.js +43 -0
  36. package/dist/lib/devices/connect.d.ts +13 -0
  37. package/dist/lib/devices/connect.js +20 -0
  38. package/dist/lib/devices/fleet.d.ts +36 -3
  39. package/dist/lib/devices/fleet.js +42 -4
  40. package/dist/lib/devices/sync.d.ts +30 -0
  41. package/dist/lib/devices/sync.js +50 -0
  42. package/dist/lib/doctor-diff.js +38 -16
  43. package/dist/lib/fleet/apply.d.ts +4 -0
  44. package/dist/lib/fleet/apply.js +28 -5
  45. package/dist/lib/fleet/capture.d.ts +35 -0
  46. package/dist/lib/fleet/capture.js +51 -0
  47. package/dist/lib/fleet/manifest.d.ts +6 -0
  48. package/dist/lib/fleet/manifest.js +24 -1
  49. package/dist/lib/fleet/types.d.ts +24 -1
  50. package/dist/lib/git.d.ts +14 -0
  51. package/dist/lib/git.js +39 -1
  52. package/dist/lib/hosts/logs.d.ts +12 -0
  53. package/dist/lib/hosts/logs.js +18 -0
  54. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  55. package/dist/lib/menubar/install-menubar.js +26 -13
  56. package/dist/lib/secrets/agent.d.ts +5 -0
  57. package/dist/lib/secrets/agent.js +35 -3
  58. package/dist/lib/secrets/bundles.js +28 -0
  59. package/dist/lib/secrets/index.d.ts +14 -0
  60. package/dist/lib/secrets/index.js +36 -5
  61. package/dist/lib/secrets/session-store.d.ts +90 -0
  62. package/dist/lib/secrets/session-store.js +221 -0
  63. package/dist/lib/session/render.d.ts +9 -1
  64. package/dist/lib/session/render.js +35 -4
  65. package/dist/lib/share/capture.d.ts +2 -0
  66. package/dist/lib/share/capture.js +12 -5
  67. package/dist/lib/shims.d.ts +27 -0
  68. package/dist/lib/shims.js +29 -3
  69. package/dist/lib/skills.d.ts +8 -1
  70. package/dist/lib/skills.js +11 -1
  71. package/dist/lib/startup/command-registry.d.ts +1 -0
  72. package/dist/lib/startup/command-registry.js +2 -0
  73. package/dist/lib/types.d.ts +5 -1
  74. package/dist/lib/uninstall.d.ts +84 -0
  75. package/dist/lib/uninstall.js +347 -0
  76. package/dist/lib/usage.d.ts +13 -1
  77. package/dist/lib/usage.js +32 -14
  78. package/dist/lib/versions.d.ts +16 -0
  79. package/dist/lib/versions.js +40 -3
  80. package/package.json +1 -1
@@ -20,7 +20,8 @@ import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBund
20
20
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
21
21
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
22
22
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
23
- import { secretsHoldMs, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
23
+ import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
24
+ import { saveSession, deleteSession, deleteAllSessions } from '../lib/secrets/session-store.js';
24
25
  import { getCliVersionFresh } from '../lib/version.js';
25
26
  import { readMeta } from '../lib/state.js';
26
27
  import { parseDuration } from '../lib/hooks/cache.js';
@@ -249,6 +250,9 @@ export function buildRemoteUnlockArgs(names, opts) {
249
250
  'unlock',
250
251
  ...(opts.all ? ['--all'] : names),
251
252
  ...(opts.ttl ? ['--ttl', opts.ttl] : []),
253
+ // Forward --durable so a remote unlock honors it too; without this the remote
254
+ // silently falls back to its own secrets.agent.durable default (off).
255
+ ...(opts.durable ? ['--durable'] : []),
252
256
  ];
253
257
  }
254
258
  // SSH target validation is defined canonically in src/lib/ssh-exec.ts and
@@ -1840,6 +1844,7 @@ Examples:
1840
1844
  .command('unlock [names...]')
1841
1845
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
1842
1846
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
1847
+ .option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
1843
1848
  .option('--all', 'Unlock every configured bundle')
1844
1849
  .option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
1845
1850
  .action(async (names, opts) => {
@@ -1880,8 +1885,12 @@ Examples:
1880
1885
  return;
1881
1886
  }
1882
1887
  if (process.platform !== 'darwin') {
1883
- console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
1884
- process.exit(1);
1888
+ // No broker + no biometry prompt off darwin: secrets already resolve
1889
+ // durably from the OS store (libsecret / Credential Manager) on every
1890
+ // read with no prompt, so an unlock is a friendly no-op — not an error.
1891
+ // Accept --durable as a documented no-op so the command is uniform.
1892
+ console.log(chalk.gray('Nothing to unlock: secrets already persist on this OS — reads never re-prompt.'));
1893
+ return;
1885
1894
  }
1886
1895
  let targets = opts.all ? listBundles().map((b) => b.name) : names;
1887
1896
  if (!targets || targets.length === 0) {
@@ -1915,6 +1924,14 @@ Examples:
1915
1924
  const { bundle, env } = readAndResolveBundleEnv(name, { noAgent: true, caller: 'unlock' });
1916
1925
  if (await agentLoad(name, bundle, env, ttlMs)) {
1917
1926
  loaded++;
1927
+ // Persist a durable session snapshot so the unlock survives a daemon
1928
+ // restart / upgrade (and sleep too, with --durable). session-store.ts.
1929
+ saveSession(name, {
1930
+ bundle,
1931
+ env,
1932
+ expiresAt: Date.now() + ttlMs,
1933
+ sleepPersist: opts.durable ?? secretsAgentDurable(),
1934
+ });
1918
1935
  console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
1919
1936
  }
1920
1937
  else {
@@ -1941,12 +1958,15 @@ Examples:
1941
1958
  return; // nothing to lock off darwin
1942
1959
  if (names && names.length > 0 && !opts.all) {
1943
1960
  let total = 0;
1944
- for (const name of names)
1961
+ for (const name of names) {
1945
1962
  total += await agentLock(name);
1963
+ deleteSession(name); // also drop the durable snapshot, or a restart re-warms it
1964
+ }
1946
1965
  console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
1947
1966
  }
1948
1967
  else {
1949
1968
  const wiped = await agentLock();
1969
+ deleteAllSessions();
1950
1970
  console.log(wiped > 0 ? chalk.green(`Locked ${wiped} bundle(s).`) : chalk.gray('Nothing to lock.'));
1951
1971
  }
1952
1972
  });
@@ -218,8 +218,7 @@ export function registerSessionsTailCommand(sessionsCmd) {
218
218
  .command('tail [sessionId]')
219
219
  .description('Stream JSONL events from a session file as they are written, one event per line. Long-running: Ctrl+C to stop. Claude and Codex only.')
220
220
  .option('--latest', 'Tail the most recent tailable session (claude or codex)')
221
- .option('--from-start', 'Emit the full file first, then follow (default: start at EOF)')
222
- .option('--json', 'Raw JSONL passthrough (default)');
221
+ .option('--from-start', 'Emit the full file first, then follow (default: start at EOF)');
223
222
  setHelpSections(tailCmd, {
224
223
  examples: `
225
224
  # Follow the most recent active Claude or Codex session
@@ -155,6 +155,12 @@ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap:
155
155
  * (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
156
156
  */
157
157
  export declare function renderSessionLog(session: SessionMeta, mode?: ViewMode): Promise<void>;
158
+ /**
159
+ * Emit one session exactly as `agents sessions <id> --json` does — the same
160
+ * redact-by-default `{ session, events }` shape — so `agents logs <id> --json`
161
+ * shares one machine-readable session contract instead of inventing another.
162
+ */
163
+ export declare function renderSessionLogJson(session: SessionMeta): Promise<void>;
158
164
  /** Column-visibility flags for the picker row, computed once over the whole pool. */
159
165
  export interface PickerColumns {
160
166
  /** Render the machine column (only when the pool spans more than one machine). */
@@ -998,7 +998,7 @@ async function sessionsAction(query, options) {
998
998
  // When the user explicitly asks to render (via mode flag), resolve the
999
999
  // query globally so sessions outside the default cwd/30d window are found.
1000
1000
  if (wantsRender && searchQuery) {
1001
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, noRedact: options.noRedact });
1001
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1002
1002
  return;
1003
1003
  }
1004
1004
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -1068,7 +1068,7 @@ async function sessionsAction(query, options) {
1068
1068
  return;
1069
1069
  }
1070
1070
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
1071
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, noRedact: options.noRedact });
1071
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1072
1072
  return;
1073
1073
  }
1074
1074
  }
@@ -1422,6 +1422,14 @@ function resolveViewMode(options, filters) {
1422
1422
  export async function renderSessionLog(session, mode = 'summary') {
1423
1423
  await renderSession(session, mode, {});
1424
1424
  }
1425
+ /**
1426
+ * Emit one session exactly as `agents sessions <id> --json` does — the same
1427
+ * redact-by-default `{ session, events }` shape — so `agents logs <id> --json`
1428
+ * shares one machine-readable session contract instead of inventing another.
1429
+ */
1430
+ export async function renderSessionLogJson(session) {
1431
+ await renderSession(session, 'json', {});
1432
+ }
1425
1433
  async function renderSession(session, mode, filters, options = {}) {
1426
1434
  // OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
1427
1435
  const realPath = session.filePath.split('#')[0];
@@ -1475,7 +1483,7 @@ async function renderSession(session, mode, filters, options = {}) {
1475
1483
  chalk.gray(` ${formatRelativeTime(session.timestamp)}`) +
1476
1484
  (session.account ? chalk.gray(` (${session.account})`) : ''));
1477
1485
  console.log(chalk.gray('─'.repeat(60)));
1478
- process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.noRedact !== true })));
1486
+ process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.redact !== false })));
1479
1487
  return;
1480
1488
  }
1481
1489
  // json — normalized events plus the durable session signals from the state
@@ -1486,7 +1494,7 @@ async function renderSession(session, mode, filters, options = {}) {
1486
1494
  // checklist reflects true session state regardless of any `--include` filter;
1487
1495
  // it lets the Factory panel read the CLI's checklist instead of re-parsing.
1488
1496
  const todos = inferSessionState(parsedEvents, { cwd: session.cwd }).todos;
1489
- process.stdout.write(renderJson(events, todos ? { ...session, todos } : session));
1497
+ process.stdout.write(renderJson(events, todos ? { ...session, todos } : session, { redact: options.redact !== false }));
1490
1498
  }
1491
1499
  function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
1492
1500
  const lbl = (label ?? '').trim();
@@ -2162,7 +2170,7 @@ async function renderOneSession(query, mode, scope) {
2162
2170
  throw new Error('Session resolution failed');
2163
2171
  }
2164
2172
  spinner.stop();
2165
- await renderSession(session, mode, scope.filter, { noRedact: scope.noRedact });
2173
+ await renderSession(session, mode, scope.filter, { redact: scope.redact });
2166
2174
  }
2167
2175
  catch (err) {
2168
2176
  if (isPromptCancelled(err))
@@ -2195,7 +2203,7 @@ export function registerSessionsCommands(program) {
2195
2203
  .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
2196
2204
  .option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
2197
2205
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2198
- .option('--no-redact', 'Disable default secret redaction in markdown session output')
2206
+ .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
2199
2207
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
2200
2208
  .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
2201
2209
  .option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
@@ -45,6 +45,7 @@ When to use:
45
45
  .command('list [agent]')
46
46
  .description('Show which skills are installed and which agent versions they are synced to')
47
47
  .option('-a, --agent <agent>', 'Filter to a specific agent (alternative to positional arg)')
48
+ .option('--json', 'Emit machine-readable JSON instead of the table/picker')
48
49
  .action(async (agentArg, options) => {
49
50
  const spinner = ora({ text: 'Loading...', isSilent: !process.stdout.isTTY }).start();
50
51
  const agentInput = agentArg || options.agent;
@@ -74,6 +75,7 @@ When to use:
74
75
  centralPath: getSkillsDir(),
75
76
  filterAgent,
76
77
  filterVersion,
78
+ json: options.json,
77
79
  });
78
80
  });
79
81
  skillsCmd
@@ -18,6 +18,7 @@ import ora from 'ora';
18
18
  import { getCliVersion } from '../lib/version.js';
19
19
  import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
20
20
  import { machineId } from '../lib/session/sync/config.js';
21
+ import { registerFleetCaptureCommand } from './fleet-capture.js';
21
22
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
22
23
  import { addControlToken } from '../lib/serve/token.js';
23
24
  import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
@@ -27,7 +28,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
27
28
  import { clearPendingSentinel } from '../lib/devices/pending.js';
28
29
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
29
30
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
30
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
31
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
31
32
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
32
33
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
33
34
  import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
@@ -244,10 +245,10 @@ async function probeRemoteHealth(target) {
244
245
  const isWin = /^win/i.test((target.platform ?? '').trim());
245
246
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
246
247
  const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
247
- const versionRes = await sshExecAsync(target.name, versionCmd, { timeoutMs: 15000, multiplex: true });
248
+ const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
248
249
  const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
249
250
  const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
250
- const doctorRes = await sshExecAsync(target.name, doctorCmd, { timeoutMs: 30000, multiplex: true });
251
+ const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
251
252
  if (doctorRes.code !== 0) {
252
253
  throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
253
254
  }
@@ -280,6 +281,20 @@ async function runFleetStatus(opts) {
280
281
  name: t.device.name,
281
282
  platform: t.device.platform,
282
283
  skip: t.skip,
284
+ dialTarget: fleetDialTarget(t.device),
285
+ // Fail fast: the stats probe already tried this box with the same
286
+ // (registry) address. If it came back unreachable, don't spend another
287
+ // 15s+30s on version+doctor — skip straight to an unreachable row so one
288
+ // genuinely-offline device can't stall the whole matrix.
289
+ //
290
+ // Only trust this on a FRESH probe (`--refresh`/`--live`). loadFleetStats
291
+ // is cache-first (daemon-warmed ~3min), so a box that came back online
292
+ // since the last warm would otherwise be skipped to "unreachable" without
293
+ // a live attempt — a false negative. On a default run we still do the live
294
+ // probe (fast now that it dials the registry address).
295
+ ...(forceRefresh && statsMap.get(t.device.name)?.reachable === false && !t.skip
296
+ ? { skip: 'unreachable' }
297
+ : {}),
283
298
  }));
284
299
  const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
285
300
  for (const result of remote) {
@@ -333,7 +348,7 @@ async function probeRemoteAuth(target) {
333
348
  const isWin = /^win/i.test((target.platform ?? '').trim());
334
349
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
335
350
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
336
- const res = await sshExecAsync(target.name, cmd, { timeoutMs: 60000, multiplex: true });
351
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
337
352
  if (res.code !== 0) {
338
353
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
339
354
  }
@@ -370,6 +385,7 @@ async function runFleetPing(opts) {
370
385
  name: t.device.name,
371
386
  platform: t.device.platform,
372
387
  skip: t.skip,
388
+ dialTarget: fleetDialTarget(t.device),
373
389
  }));
374
390
  const probeable = remoteTargets.filter((t) => !t.skip).length;
375
391
  const spinner = isInteractiveTerminal() && !opts.json
@@ -511,6 +527,8 @@ Typical workflow:
511
527
  process.exit(1);
512
528
  }
513
529
  });
530
+ // `agents fleet capture` — snapshot live state into agents.yaml fleet:.
531
+ registerFleetCaptureCommand(devicesCmd);
514
532
  devicesCmd
515
533
  .command('register <name>')
516
534
  .description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
@@ -781,7 +799,7 @@ Typical workflow:
781
799
  return;
782
800
  }
783
801
  console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
784
- const results = runFleet(targets, cmd);
802
+ const results = runFleet(targets, cmd, { self: machineId() });
785
803
  printFleetResults(results);
786
804
  });
787
805
  devicesCmd
@@ -800,7 +818,7 @@ Typical workflow:
800
818
  return;
801
819
  }
802
820
  console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
803
- const results = runFleet(targets, cmd);
821
+ const results = runFleet(targets, cmd, { self: machineId() });
804
822
  printFleetResults(results);
805
823
  });
806
824
  }
@@ -13,6 +13,7 @@ import { AGENTS } from '../lib/agents.js';
13
13
  import { setHelpSections } from '../lib/help.js';
14
14
  import { computeSyncStatus } from '../lib/sync-status.js';
15
15
  import { promptDriftSync } from '../lib/drift-sync.js';
16
+ import { resolveSurface } from './utils.js';
16
17
  const agentName = (id) => AGENTS[id]?.name ?? id;
17
18
  function versionSummary(v) {
18
19
  if (!v.everSynced)
@@ -46,9 +47,14 @@ export function registerStatusCommand(program) {
46
47
  agents status --yes
47
48
  `,
48
49
  });
49
- cmd.action(async (opts) => {
50
+ cmd.action(async (opts, command) => {
51
+ // Centralized surface read (the human/agent split in one place). Note we still
52
+ // pass the *raw* `opts.yes` to promptDriftSync below — it distinguishes an
53
+ // explicit `--yes` (act) from a non-TTY shell (report only), so `surface.assumeYes`
54
+ // (which conflates the two) would wrongly auto-reconcile in a plain pipe.
55
+ const surface = resolveSurface(command);
50
56
  const cwd = opts.cwd ?? process.cwd();
51
- if (opts.json) {
57
+ if (surface.json) {
52
58
  const status = await computeSyncStatus({ cwd });
53
59
  console.log(JSON.stringify(status, null, 2));
54
60
  return;
@@ -53,7 +53,7 @@ When to use:
53
53
  - Team sharing: distribute subagent definitions via GitHub repos
54
54
  `);
55
55
  // Shared list implementation, registered as `list` and hidden `view` alias.
56
- const runList = async () => {
56
+ const runList = async (opts) => {
57
57
  const rows = buildSubagentRows();
58
58
  await showResourceList({
59
59
  resourcePlural: 'subagents',
@@ -62,12 +62,14 @@ When to use:
62
62
  rows,
63
63
  emptyMessage: 'No subagents in ~/.agents/subagents/. Add one with: agents subagents add gh:user/repo',
64
64
  centralPath: getSubagentsDir(),
65
+ json: opts?.json,
65
66
  });
66
67
  };
67
68
  // agents subagents list
68
69
  subagentsCmd
69
70
  .command('list')
70
71
  .description('Show subagents in a table with sync status across agent versions')
72
+ .option('--json', 'Emit machine-readable JSON instead of the table/picker')
71
73
  .addHelpText('after', `
72
74
  Examples:
73
75
  # Interactive picker (TTY) or sync-status table (piped)
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `agents uninstall` — completely remove agents-cli and restore the user's
3
+ * original agent configs. The reverse of `agents setup`.
4
+ *
5
+ * Thin command layer: the restore/teardown logic lives in `lib/uninstall.ts`
6
+ * (planUninstall / executeUninstall) so it can be tested against a real temp
7
+ * HOME without the CLI wrapper.
8
+ */
9
+ import type { Command } from 'commander';
10
+ /** Register `agents uninstall`. */
11
+ export declare function registerUninstallCommands(program: Command): void;
@@ -0,0 +1,158 @@
1
+ import chalk from 'chalk';
2
+ import { confirm } from '@inquirer/prompts';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ import { planUninstall, executeUninstall } from '../lib/uninstall.js';
5
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
6
+ /** Render the read-only plan so the user sees exactly what will change. */
7
+ function printPlan(plan, purge) {
8
+ const restores = plan.configs.filter((c) => c.kind === 'restore-backup' || c.kind === 'restore-version-home');
9
+ const dangling = plan.configs.filter((c) => c.kind === 'remove-dangling');
10
+ const untouched = plan.configs.filter((c) => c.kind === 'leave-real' || c.kind === 'leave-foreign');
11
+ console.log(chalk.bold('\nagents uninstall — planned changes\n'));
12
+ if (restores.length > 0) {
13
+ console.log(chalk.green('Restore your original config (adopted by agents-cli):'));
14
+ for (const c of restores) {
15
+ const how = c.kind === 'restore-backup' ? 'from backup' : 'from version home';
16
+ console.log(chalk.gray(` ${c.realPath} ${chalk.dim(`(${how})`)}`));
17
+ }
18
+ }
19
+ if (dangling.length > 0) {
20
+ console.log(chalk.yellow('Remove dangling symlink (no original found to restore):'));
21
+ for (const c of dangling)
22
+ console.log(chalk.gray(` ${c.realPath}`));
23
+ }
24
+ if (untouched.length > 0) {
25
+ console.log(chalk.cyan('Left untouched (real, un-adopted configs — never modified):'));
26
+ for (const c of untouched)
27
+ console.log(chalk.gray(` ${c.realPath}`));
28
+ }
29
+ if (plan.homeFiles.length > 0) {
30
+ console.log(chalk.green('Restore home files:'));
31
+ for (const hf of plan.homeFiles)
32
+ console.log(chalk.gray(` ${hf.realPath}`));
33
+ }
34
+ if (plan.launchers.length > 0) {
35
+ console.log(chalk.green('Release adopted launchers (restore native binaries on PATH):'));
36
+ console.log(chalk.gray(` ${plan.launchers.join(', ')}`));
37
+ }
38
+ if (plan.rcFiles.length > 0) {
39
+ console.log(chalk.green('Remove the shim directory from PATH in:'));
40
+ for (const rc of plan.rcFiles)
41
+ console.log(chalk.gray(` ${rc}`));
42
+ }
43
+ console.log(chalk.bold('\nData:'));
44
+ if (purge) {
45
+ console.log(chalk.red(` Permanently delete ${plan.agentsDir} (installed versions, session history, secrets metadata).`));
46
+ if (plan.legacySymlink)
47
+ console.log(chalk.red(` Permanently delete ${plan.legacySymlink}.`));
48
+ }
49
+ else {
50
+ console.log(chalk.gray(` Move ${plan.agentsDir} aside to ${plan.agentsDir}.removed-<timestamp> (recoverable).`));
51
+ if (plan.legacySymlink)
52
+ console.log(chalk.gray(` Remove the legacy symlink ${plan.legacySymlink}.`));
53
+ console.log(chalk.gray(' Use --purge to hard-delete instead.'));
54
+ }
55
+ console.log();
56
+ }
57
+ /** Report what actually happened, then the final manual npm step. */
58
+ function printResult(result, cleanedPath) {
59
+ for (const r of result.restoredConfigs)
60
+ console.log(chalk.green(`Restored ${r.realPath}`));
61
+ for (const r of result.removedDanglingConfigs)
62
+ console.log(chalk.yellow(`Removed dangling symlink ${r.realPath}`));
63
+ for (const f of result.restoredHomeFiles)
64
+ console.log(chalk.green(`Restored ${f}`));
65
+ if (result.releasedLaunchers.length > 0)
66
+ console.log(chalk.green(`Released launchers: ${result.releasedLaunchers.join(', ')}`));
67
+ for (const rc of result.cleanedRcFiles)
68
+ console.log(chalk.green(`Cleaned shim PATH entry from ${rc}`));
69
+ if (result.agentsDir.disposition === 'moved') {
70
+ console.log(chalk.gray(`Moved ${result.agentsDir.path} to ${result.agentsDir.movedTo}`));
71
+ }
72
+ else if (result.agentsDir.disposition === 'purged') {
73
+ console.log(chalk.gray(`Deleted ${result.agentsDir.path}`));
74
+ }
75
+ for (const e of result.errors)
76
+ console.log(chalk.red(` ! ${e}`));
77
+ if (result.purgeDowngraded) {
78
+ console.log(chalk.yellow(`\n--purge was downgraded to move-aside because a restore step errored — ${result.agentsDir.path} was kept so nothing is lost. Resolve the errors above, then delete ${result.agentsDir.movedTo} manually.`));
79
+ }
80
+ console.log(chalk.bold('\nFinish by removing the CLI package:'));
81
+ console.log(chalk.gray(' npm uninstall -g @phnx-labs/agents-cli # or: bun remove -g @phnx-labs/agents-cli'));
82
+ if (cleanedPath) {
83
+ console.log(chalk.gray('Open a new shell (or re-source your rc file) so the removed PATH entry takes effect.'));
84
+ }
85
+ if (result.agentsDir.disposition === 'moved') {
86
+ console.log(chalk.gray(`Your data is still at ${result.agentsDir.movedTo} — delete it once you are sure.`));
87
+ }
88
+ }
89
+ /** Register `agents uninstall`. */
90
+ export function registerUninstallCommands(program) {
91
+ const cmd = program
92
+ .command('uninstall')
93
+ .description('Completely remove agents-cli and restore your original agent configs. Reverses `agents setup`.')
94
+ .option('--purge', 'Hard-delete ~/.agents (installed versions, sessions, secrets metadata) instead of moving it aside')
95
+ .option('--dry-run', 'Show exactly what would change without modifying anything')
96
+ .option('-y, --yes', 'Skip the confirmation prompt (required to run non-interactively)');
97
+ setHelpSections(cmd, {
98
+ examples: `
99
+ # Preview everything that would change, without touching anything
100
+ agents uninstall --dry-run
101
+
102
+ # Restore your configs and move ~/.agents aside (recoverable)
103
+ agents uninstall
104
+
105
+ # Same, but hard-delete ~/.agents (no recovery)
106
+ agents uninstall --purge
107
+ `,
108
+ notes: `
109
+ - Restores every ~/.<agent> that agents-cli adopted; a real config it never adopted is left untouched.
110
+ - Releases adopted launchers and strips the shim directory from your shell PATH.
111
+ - Without --purge, ~/.agents is moved to ~/.agents.removed-<timestamp> so nothing is lost.
112
+ - The CLI cannot delete its own running binary; it prints the final 'npm uninstall -g' step for you.
113
+ `,
114
+ });
115
+ cmd.action(async (options) => {
116
+ // We are tearing ~/.agents down. Silence the JSONL audit log for the rest
117
+ // of this process so a late emit() (its events path is memoized to the old
118
+ // location) can't re-create ~/.agents after we move it aside.
119
+ process.env.AGENTS_DISABLE_EVENT_LOG = '1';
120
+ const plan = planUninstall();
121
+ if (!plan.isInstalled) {
122
+ console.log(chalk.gray('agents-cli is not set up (no ~/.agents directory) — nothing to uninstall.'));
123
+ return;
124
+ }
125
+ printPlan(plan, !!options.purge);
126
+ if (options.dryRun) {
127
+ console.log(chalk.gray('Dry run — nothing was changed.'));
128
+ return;
129
+ }
130
+ if (!options.yes) {
131
+ if (!isInteractiveTerminal()) {
132
+ console.log(chalk.red('Refusing to uninstall non-interactively. Re-run with --yes to confirm.'));
133
+ return;
134
+ }
135
+ try {
136
+ const ok = await confirm({
137
+ message: options.purge
138
+ ? 'Permanently remove agents-cli and restore your original configs?'
139
+ : 'Remove agents-cli (data moved to a recoverable directory) and restore your original configs?',
140
+ default: false,
141
+ });
142
+ if (!ok) {
143
+ console.log(chalk.gray('Cancelled.'));
144
+ return;
145
+ }
146
+ }
147
+ catch (err) {
148
+ if (isPromptCancelled(err)) {
149
+ console.log(chalk.gray('Cancelled.'));
150
+ return;
151
+ }
152
+ throw err;
153
+ }
154
+ }
155
+ const result = executeUninstall(plan, { purge: options.purge, timestamp: Date.now() });
156
+ printResult(result, result.cleanedRcFiles.length > 0);
157
+ });
158
+ }
@@ -10,4 +10,17 @@
10
10
  * don't publish per-account usage today)
11
11
  */
12
12
  import type { Command } from 'commander';
13
+ import type { AgentId } from '../lib/types.js';
14
+ import { getUsageInfoForIdentity } from '../lib/usage.js';
15
+ /** One agent's usage snapshot — the unit the text and --json renderers share. */
16
+ export interface AgentUsageRecord {
17
+ agent: AgentId;
18
+ /** Plain agent name (no ANSI) — safe to emit in --json; the text table colorizes it. */
19
+ label: string;
20
+ status: 'unsupported' | 'no-version' | 'not-signed-in' | 'ok';
21
+ email?: string;
22
+ usage?: Awaited<ReturnType<typeof getUsageInfoForIdentity>>;
23
+ }
13
24
  export declare function registerUsageCommand(program: Command): void;
25
+ /** Render one usage record as the human table section. */
26
+ export declare function formatAgentUsage(rec: AgentUsageRecord): string;
@@ -13,13 +13,15 @@ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
13
13
  export function registerUsageCommand(program) {
14
14
  addHostOption(program.command('usage [agent]'))
15
15
  .description('Show rate-limit / quota usage per agent')
16
+ .option('--json', 'Emit machine-readable JSON (per-agent usage snapshot) instead of the table')
16
17
  .addHelpText('after', `
17
18
  Examples:
18
19
  agents usage Show usage for all installed agents
19
20
  agents usage claude Show usage for Claude only
20
21
  agents usage codex Show usage for Codex only
22
+ agents usage --json Machine-readable snapshot for scripts
21
23
  `)
22
- .action(async (agentFilter) => {
24
+ .action(async (agentFilter, options) => {
23
25
  let filter;
24
26
  if (agentFilter) {
25
27
  const resolved = resolveAgentName(agentFilter);
@@ -33,47 +35,67 @@ Examples:
33
35
  ? [filter]
34
36
  : ALL_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
35
37
  if (targets.length === 0) {
38
+ if (options.json) {
39
+ console.log('[]');
40
+ return;
41
+ }
36
42
  console.log(chalk.gray('No agents installed. Run `agents add <agent>` first.'));
37
43
  return;
38
44
  }
39
- const sections = await Promise.all(targets.map(async (agentId) => renderAgentUsage(agentId)));
40
- console.log(sections.filter(Boolean).join('\n\n'));
45
+ const records = await Promise.all(targets.map((agentId) => collectAgentUsage(agentId)));
46
+ if (options.json) {
47
+ console.log(JSON.stringify(records, null, 2));
48
+ return;
49
+ }
50
+ console.log(records.map(formatAgentUsage).filter(Boolean).join('\n\n'));
41
51
  });
42
52
  }
43
- async function renderAgentUsage(agentId) {
44
- const cfg = AGENTS[agentId];
45
- const heading = agentLabel(agentId);
53
+ /** Gather one agent's usage snapshot as structured data (shared by both renderers). */
54
+ async function collectAgentUsage(agentId) {
55
+ // Plain name — color is applied only at text-render time (formatAgentUsage), so
56
+ // `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
57
+ const label = AGENTS[agentId].name;
46
58
  if (!USAGE_SUPPORTED.has(agentId)) {
47
- return [
48
- `${heading}`,
49
- ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`,
50
- ].join('\n');
59
+ return { agent: agentId, label, status: 'unsupported' };
51
60
  }
52
61
  const versions = listInstalledVersions(agentId);
53
62
  const version = getGlobalDefault(agentId) || versions[0];
54
63
  if (!version) {
55
- return [`${heading}`, ` ${chalk.dim('No version installed.')}`].join('\n');
64
+ return { agent: agentId, label, status: 'no-version' };
56
65
  }
57
66
  const home = getVersionHomePath(agentId, version);
58
67
  const info = await getAccountInfo(agentId, home);
59
68
  if (!info.usageKey && !info.accountKey) {
60
- return [`${heading}`, ` ${chalk.dim('Not signed in.')}`].join('\n');
61
- }
62
- const usage = await getUsageInfoForIdentity({
63
- agentId,
64
- home,
65
- info,
66
- cliVersion: null,
67
- });
68
- const lines = [heading];
69
- if (info.email)
70
- lines.push(` ${chalk.dim(info.email)}`);
71
- const section = formatUsageSection(usage);
72
- if (section.length === 0) {
73
- lines.push(` ${chalk.dim('No usage data available right now.')}`);
69
+ return { agent: agentId, label, status: 'not-signed-in' };
74
70
  }
75
- else {
76
- lines.push(...section);
71
+ const usage = await getUsageInfoForIdentity({ agentId, home, info, cliVersion: null });
72
+ return { agent: agentId, label, status: 'ok', email: info.email ?? undefined, usage };
73
+ }
74
+ /** Render one usage record as the human table section. */
75
+ export function formatAgentUsage(rec) {
76
+ const cfg = AGENTS[rec.agent];
77
+ // Colorize the heading here (not in the record) so the plain `label` stays
78
+ // clean for --json while the text table keeps its per-agent color.
79
+ const heading = agentLabel(rec.agent);
80
+ switch (rec.status) {
81
+ case 'unsupported':
82
+ return [heading, ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`].join('\n');
83
+ case 'no-version':
84
+ return [heading, ` ${chalk.dim('No version installed.')}`].join('\n');
85
+ case 'not-signed-in':
86
+ return [heading, ` ${chalk.dim('Not signed in.')}`].join('\n');
87
+ case 'ok': {
88
+ const lines = [heading];
89
+ if (rec.email)
90
+ lines.push(` ${chalk.dim(rec.email)}`);
91
+ const section = formatUsageSection(rec.usage);
92
+ if (section.length === 0) {
93
+ lines.push(` ${chalk.dim('No usage data available right now.')}`);
94
+ }
95
+ else {
96
+ lines.push(...section);
97
+ }
98
+ return lines.join('\n');
99
+ }
77
100
  }
78
- return lines.join('\n');
79
101
  }