@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.72
4
+
5
+ - **Stop `agents doctor` from reporting phantom drift and `agents prune` from
6
+ deleting source-managed resources.** Three reconciler false positives are
7
+ fixed: the instruction file (`CLAUDE.md`/`AGENTS.md`) is now compared against
8
+ the composed active-preset output the rules writer actually emits — not the raw
9
+ whole-repo `rules/AGENTS.md` — so a correctly-synced home no longer shows as
10
+ permanent drift; plugin-bundled commands installed as `<plugin>-<command>`
11
+ command-skills (e.g. `swarm-plan`, `code-review`) are no longer flagged as
12
+ orphans/extras that `prune cleanup` would delete; and command-as-skill wrappers
13
+ (the `agents_command` marker) are no longer miscounted as skills and surfaced as
14
+ deletable skill orphans. Source: `apps/cli/src/lib/staleness/`,
15
+ `apps/cli/src/lib/commands.ts`, `apps/cli/src/lib/skills.ts`.
16
+
17
+ - **Capture your whole fleet into `agents.yaml`, then rebuild it anywhere with
18
+ `agents apply` (#1305).** New `agents fleet capture` (alias `agents devices
19
+ capture`) snapshots the live environment into the portable `fleet:` block — the
20
+ device roster (**names only**), the source's agents as `defaults`, secrets-bundle
21
+ **names**, and routine **names**. It commits **zero** Tailscale IPs or usernames:
22
+ `agents apply` reconstructs a fresh machine's roster by resolving each device
23
+ name **live from Tailscale** (`ensureDevicesRegistered`), so `git clone` +
24
+ `agents apply` replicates the fleet with nothing sensitive in the repo. `apply`
25
+ now also passes declared `sync:` scopes through to `agents sync <scope>`
26
+ (previously a bare `sync`) and surfaces declared secrets-bundle names to recreate
27
+ on each device (values stay keychain-local, never pushed). Browser profiles are
28
+ intentionally not duplicated into `fleet:` — they already sync via the central
29
+ `browser:` block. Source: `apps/cli/src/commands/fleet-capture.ts`,
30
+ `apps/cli/src/lib/fleet/capture.ts`, `apps/cli/src/lib/devices/sync.ts`,
31
+ `apps/cli/src/lib/fleet/{types,manifest,apply}.ts`.
32
+
33
+ - **`agents fleet status` no longer hangs on a stale `~/.ssh/config`.** The fleet probes (version / doctor / `fleet ping`) now dial each device at its registry Tailscale address (`dnsName`/IP) instead of the bare host name — so a hand-written `Host <name>` block carrying a drifted LAN IP can no longer shadow the correct entry and make a reachable box look dead. It also fails fast: a device the stats probe already found unreachable is skipped straight to an unreachable row instead of eating a 15s+30s version+doctor timeout. Source: `apps/cli/src/commands/ssh.ts`.
34
+
35
+ - **No more macOS keychain password prompt from an interactive command.** Writing a non-`agents-cli.` keychain item (e.g. a refreshed Claude OAuth token during `agents view`, or an `agents secrets add`) via `/usr/bin/security add-generic-password -w` piped the value over stdin — but `readpassphrase(3)` reads the *controlling terminal* when one exists, so in an interactive shell `security` prompted the user ("password data for new item:") and hung to the timeout, ignoring the piped value. The write now runs `detached` (a new session with no controlling terminal) so the piped stdin is always used. Verified under a pty. Source: `apps/cli/src/lib/secrets/index.ts`.
36
+
37
+ - **`agents logs <id> --json` now reports the true final status of a host task.**
38
+ For a run that finished remotely between dispatch and the one-shot `--json`
39
+ read, the payload emitted a stale `status: "running"` with no `exitCode` — even
40
+ though the completed log was already present — because `hostTaskLogJson`
41
+ discarded the reconciled record `reconcileTask` returns (it heals a new object
42
+ rather than mutating in place). It now emits the reconciled task, so a polling
43
+ agent sees `completed`/`failed` + `exitCode` + `finishedAt`. Source:
44
+ `apps/cli/src/lib/hosts/logs.ts`.
45
+
46
+ - **Fix the macOS menu-bar auto-heal so upgrades actually restart the helper.**
47
+ `agents` has an on-startup self-heal that re-copies `MenubarHelper.app` when
48
+ the CLI version changes, but on modern macOS `launchctl bootstrap` fails when
49
+ the job is already bootstrapped, and the deprecated `launchctl load -w`
50
+ fallback plus `kickstart -k` did not recover a job that launchd had stopped
51
+ respawning after a `WindowServer event port death`. The helper would stay
52
+ updated on disk but invisible in the menu bar. `enableMenubarService` now
53
+ boots the old job out, bootstraps the fresh plist, and kickstarts it — the
54
+ same sequence that reliably restores the icon by hand. Source:
55
+ `apps/cli/src/lib/menubar/install-menubar.ts`,
56
+ `apps/cli/src/lib/menubar/install-menubar.test.ts`.
57
+
58
+ - **`agents repo pull` no longer wedges on per-machine pin drift.** The committed
59
+ `devices/<machineId>/agents.yaml` (each box's agent version pins) is rewritten
60
+ whenever a pin changes, leaving the working tree perpetually dirty — so
61
+ `agents repo pull`, which refuses a dirty tree, kept failing until the file was
62
+ hand-committed. `pullRepo` now durably commits **just that one path** (explicit
63
+ pathspec) before pulling, via `commitOwnDeviceMeta`. Genuine uncommitted edits to
64
+ any other file still (correctly) block the pull. No-op for the system/extra repos
65
+ that don't own the path. Source: `apps/cli/src/lib/git.ts`.
66
+
67
+ - **`agents secrets unlock` now stays unlocked across an agents-cli upgrade (and,
68
+ with `--durable`, across sleep + reboot).** The macOS secrets broker held an
69
+ unlock only in RAM, so it evaporated every time the daemon restarted (upgrade)
70
+ or the machine slept — forcing a Touch ID re-tap and breaking headless reads
71
+ with "not unlocked in the secrets agent". An unlock now also persists a
72
+ device-local, non-biometry keychain session item that the broker **rehydrates on
73
+ start** and that reads **fall back to** silently. Split default: it survives
74
+ upgrade/restart automatically; pass `--durable` (or set `secrets.agent.durable:
75
+ true`) to also survive sleep/reboot — otherwise a bundle re-locks on sleep as
76
+ before. `lock` / rotate / delete clear it. On Linux and Windows `unlock` is now a
77
+ friendly no-op (secrets already resolve durably from the OS store with no
78
+ prompt), so the command behaves the same on all three platforms. Source:
79
+ `apps/cli/src/lib/secrets/session-store.ts` (new),
80
+ `apps/cli/src/lib/secrets/agent.ts`, `apps/cli/src/lib/secrets/bundles.ts`,
81
+ `apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/commands/secrets.ts`,
82
+ `apps/cli/src/lib/types.ts`.
83
+
84
+ - **`agents share` cover capture finds Playwright's `chrome-headless-shell` packages.** `scanCaches()` only knew the classic `chrome-mac/Chromium.app` and `Google Chrome for Testing` layouts, so on machines whose Playwright cache holds only the newer `chromium_headless_shell-*` packages (a raw `chrome-headless-shell` binary, not an `.app` bundle) — and no system Chrome/Brave/Edge — the OG cover capture silently returned null and shared plans published without a preview card. The scan now matches `chrome-headless-shell-mac-arm64`, `chrome-headless-shell-mac-x64`, and `chrome-headless-shell-linux64` layouts alongside the existing ones. Source: `apps/cli/src/lib/share/capture.ts`, `apps/cli/src/lib/share/capture.test.ts`.
85
+
86
+ - **New `agents uninstall` — cleanly reverse adoption and restore your original setup.** Installing agents-cli *adopts* your agent config: it moves `~/.<agent>` aside and replaces it with a symlink into the version homes, adopts the launcher on `PATH`, and adds the shim dir to your shell rc — but until now nothing put any of that back, so removing the CLI stranded your original config under `~/.agents/.history/backups/` and left `~/.claude` a dangling symlink. `agents uninstall` is the reverse of `agents setup`: it restores every adopted `~/.<agent>` (from the timestamped backup, or the version home for imported installs), restores owned home files, releases adopted launchers, strips the shim dir from every shell rc, then disposes of `~/.agents` — moved aside to `~/.agents.removed-<ts>` (recoverable) by default, or hard-deleted with `--purge`. A config agents-cli never adopted is never touched (ownership is decided structurally by `getConfigSymlinkVersion`, the same check `removeVersion` uses); `--dry-run` prints the full plan without changing anything; and if any restore step errors, `--purge` self-downgrades to the recoverable move-aside so a swallowed error can never take your only copy. Works on macOS, Linux, and Windows (junctions and cross-volume `~/.agents` handled). Source: `apps/cli/src/lib/uninstall.ts`, `apps/cli/src/commands/uninstall.ts`.
87
+
3
88
  ## 1.20.70
4
89
 
5
90
  - **Fix `agents setup computer` / `agents computer setup` refusing to install a
package/dist/bin/agents CHANGED
Binary file
@@ -15,6 +15,7 @@ import chalk from 'chalk';
15
15
  import { setHelpSections } from '../lib/help.js';
16
16
  import { machineId } from '../lib/session/sync/config.js';
17
17
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
18
+ import { ensureDevicesRegistered } from '../lib/devices/sync.js';
18
19
  import { readFleetFile, resolveDesired } from '../lib/fleet/manifest.js';
19
20
  import { snapshotAuth, materializeAuth, parseAuthBundle, KEYCHAIN_BOUND_ON_MAC } from '../lib/fleet/auth-sync.js';
20
21
  import { agentIdOf, diffFleet, probeDevice, runFleetApply, pool, sourceHome, } from '../lib/fleet/apply.js';
@@ -121,6 +122,15 @@ function renderPlan(plan) {
121
122
  if (noToken.length > 0) {
122
123
  console.log(chalk.yellow(` manual login needed (no portable token on source): ${noToken.join(', ')}`));
123
124
  }
125
+ // Secrets bundles are declared once for the fleet; surface the distinct set to
126
+ // recreate on any device missing them (values are keychain-local, never pushed).
127
+ const bundles = [...new Set(rows.flatMap((r) => r.secretsNeeded))];
128
+ if (bundles.length > 0) {
129
+ const shown = bundles.slice(0, 12);
130
+ const more = bundles.length - shown.length;
131
+ const list = shown.join(', ') + (more > 0 ? `, +${more} more` : '');
132
+ console.log(chalk.yellow(` ${bundles.length} secrets bundle(s) to recreate where missing (keychain-local, never pushed): ${list}`));
133
+ }
124
134
  }
125
135
  /** padEnd on the visible width, ignoring chalk color codes. Exported for tests. */
126
136
  export function stripPad(s, width) {
@@ -135,6 +145,24 @@ async function runApply(opts) {
135
145
  const file = opts.file ?? 'agents.yaml';
136
146
  const manifest = readFleetFile(path.resolve(file));
137
147
  const source = machineId();
148
+ // Fresh-machine bootstrap: an explicit `devices:` map may name boxes this
149
+ // machine has never registered (e.g. a freshly-cloned agents.yaml). Resolve
150
+ // those names live from Tailscale and register them BEFORE resolveDesired
151
+ // validates the roster — so replication needs only the repo, never committed
152
+ // IPs/usernames. `devices: all` needs no bootstrap (it targets what's already
153
+ // registered + online).
154
+ let unresolved = [];
155
+ if (manifest.devices !== 'all') {
156
+ const wanted = Object.keys(manifest.devices).filter((n) => n !== source);
157
+ const boot = await ensureDevicesRegistered(wanted);
158
+ unresolved = boot.unresolved;
159
+ if (boot.registered.length > 0) {
160
+ console.log(chalk.gray(`Registered ${boot.registered.length} device(s) from Tailscale: ${boot.registered.join(', ')}`));
161
+ }
162
+ if (boot.unresolved.length > 0) {
163
+ console.log(chalk.yellow(`Not resolvable on Tailscale — skipped, reconcile continues for the rest: ${boot.unresolved.join(', ')}`));
164
+ }
165
+ }
138
166
  const registry = await loadDevices();
139
167
  // Control devices (a cockpit) never run agents — exclude them from the
140
168
  // reconcile set entirely so `agents apply` doesn't try to install/sync/login
@@ -142,7 +170,9 @@ async function runApply(opts) {
142
170
  const all = Object.values(registry).filter((d) => !isControlDevice(d));
143
171
  const online = all.filter((d) => d.tailscale?.online === true).map((d) => d.name);
144
172
  const registered = all.map((d) => d.name);
145
- let desired = resolveDesired(manifest, { onlineDevices: online, registeredDevices: registered, source });
173
+ // Unresolved names are skipped (surfaced above), never fatal a manifest
174
+ // naming an asleep box must not abort the reconcile for every other device.
175
+ let desired = resolveDesired(manifest, { onlineDevices: online, registeredDevices: registered, source, unresolved });
146
176
  if (opts.device) {
147
177
  desired = desired.filter((d) => d.device === opts.device);
148
178
  if (desired.length === 0)
@@ -174,7 +204,7 @@ async function runApply(opts) {
174
204
  const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device)));
175
205
  const probes = new Map(probeList.map((p) => [p.device, p]));
176
206
  const targetCliVersion = localCliVersion();
177
- let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth });
207
+ let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth, secretsBundles: manifest.secrets?.bundles });
178
208
  // --only filter.
179
209
  if (opts.only) {
180
210
  const keep = new Set();
@@ -192,7 +222,10 @@ async function runApply(opts) {
192
222
  const isDry = opts.plan || opts.dryRun;
193
223
  if (isDry)
194
224
  return;
195
- if (plan.actions.filter((a) => a.kind !== 'needs-login').length === 0) {
225
+ // `needs-login`/`needs-secret` are surfaced manual reminders, not executable
226
+ // mutations — exclude them so an otherwise-converged fleet still says "nothing
227
+ // to do" instead of looping forever on un-actionable surfacing.
228
+ if (plan.actions.filter((a) => a.kind !== 'needs-login' && a.kind !== 'needs-secret').length === 0) {
196
229
  console.log(chalk.green('\nNothing to do — fleet already matches the profile.'));
197
230
  return;
198
231
  }
@@ -4,6 +4,7 @@ import { setHelpSections } from '../lib/help.js';
4
4
  import { computeDrift } from '../lib/drift.js';
5
5
  import { loadDevices } from '../lib/devices/registry.js';
6
6
  import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
+ import { fleetDialTarget } from '../lib/devices/connect.js';
7
8
  import { machineId } from '../lib/session/sync/config.js';
8
9
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
9
10
  import { sshExecAsync } from '../lib/ssh-exec.js';
@@ -107,7 +108,7 @@ function checkPayload(device, drift) {
107
108
  async function probeDeviceCheck(target) {
108
109
  const isWin = /^win/i.test((target.platform ?? '').trim());
109
110
  const remoteCmd = buildRemoteAgentsInvocation(['check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
110
- const res = await sshExecAsync(target.name, remoteCmd, { timeoutMs: 30000, multiplex: true });
111
+ const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
111
112
  if (res.code !== 0 && !res.stdout.trim()) {
112
113
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
113
114
  }
@@ -135,6 +136,7 @@ async function runDevicesCheck(opts, cwd) {
135
136
  name: t.device.name,
136
137
  platform: t.device.platform,
137
138
  skip: t.skip,
139
+ dialTarget: fleetDialTarget(t.device),
138
140
  }));
139
141
  const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
140
142
  const devices = [local];
@@ -43,6 +43,7 @@ When to use:
43
43
  `);
44
44
  commandsCmd
45
45
  .command('list [agent]')
46
+ .option('--json', 'Emit machine-readable JSON instead of the table/picker')
46
47
  .description('Show which slash commands are installed and which agent versions they are synced to')
47
48
  .option('-a, --agent <agent>', 'Filter to a specific agent (alternative to positional arg)')
48
49
  .action(async (agentArg, options) => {
@@ -73,6 +74,7 @@ When to use:
73
74
  centralPath: getCommandsDir(),
74
75
  filterAgent,
75
76
  filterVersion,
77
+ json: options.json,
76
78
  });
77
79
  });
78
80
  commandsCmd
@@ -8,6 +8,7 @@
8
8
  import { Option } from 'commander';
9
9
  import chalk from 'chalk';
10
10
  import { setHelpSections } from '../lib/help.js';
11
+ import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
11
12
  import { parseLoopInterval } from '../lib/loop.js';
12
13
  import { AGENTS } from '../lib/agents.js';
13
14
  import { recordDispatchedRun } from '../lib/audit/log.js';
@@ -703,6 +704,17 @@ export function registerRunCommand(program) {
703
704
  `Make sure the host is reachable over SSH, then re-run with --copy-creds.`));
704
705
  process.exit(1);
705
706
  }
707
+ // --copy-creds ships live credentials to a host, so it deliberately
708
+ // requires an interactive terminal to pick which signed-in runtimes to
709
+ // send and to confirm the transfer. Fail clean in a non-TTY/headless
710
+ // shell instead of hanging on the picker/confirm below — auto-shipping
711
+ // every signed-in token without consent would defeat the gate's purpose.
712
+ if (!isInteractiveTerminal()) {
713
+ // No non-interactive form: --copy-creds must pick which signed-in
714
+ // runtimes to ship and confirm the transfer, so pass no alternatives
715
+ // (an empty list prints just the "needs an interactive terminal" line).
716
+ requireInteractiveSelection('--copy-creds (it selects which credentials to ship and confirms the transfer)', []);
717
+ }
706
718
  const { detectSignedInRuntimes, pickRuntimes, resolveClaudeCredentialsBlob } = await import('../lib/crabbox/runtimes.js');
707
719
  const { confirm } = await import('@inquirer/prompts');
708
720
  const detected = await detectSignedInRuntimes();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents fleet capture` (alias surface of `agents devices capture`) — snapshot
3
+ * the live environment into the `fleet:` block of `agents.yaml` so a fresh
4
+ * machine can reconstruct it with `agents apply`.
5
+ *
6
+ * Local-read + local-write, zero SSH. Records device NAMES only (never IPs or
7
+ * usernames — those are re-resolved live from Tailscale at apply-time), plus the
8
+ * source's own agents as defaults, browser profiles, secrets-bundle names, and
9
+ * routine names. The heavy lifting is the pure `captureFleet` builder; this
10
+ * command just gathers inputs and persists via `updateMeta`.
11
+ */
12
+ import { Command } from 'commander';
13
+ /** Attach `capture` to the `devices`/`fleet` command tree. */
14
+ export declare function registerFleetCaptureCommand(devicesCmd: Command): void;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `agents fleet capture` (alias surface of `agents devices capture`) — snapshot
3
+ * the live environment into the `fleet:` block of `agents.yaml` so a fresh
4
+ * machine can reconstruct it with `agents apply`.
5
+ *
6
+ * Local-read + local-write, zero SSH. Records device NAMES only (never IPs or
7
+ * usernames — those are re-resolved live from Tailscale at apply-time), plus the
8
+ * source's own agents as defaults, browser profiles, secrets-bundle names, and
9
+ * routine names. The heavy lifting is the pure `captureFleet` builder; this
10
+ * command just gathers inputs and persists via `updateMeta`.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import chalk from 'chalk';
15
+ import * as yaml from 'yaml';
16
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
17
+ import { readMeta, updateMeta, getDeviceMetaPath } from '../lib/state.js';
18
+ import { listBundles } from '../lib/secrets/bundles.js';
19
+ import { listJobs } from '../lib/routines.js';
20
+ import { captureFleet } from '../lib/fleet/capture.js';
21
+ /** The committed per-device pin dir: `~/.agents/devices/`. Derived from the
22
+ * device-meta path so it honors any AGENTS_HOME override the same way. */
23
+ function devicesPinDir() {
24
+ return path.dirname(path.dirname(getDeviceMetaPath()));
25
+ }
26
+ /** Read `devices/<name>/agents.yaml` pin files into `name -> [id@latest]`. Only
27
+ * used with `--from-pins`; keeps capture zero-SSH by reading committed state. */
28
+ function agentsFromPins(names) {
29
+ const dir = devicesPinDir();
30
+ const out = {};
31
+ for (const name of names) {
32
+ const p = path.join(dir, name, 'agents.yaml');
33
+ if (!fs.existsSync(p))
34
+ continue;
35
+ try {
36
+ const doc = yaml.parse(fs.readFileSync(p, 'utf-8'));
37
+ const ids = doc?.agents ? Object.keys(doc.agents) : [];
38
+ if (ids.length > 0)
39
+ out[name] = ids.map((id) => `${id}@latest`);
40
+ }
41
+ catch {
42
+ /* skip an unparsable pin file — capture is best-effort per device */
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ async function runCapture(opts) {
48
+ const meta = readMeta();
49
+ // Roster: registered, non-control device names only.
50
+ const registry = await loadDevices();
51
+ let names = Object.values(registry)
52
+ .filter((d) => !isControlDevice(d))
53
+ .map((d) => d.name)
54
+ .sort();
55
+ if (opts.device) {
56
+ names = names.filter((n) => n === opts.device);
57
+ if (names.length === 0)
58
+ throw new Error(`Device '${opts.device}' is not a registered device.`);
59
+ }
60
+ // Defaults seeded from the source machine's own installed agents.
61
+ const sourceAgents = Object.keys(meta.agents ?? {}).sort();
62
+ const defaults = {
63
+ agents: sourceAgents.map((id) => `${id}@latest`),
64
+ sync: ['user'],
65
+ login: 'sync',
66
+ };
67
+ const inputs = {
68
+ devices: names,
69
+ defaults,
70
+ agentsByDevice: opts.fromPins ? agentsFromPins(names) : undefined,
71
+ // Browser profiles are intentionally NOT captured — the central `browser:`
72
+ // block already syncs via the repo, and its ssh:// endpoints can carry
73
+ // `user@host`, which must never be copied into the fleet: block.
74
+ secretsBundles: listBundles().map((b) => b.name),
75
+ routines: listJobs().map((j) => j.name),
76
+ };
77
+ const next = captureFleet(meta.fleet, inputs);
78
+ if (opts.dryRun) {
79
+ console.log(chalk.gray('# agents.yaml fleet: block (dry run — not written)'));
80
+ console.log(yaml.stringify({ fleet: next }).trimEnd());
81
+ return;
82
+ }
83
+ updateMeta((m) => ({ ...m, fleet: next }));
84
+ const deviceCount = Object.keys(next.devices === 'all' ? {} : next.devices).length;
85
+ console.log(chalk.green('Captured fleet profile') +
86
+ chalk.gray(` — ${deviceCount} device(s), ${defaults.agents?.length ?? 0} agent(s), ` +
87
+ `${inputs.secretsBundles?.length ?? 0} secret bundle(s), ${inputs.routines?.length ?? 0} routine(s).`));
88
+ console.log(chalk.gray(' Wrote agents.yaml → fleet:. Push it (`agents repo push`) and run `agents apply` on any machine.'));
89
+ }
90
+ /** Attach `capture` to the `devices`/`fleet` command tree. */
91
+ export function registerFleetCaptureCommand(devicesCmd) {
92
+ devicesCmd
93
+ .command('capture')
94
+ .description('Snapshot the live environment (roster names, agents, browser, secret-bundle names, routines) into agents.yaml fleet:.')
95
+ .option('--dry-run', 'print the fleet: block that would be written, and exit')
96
+ .option('--from-pins', 'record per-device agents from committed devices/<name>/agents.yaml')
97
+ .option('--device <name>', 'capture a single device')
98
+ .action(async (opts) => {
99
+ try {
100
+ await runCapture(opts);
101
+ }
102
+ catch (e) {
103
+ console.error(chalk.red(e.message));
104
+ process.exit(1);
105
+ }
106
+ });
107
+ }
@@ -34,21 +34,26 @@ export function registerForkCommand(program) {
34
34
  matches = findSessionsById(sessionArg, {});
35
35
  }
36
36
  if (matches.length === 0) {
37
- console.log(chalk.red(`No session matching "${sessionArg}".`));
38
- console.log(chalk.gray('List candidates with: agents sessions'));
37
+ // Errors go to stderr and set a non-zero exit code so a script chaining on
38
+ // `agents fork <id> && agents resume <new>` doesn't proceed on a failed fork.
39
+ console.error(chalk.red(`No session matching "${sessionArg}".`));
40
+ console.error(chalk.gray('List candidates with: agents sessions'));
41
+ process.exitCode = 1;
39
42
  return;
40
43
  }
41
44
  if (matches.length > 1) {
42
- console.log(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
45
+ console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
43
46
  for (const m of matches.slice(0, 8)) {
44
- console.log(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
47
+ console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
45
48
  }
49
+ process.exitCode = 1;
46
50
  return;
47
51
  }
48
52
  const source = matches[0];
49
53
  if (!isForkableAgent(source.agent)) {
50
- console.log(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
51
- console.log(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
54
+ console.error(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
55
+ console.error(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
56
+ process.exitCode = 1;
52
57
  return;
53
58
  }
54
59
  let result;
@@ -56,7 +61,8 @@ export function registerForkCommand(program) {
56
61
  result = forkSession(source, { name: options.name });
57
62
  }
58
63
  catch (err) {
59
- console.log(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
64
+ console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
65
+ process.exitCode = 1;
60
66
  return;
61
67
  }
62
68
  console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
@@ -9,6 +9,7 @@
9
9
  import chalk from 'chalk';
10
10
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
11
11
  import { checkbox, confirm } from '@inquirer/prompts';
12
+ import { isInteractiveTerminal } from './utils.js';
12
13
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
13
14
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
14
15
  import { getDevice } from '../lib/devices/registry.js';
@@ -42,6 +43,12 @@ async function maybeBootstrap(target, hostName, probe = probeHost(target, resolv
42
43
  const remoteVer = remoteAgentsVersion(target, os);
43
44
  const localVer = localCliVersion();
44
45
  if (!remoteVer) {
46
+ // Non-TTY: this is a best-effort bootstrap prompt, so report and return
47
+ // instead of hanging on confirm() in a headless/piped shell.
48
+ if (!isInteractiveTerminal()) {
49
+ console.log(chalk.gray(` agents-cli not found on ${hostName} — install it there, then: agents hosts check ${hostName}`));
50
+ return;
51
+ }
45
52
  const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
46
53
  if (ok) {
47
54
  console.log(chalk.gray(' Installing agents-cli on the host…'));
@@ -52,6 +59,10 @@ async function maybeBootstrap(target, hostName, probe = probeHost(target, resolv
52
59
  }
53
60
  const remoteClean = remoteVer.replace(/^v/, '');
54
61
  if (localVer && remoteClean !== localVer) {
62
+ if (!isInteractiveTerminal()) {
63
+ console.log(chalk.gray(` ${hostName} has agents-cli ${remoteClean}, you have ${localVer} — versions differ; upgrade the host to match when convenient.`));
64
+ return;
65
+ }
55
66
  const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
56
67
  if (ok) {
57
68
  const r = bootstrapAgentsCli(target, localVer, os);
@@ -23,9 +23,9 @@
23
23
  import chalk from 'chalk';
24
24
  import * as fs from 'fs';
25
25
  import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
26
- import { parseAgentFilter, renderSessionLog } from './sessions.js';
26
+ import { parseAgentFilter, renderSessionLog, renderSessionLogJson } from './sessions.js';
27
27
  import { streamSessionTail, isTailable } from './sessions-tail.js';
28
- import { showHostTaskLog } from '../lib/hosts/logs.js';
28
+ import { showHostTaskLog, hostTaskLogJson } from '../lib/hosts/logs.js';
29
29
  import { listTasks } from '../lib/hosts/tasks.js';
30
30
  import { itemPicker } from '../lib/picker.js';
31
31
  import { query, stats, getLogsPath, rotate, levelFor, } from '../lib/events.js';
@@ -43,8 +43,20 @@ function candidateLabel(c) {
43
43
  const title = s.label || s.topic || '';
44
44
  return `${chalk.gray('sess')} ${s.shortId.padEnd(9)} ${(s.agent + ver).padEnd(14)} ${chalk.gray(s.timestamp.slice(0, 16))} ${title.slice(0, 40)}`;
45
45
  }
46
+ /** Emit a host-dispatch task's log as JSON: `{ kind, task, log }`. */
47
+ function emitHostTaskJson(id) {
48
+ const hj = hostTaskLogJson(id);
49
+ if (!hj.found)
50
+ return false;
51
+ process.stdout.write(JSON.stringify({ kind: 'task', task: hj.task, log: hj.log ?? null }, null, 2) + '\n');
52
+ return true;
53
+ }
46
54
  /** Show a resolved session — follow (tail), concise summary, or (`full`) transcript. */
47
- async function showSession(session, follow, full) {
55
+ async function showSession(session, follow, full, json = false) {
56
+ if (json) {
57
+ await renderSessionLogJson(session);
58
+ return;
59
+ }
48
60
  if (follow) {
49
61
  if (!isTailable(session.agent)) {
50
62
  console.error(chalk.red(`Tailing is supported for claude and codex sessions only (got ${session.agent}).`));
@@ -55,17 +67,33 @@ async function showSession(session, follow, full) {
55
67
  }
56
68
  await renderSessionLog(session, full ? 'markdown' : 'summary');
57
69
  }
58
- async function showCandidate(c, follow, full) {
70
+ async function showCandidate(c, follow, full, json = false) {
59
71
  if (c.kind === 'task') {
72
+ if (json) {
73
+ emitHostTaskJson(c.task.id);
74
+ return;
75
+ }
60
76
  const res = await showHostTaskLog(c.task.id, follow, full);
61
77
  if (res.exitCode !== undefined)
62
78
  process.exitCode = res.exitCode;
63
79
  return;
64
80
  }
65
- await showSession(c.session, follow, full);
81
+ await showSession(c.session, follow, full, json);
66
82
  }
67
83
  /** Resolve an explicit id/--session: host task first, then a session. */
68
- async function showById(id, follow, full) {
84
+ async function showById(id, follow, full, json = false) {
85
+ if (json) {
86
+ if (emitHostTaskJson(id))
87
+ return;
88
+ const sessions = await discoverSessions({ all: true, limit: 5000 });
89
+ const matches = resolveSessionById(sessions, id);
90
+ if (matches.length === 0) {
91
+ console.error(chalk.red(`No run or session found matching "${id}".`));
92
+ process.exit(1);
93
+ }
94
+ await renderSessionLogJson(matches[0]);
95
+ return;
96
+ }
69
97
  const hostRes = await showHostTaskLog(id, follow, full);
70
98
  if (hostRes.found) {
71
99
  if (hostRes.exitCode !== undefined)
@@ -85,7 +113,7 @@ async function runLogs(id, opts) {
85
113
  const full = !!opts.full;
86
114
  const directId = opts.session ?? id;
87
115
  if (directId) {
88
- await showById(directId, follow, full);
116
+ await showById(directId, follow, full, !!opts.json);
89
117
  return;
90
118
  }
91
119
  const { agent, version } = parseAgentFilter(opts.agent);
@@ -110,7 +138,7 @@ async function runLogs(id, opts) {
110
138
  process.exit(1);
111
139
  }
112
140
  if (candidates.length === 1) {
113
- await showCandidate(candidates[0], follow, full);
141
+ await showCandidate(candidates[0], follow, full, !!opts.json);
114
142
  return;
115
143
  }
116
144
  // Multiple sessions matched → picker if interactive, else a list to pick from.
@@ -134,7 +162,7 @@ async function runLogs(id, opts) {
134
162
  });
135
163
  if (!picked)
136
164
  return;
137
- await showCandidate(picked.item, follow, full);
165
+ await showCandidate(picked.item, follow, full, !!opts.json);
138
166
  }
139
167
  function parseSince(s) {
140
168
  const m = s.match(/^(\d+)([smhdw])$/);
@@ -343,6 +371,7 @@ export function registerLogsCommand(program) {
343
371
  .option('--session <id>', 'Select a session/run by id (same as the positional id)')
344
372
  .option('-f, --follow', 'Follow live output')
345
373
  .option('-m, --full', 'Show the full raw transcript / stdout instead of the concise summary')
374
+ .option('--json', 'Machine-readable JSON: a host task as { kind, task, log }, a session as the redacted { session, events } (same shape as `sessions <id> --json`)')
346
375
  .action((id, opts) => runLogs(id, opts));
347
376
  logsCmd
348
377
  .command('audit')
@@ -147,6 +147,7 @@ When to use:
147
147
  .command('list [agent]')
148
148
  .description('Show which MCP servers are registered and which agent versions they are synced to')
149
149
  .option('-a, --agent <agent>', 'Filter to a specific agent (alternative to positional arg)')
150
+ .option('--json', 'Emit machine-readable JSON instead of the table/picker')
150
151
  .action(async (agentArg, options) => {
151
152
  const spinner = ora({ text: 'Loading...', isSilent: !process.stdout.isTTY }).start();
152
153
  const agentInput = agentArg || options.agent;
@@ -176,6 +177,7 @@ When to use:
176
177
  centralPath: getMcpDir(),
177
178
  filterAgent,
178
179
  filterVersion,
180
+ json: options.json,
179
181
  });
180
182
  });
181
183
  mcpCmd
@@ -33,6 +33,8 @@ export interface ResourceViewOptions {
33
33
  /** When the user specified agent or agent@version, we scope per-agent. */
34
34
  filterAgent?: AgentId;
35
35
  filterVersion?: string;
36
+ /** Emit machine-readable JSON instead of the picker/table (for agents/scripts). */
37
+ json?: boolean;
36
38
  }
37
39
  /** Display a resource list: interactive picker in TTY mode, plain table otherwise. */
38
40
  export declare function showResourceList(opts: ResourceViewOptions): Promise<void>;
@@ -14,6 +14,14 @@ import { isInteractiveTerminal, isPromptCancelled, printWithPager } from './util
14
14
  import { terminalWidth, truncateToWidth, padToWidth, stringWidth, stripAnsi } from '../lib/session/width.js';
15
15
  /** Display a resource list: interactive picker in TTY mode, plain table otherwise. */
16
16
  export async function showResourceList(opts) {
17
+ if (opts.json) {
18
+ // Strip the non-serializable buildDetail thunk; emit the row metadata plus
19
+ // each resource's per-agent-version sync targets. One shared JSON contract
20
+ // for every resource `list` built on this helper.
21
+ const rows = opts.rows.map(({ buildDetail, ...row }) => row);
22
+ console.log(JSON.stringify(rows, null, 2));
23
+ return;
24
+ }
17
25
  if (opts.rows.length === 0) {
18
26
  console.log(chalk.gray(opts.emptyMessage));
19
27
  return;
@@ -49,6 +49,7 @@ export declare function parseImportSource(opts: {
49
49
  export declare function buildRemoteUnlockArgs(names: string[], opts: {
50
50
  all?: boolean;
51
51
  ttl?: string;
52
+ durable?: boolean;
52
53
  }): string[];
53
54
  export { SSH_TARGET_RE, assertValidSshTarget };
54
55
  /**