@phnx-labs/agents-cli 1.20.70 → 1.20.71
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.
- package/CHANGELOG.md +60 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/exec.js +12 -0
- package/dist/commands/fleet-capture.d.ts +14 -0
- package/dist/commands/fleet-capture.js +107 -0
- package/dist/commands/fork.js +13 -7
- package/dist/commands/hosts.js +11 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.js +6 -6
- package/dist/commands/ssh.js +24 -6
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/devices/connect.d.ts +13 -0
- package/dist/lib/devices/connect.js +20 -0
- package/dist/lib/devices/fleet.d.ts +36 -3
- package/dist/lib/devices/fleet.js +42 -4
- package/dist/lib/devices/sync.d.ts +30 -0
- package/dist/lib/devices/sync.js +50 -0
- package/dist/lib/doctor-diff.js +38 -16
- package/dist/lib/fleet/apply.d.ts +4 -0
- package/dist/lib/fleet/apply.js +28 -5
- package/dist/lib/fleet/capture.d.ts +35 -0
- package/dist/lib/fleet/capture.js +51 -0
- package/dist/lib/fleet/manifest.d.ts +6 -0
- package/dist/lib/fleet/manifest.js +24 -1
- package/dist/lib/fleet/types.d.ts +24 -1
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +39 -1
- package/dist/lib/menubar/install-menubar.d.ts +12 -0
- package/dist/lib/menubar/install-menubar.js +26 -13
- package/dist/lib/secrets/agent.d.ts +5 -0
- package/dist/lib/secrets/agent.js +35 -3
- package/dist/lib/secrets/bundles.js +28 -0
- package/dist/lib/secrets/index.d.ts +14 -0
- package/dist/lib/secrets/index.js +36 -5
- package/dist/lib/secrets/session-store.d.ts +90 -0
- package/dist/lib/secrets/session-store.js +221 -0
- package/dist/lib/session/render.d.ts +9 -1
- package/dist/lib/session/render.js +35 -4
- package/dist/lib/share/capture.d.ts +2 -0
- package/dist/lib/share/capture.js +12 -5
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/types.d.ts +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.71
|
|
4
|
+
|
|
5
|
+
- **Capture your whole fleet into `agents.yaml`, then rebuild it anywhere with
|
|
6
|
+
`agents apply` (#1305).** New `agents fleet capture` (alias `agents devices
|
|
7
|
+
capture`) snapshots the live environment into the portable `fleet:` block — the
|
|
8
|
+
device roster (**names only**), the source's agents as `defaults`, secrets-bundle
|
|
9
|
+
**names**, and routine **names**. It commits **zero** Tailscale IPs or usernames:
|
|
10
|
+
`agents apply` reconstructs a fresh machine's roster by resolving each device
|
|
11
|
+
name **live from Tailscale** (`ensureDevicesRegistered`), so `git clone` +
|
|
12
|
+
`agents apply` replicates the fleet with nothing sensitive in the repo. `apply`
|
|
13
|
+
now also passes declared `sync:` scopes through to `agents sync <scope>`
|
|
14
|
+
(previously a bare `sync`) and surfaces declared secrets-bundle names to recreate
|
|
15
|
+
on each device (values stay keychain-local, never pushed). Browser profiles are
|
|
16
|
+
intentionally not duplicated into `fleet:` — they already sync via the central
|
|
17
|
+
`browser:` block. Source: `apps/cli/src/commands/fleet-capture.ts`,
|
|
18
|
+
`apps/cli/src/lib/fleet/capture.ts`, `apps/cli/src/lib/devices/sync.ts`,
|
|
19
|
+
`apps/cli/src/lib/fleet/{types,manifest,apply}.ts`.
|
|
20
|
+
|
|
21
|
+
- **`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`.
|
|
22
|
+
|
|
23
|
+
- **Fix the macOS menu-bar auto-heal so upgrades actually restart the helper.**
|
|
24
|
+
`agents` has an on-startup self-heal that re-copies `MenubarHelper.app` when
|
|
25
|
+
the CLI version changes, but on modern macOS `launchctl bootstrap` fails when
|
|
26
|
+
the job is already bootstrapped, and the deprecated `launchctl load -w`
|
|
27
|
+
fallback plus `kickstart -k` did not recover a job that launchd had stopped
|
|
28
|
+
respawning after a `WindowServer event port death`. The helper would stay
|
|
29
|
+
updated on disk but invisible in the menu bar. `enableMenubarService` now
|
|
30
|
+
boots the old job out, bootstraps the fresh plist, and kickstarts it — the
|
|
31
|
+
same sequence that reliably restores the icon by hand. Source:
|
|
32
|
+
`apps/cli/src/lib/menubar/install-menubar.ts`,
|
|
33
|
+
`apps/cli/src/lib/menubar/install-menubar.test.ts`.
|
|
34
|
+
|
|
35
|
+
- **`agents repo pull` no longer wedges on per-machine pin drift.** The committed
|
|
36
|
+
`devices/<machineId>/agents.yaml` (each box's agent version pins) is rewritten
|
|
37
|
+
whenever a pin changes, leaving the working tree perpetually dirty — so
|
|
38
|
+
`agents repo pull`, which refuses a dirty tree, kept failing until the file was
|
|
39
|
+
hand-committed. `pullRepo` now durably commits **just that one path** (explicit
|
|
40
|
+
pathspec) before pulling, via `commitOwnDeviceMeta`. Genuine uncommitted edits to
|
|
41
|
+
any other file still (correctly) block the pull. No-op for the system/extra repos
|
|
42
|
+
that don't own the path. Source: `apps/cli/src/lib/git.ts`.
|
|
43
|
+
|
|
44
|
+
- **`agents secrets unlock` now stays unlocked across an agents-cli upgrade (and,
|
|
45
|
+
with `--durable`, across sleep + reboot).** The macOS secrets broker held an
|
|
46
|
+
unlock only in RAM, so it evaporated every time the daemon restarted (upgrade)
|
|
47
|
+
or the machine slept — forcing a Touch ID re-tap and breaking headless reads
|
|
48
|
+
with "not unlocked in the secrets agent". An unlock now also persists a
|
|
49
|
+
device-local, non-biometry keychain session item that the broker **rehydrates on
|
|
50
|
+
start** and that reads **fall back to** silently. Split default: it survives
|
|
51
|
+
upgrade/restart automatically; pass `--durable` (or set `secrets.agent.durable:
|
|
52
|
+
true`) to also survive sleep/reboot — otherwise a bundle re-locks on sleep as
|
|
53
|
+
before. `lock` / rotate / delete clear it. On Linux and Windows `unlock` is now a
|
|
54
|
+
friendly no-op (secrets already resolve durably from the OS store with no
|
|
55
|
+
prompt), so the command behaves the same on all three platforms. Source:
|
|
56
|
+
`apps/cli/src/lib/secrets/session-store.ts` (new),
|
|
57
|
+
`apps/cli/src/lib/secrets/agent.ts`, `apps/cli/src/lib/secrets/bundles.ts`,
|
|
58
|
+
`apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/commands/secrets.ts`,
|
|
59
|
+
`apps/cli/src/lib/types.ts`.
|
|
60
|
+
|
|
61
|
+
- **`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`.
|
|
62
|
+
|
|
3
63
|
## 1.20.70
|
|
4
64
|
|
|
5
65
|
- **Fix `agents setup computer` / `agents computer setup` refusing to install a
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/check.js
CHANGED
|
@@ -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.
|
|
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];
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/fork.js
CHANGED
|
@@ -34,21 +34,26 @@ export function registerForkCommand(program) {
|
|
|
34
34
|
matches = findSessionsById(sessionArg, {});
|
|
35
35
|
}
|
|
36
36
|
if (matches.length === 0) {
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
-
console.
|
|
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.
|
|
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}`));
|
package/dist/commands/hosts.js
CHANGED
|
@@ -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);
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
-
|
|
1884
|
-
|
|
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
|
|
@@ -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,
|
|
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,
|
|
1071
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1072
1072
|
return;
|
|
1073
1073
|
}
|
|
1074
1074
|
}
|
|
@@ -1475,7 +1475,7 @@ async function renderSession(session, mode, filters, options = {}) {
|
|
|
1475
1475
|
chalk.gray(` ${formatRelativeTime(session.timestamp)}`) +
|
|
1476
1476
|
(session.account ? chalk.gray(` (${session.account})`) : ''));
|
|
1477
1477
|
console.log(chalk.gray('─'.repeat(60)));
|
|
1478
|
-
process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.
|
|
1478
|
+
process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.redact !== false })));
|
|
1479
1479
|
return;
|
|
1480
1480
|
}
|
|
1481
1481
|
// json — normalized events plus the durable session signals from the state
|
|
@@ -1486,7 +1486,7 @@ async function renderSession(session, mode, filters, options = {}) {
|
|
|
1486
1486
|
// checklist reflects true session state regardless of any `--include` filter;
|
|
1487
1487
|
// it lets the Factory panel read the CLI's checklist instead of re-parsing.
|
|
1488
1488
|
const todos = inferSessionState(parsedEvents, { cwd: session.cwd }).todos;
|
|
1489
|
-
process.stdout.write(renderJson(events, todos ? { ...session, todos } : session));
|
|
1489
|
+
process.stdout.write(renderJson(events, todos ? { ...session, todos } : session, { redact: options.redact !== false }));
|
|
1490
1490
|
}
|
|
1491
1491
|
function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
|
|
1492
1492
|
const lbl = (label ?? '').trim();
|
|
@@ -2162,7 +2162,7 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2162
2162
|
throw new Error('Session resolution failed');
|
|
2163
2163
|
}
|
|
2164
2164
|
spinner.stop();
|
|
2165
|
-
await renderSession(session, mode, scope.filter, {
|
|
2165
|
+
await renderSession(session, mode, scope.filter, { redact: scope.redact });
|
|
2166
2166
|
}
|
|
2167
2167
|
catch (err) {
|
|
2168
2168
|
if (isPromptCancelled(err))
|
|
@@ -2195,7 +2195,7 @@ export function registerSessionsCommands(program) {
|
|
|
2195
2195
|
.option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
|
|
2196
2196
|
.option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
|
|
2197
2197
|
.option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
|
|
2198
|
-
.option('--no-redact', 'Disable default secret redaction in
|
|
2198
|
+
.option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
|
|
2199
2199
|
.option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
|
|
2200
2200
|
.option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
|
|
2201
2201
|
.option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
|