@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.
- package/CHANGELOG.md +85 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/commands.js +2 -0
- 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/logs.js +38 -9
- package/dist/commands/mcp.js +2 -0
- package/dist/commands/resource-view.d.ts +2 -0
- package/dist/commands/resource-view.js +8 -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.d.ts +6 -0
- package/dist/commands/sessions.js +14 -6
- package/dist/commands/skills.js +2 -0
- package/dist/commands/ssh.js +24 -6
- package/dist/commands/status.js +8 -2
- package/dist/commands/subagents.js +3 -1
- package/dist/commands/uninstall.d.ts +11 -0
- package/dist/commands/uninstall.js +158 -0
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/utils.d.ts +29 -0
- package/dist/commands/utils.js +24 -0
- package/dist/commands/versions.js +120 -11
- package/dist/index.js +5 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/daemon.d.ts +9 -0
- package/dist/lib/daemon.js +43 -0
- 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/hosts/logs.d.ts +12 -0
- package/dist/lib/hosts/logs.js +18 -0
- 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/shims.d.ts +27 -0
- package/dist/lib/shims.js +29 -3
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +5 -1
- package/dist/lib/uninstall.d.ts +84 -0
- package/dist/lib/uninstall.js +347 -0
- package/dist/lib/usage.d.ts +13 -1
- package/dist/lib/usage.js +32 -14
- package/dist/lib/versions.d.ts +16 -0
- package/dist/lib/versions.js +40 -3
- 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
|
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];
|
|
@@ -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
|
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/logs.js
CHANGED
|
@@ -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')
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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;
|