@phnx-labs/agents-cli 1.20.69 → 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 +81 -0
- package/README.md +19 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/doctor.js +5 -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/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 +63 -16
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +5 -2
- package/dist/lib/auth-health.d.ts +38 -0
- package/dist/lib/auth-health.js +48 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/computer/download.d.ts +3 -0
- package/dist/lib/computer/download.js +13 -12
- package/dist/lib/daemon.js +38 -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/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- 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/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -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/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
|
@@ -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')
|
package/dist/commands/ssh.js
CHANGED
|
@@ -18,6 +18,7 @@ import ora from 'ora';
|
|
|
18
18
|
import { getCliVersion } from '../lib/version.js';
|
|
19
19
|
import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
|
|
20
20
|
import { machineId } from '../lib/session/sync/config.js';
|
|
21
|
+
import { registerFleetCaptureCommand } from './fleet-capture.js';
|
|
21
22
|
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
|
|
22
23
|
import { addControlToken } from '../lib/serve/token.js';
|
|
23
24
|
import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
|
|
@@ -27,18 +28,19 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
|
|
|
27
28
|
import { clearPendingSentinel } from '../lib/devices/pending.js';
|
|
28
29
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
29
30
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
30
|
-
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
31
|
+
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
31
32
|
import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
|
|
32
33
|
import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
|
|
33
34
|
import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
|
|
34
|
-
import { fleetCapacity, fmtBytes, headroom,
|
|
35
|
+
import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
|
|
35
36
|
import { buildFleetHealthReport, renderFleetMatrix, renderFleetWarnings, } from '../lib/devices/health-report.js';
|
|
37
|
+
import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
|
|
36
38
|
import { checkSyncStatus, countOrphans } from '../lib/drift.js';
|
|
37
39
|
import { checkAllClis } from '../lib/teams/agents.js';
|
|
38
40
|
import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
|
|
39
41
|
import { sshExecAsync } from '../lib/ssh-exec.js';
|
|
40
42
|
import { ALL_AGENT_IDS } from '../lib/agents.js';
|
|
41
|
-
import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
|
|
43
|
+
import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
|
|
42
44
|
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
43
45
|
* command is running on so it stands out from the rest of the tailnet. */
|
|
44
46
|
function deviceSummary(d, isSelf = false) {
|
|
@@ -243,10 +245,10 @@ async function probeRemoteHealth(target) {
|
|
|
243
245
|
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
244
246
|
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
245
247
|
const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
|
|
246
|
-
const versionRes = await sshExecAsync(target.
|
|
248
|
+
const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
|
|
247
249
|
const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
|
|
248
250
|
const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
|
|
249
|
-
const doctorRes = await sshExecAsync(target.
|
|
251
|
+
const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
|
|
250
252
|
if (doctorRes.code !== 0) {
|
|
251
253
|
throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
|
|
252
254
|
}
|
|
@@ -256,25 +258,43 @@ async function probeRemoteHealth(target) {
|
|
|
256
258
|
clis: parsed.clis ?? {},
|
|
257
259
|
sync: parsed.sync ?? [],
|
|
258
260
|
orphans: parsed.orphans ?? [],
|
|
261
|
+
// The remote self-reports its own cached auth rollup (fresh via its daemon),
|
|
262
|
+
// so the Auth column is current without a prior fleet-wide `fleet ping`.
|
|
263
|
+
// Older remotes that don't emit it fall back to this host's cache below.
|
|
264
|
+
auth: parsed.auth,
|
|
259
265
|
};
|
|
260
266
|
}
|
|
261
267
|
async function runFleetStatus(opts) {
|
|
262
268
|
const reg = await loadDevices();
|
|
263
269
|
const self = machineId();
|
|
270
|
+
const forceRefresh = Boolean(opts.refresh || opts.live);
|
|
264
271
|
const planned = planFleetTargets(reg);
|
|
265
272
|
const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
|
|
273
|
+
// Cache-first: serve remote stats from the daemon-warmed cache (instant),
|
|
274
|
+
// probe this machine locally, and only ssh out for missing/forced rows.
|
|
266
275
|
const statsMap = opts.stats === false
|
|
267
276
|
? new Map()
|
|
268
|
-
: await
|
|
269
|
-
if (opts.stats !== false && !statsMap.has(self)) {
|
|
270
|
-
statsMap.set(self, await probeLocalStats(self));
|
|
271
|
-
}
|
|
277
|
+
: (await loadFleetStats(probeable, { forceRefresh, selfName: self })).stats;
|
|
272
278
|
const rows = [localHealthRow(self, statsMap.get(self))];
|
|
273
279
|
const remoteTargets = remoteFleetTargets(planned, self)
|
|
274
280
|
.map((t) => ({
|
|
275
281
|
name: t.device.name,
|
|
276
282
|
platform: t.device.platform,
|
|
277
283
|
skip: t.skip,
|
|
284
|
+
dialTarget: fleetDialTarget(t.device),
|
|
285
|
+
// Fail fast: the stats probe already tried this box with the same
|
|
286
|
+
// (registry) address. If it came back unreachable, don't spend another
|
|
287
|
+
// 15s+30s on version+doctor — skip straight to an unreachable row so one
|
|
288
|
+
// genuinely-offline device can't stall the whole matrix.
|
|
289
|
+
//
|
|
290
|
+
// Only trust this on a FRESH probe (`--refresh`/`--live`). loadFleetStats
|
|
291
|
+
// is cache-first (daemon-warmed ~3min), so a box that came back online
|
|
292
|
+
// since the last warm would otherwise be skipped to "unreachable" without
|
|
293
|
+
// a live attempt — a false negative. On a default run we still do the live
|
|
294
|
+
// probe (fast now that it dials the registry address).
|
|
295
|
+
...(forceRefresh && statsMap.get(t.device.name)?.reachable === false && !t.skip
|
|
296
|
+
? { skip: 'unreachable' }
|
|
297
|
+
: {}),
|
|
278
298
|
}));
|
|
279
299
|
const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
|
|
280
300
|
for (const result of remote) {
|
|
@@ -300,6 +320,15 @@ async function runFleetStatus(opts) {
|
|
|
300
320
|
});
|
|
301
321
|
}
|
|
302
322
|
}
|
|
323
|
+
// Auth column: remote rows already carry the host's self-reported rollup from
|
|
324
|
+
// its `doctor --json`. Fill the rest (this machine; older remotes that don't
|
|
325
|
+
// emit it) from this host's local cache — written by `agents fleet ping` and
|
|
326
|
+
// the daemon's local refresh. A never-probed host rolls up to "—". No network.
|
|
327
|
+
const authCache = readAuthHealthCache();
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
if (!row.auth)
|
|
330
|
+
row.auth = summarizeHostAuth(authCache, row.name);
|
|
331
|
+
}
|
|
303
332
|
const report = buildFleetHealthReport(rows);
|
|
304
333
|
if (opts.json) {
|
|
305
334
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -319,7 +348,7 @@ async function probeRemoteAuth(target) {
|
|
|
319
348
|
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
320
349
|
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
321
350
|
const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
|
|
322
|
-
const res = await sshExecAsync(target.
|
|
351
|
+
const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
|
|
323
352
|
if (res.code !== 0) {
|
|
324
353
|
throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
|
|
325
354
|
}
|
|
@@ -356,6 +385,7 @@ async function runFleetPing(opts) {
|
|
|
356
385
|
name: t.device.name,
|
|
357
386
|
platform: t.device.platform,
|
|
358
387
|
skip: t.skip,
|
|
388
|
+
dialTarget: fleetDialTarget(t.device),
|
|
359
389
|
}));
|
|
360
390
|
const probeable = remoteTargets.filter((t) => !t.skip).length;
|
|
361
391
|
const spinner = isInteractiveTerminal() && !opts.json
|
|
@@ -497,6 +527,8 @@ Typical workflow:
|
|
|
497
527
|
process.exit(1);
|
|
498
528
|
}
|
|
499
529
|
});
|
|
530
|
+
// `agents fleet capture` — snapshot live state into agents.yaml fleet:.
|
|
531
|
+
registerFleetCaptureCommand(devicesCmd);
|
|
500
532
|
devicesCmd
|
|
501
533
|
.command('register <name>')
|
|
502
534
|
.description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
|
|
@@ -550,6 +582,8 @@ Typical workflow:
|
|
|
550
582
|
.description('List registered devices with platform, address, reachability, and live resource headroom.')
|
|
551
583
|
.option('--json', 'output the registry as a JSON array (for scripts and hooks)')
|
|
552
584
|
.option('--no-stats', 'skip the live resource probe (instant; names/addresses only)')
|
|
585
|
+
.option('--refresh', 'force a live probe of every device, bypassing the cache')
|
|
586
|
+
.option('--live', 'alias of --refresh (shorter to type)')
|
|
553
587
|
.option('-f, --full', 'full mode: add per-device core count and free/total memory')
|
|
554
588
|
.action(async (opts) => {
|
|
555
589
|
const reg = await loadDevices();
|
|
@@ -564,18 +598,26 @@ Typical workflow:
|
|
|
564
598
|
return;
|
|
565
599
|
}
|
|
566
600
|
const self = machineId();
|
|
601
|
+
const forceRefresh = Boolean(opts.refresh || opts.live);
|
|
567
602
|
let statsMap;
|
|
603
|
+
let freshness;
|
|
568
604
|
if (opts.stats !== false) {
|
|
569
|
-
//
|
|
570
|
-
//
|
|
605
|
+
// Cache-first: serve remote devices from the daemon-warmed cache
|
|
606
|
+
// (instant), probe this machine locally, and only ssh out for missing or
|
|
607
|
+
// forced (--refresh/--live) rows — so a warm read never hangs on a box.
|
|
571
608
|
const probeable = planFleetTargets(reg)
|
|
572
609
|
.filter((t) => !t.skip)
|
|
573
610
|
.map((t) => t.device);
|
|
574
|
-
|
|
611
|
+
// Only spin when we'll actually ssh (forced, or a cold/partial cache).
|
|
612
|
+
const cache = readStatsCache();
|
|
613
|
+
const willSsh = forceRefresh || probeable.some((d) => d.name !== self && !cache[d.name]);
|
|
614
|
+
const spinner = willSsh && isInteractiveTerminal()
|
|
575
615
|
? ora(`Probing ${probeable.length} device${probeable.length === 1 ? '' : 's'}…`).start()
|
|
576
616
|
: undefined;
|
|
577
617
|
try {
|
|
578
|
-
|
|
618
|
+
const res = await loadFleetStats(probeable, { forceRefresh, selfName: self });
|
|
619
|
+
statsMap = res.stats;
|
|
620
|
+
freshness = res;
|
|
579
621
|
}
|
|
580
622
|
finally {
|
|
581
623
|
spinner?.stop();
|
|
@@ -584,6 +626,9 @@ Typical workflow:
|
|
|
584
626
|
console.log(chalk.bold(`Devices (${names.length})`));
|
|
585
627
|
for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
|
|
586
628
|
console.log(line);
|
|
629
|
+
if (freshness?.servedFromCache && freshness.oldestFetchedAt != null) {
|
|
630
|
+
console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
|
|
631
|
+
}
|
|
587
632
|
});
|
|
588
633
|
devicesCmd
|
|
589
634
|
.command('status')
|
|
@@ -591,6 +636,8 @@ Typical workflow:
|
|
|
591
636
|
.option('--json', 'output machine-readable JSON')
|
|
592
637
|
.option('--strict', 'exit non-zero when any device has drift or is unreachable')
|
|
593
638
|
.option('--no-stats', 'skip the live resource probe')
|
|
639
|
+
.option('--refresh', 'force a live probe of every device, bypassing the cache')
|
|
640
|
+
.option('--live', 'alias of --refresh (shorter to type)')
|
|
594
641
|
.action(async (opts) => {
|
|
595
642
|
await runFleetStatus(opts);
|
|
596
643
|
});
|
|
@@ -752,7 +799,7 @@ Typical workflow:
|
|
|
752
799
|
return;
|
|
753
800
|
}
|
|
754
801
|
console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
|
|
755
|
-
const results = runFleet(targets, cmd);
|
|
802
|
+
const results = runFleet(targets, cmd, { self: machineId() });
|
|
756
803
|
printFleetResults(results);
|
|
757
804
|
});
|
|
758
805
|
devicesCmd
|
|
@@ -771,7 +818,7 @@ Typical workflow:
|
|
|
771
818
|
return;
|
|
772
819
|
}
|
|
773
820
|
console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
|
|
774
|
-
const results = runFleet(targets, cmd);
|
|
821
|
+
const results = runFleet(targets, cmd, { self: machineId() });
|
|
775
822
|
printFleetResults(results);
|
|
776
823
|
});
|
|
777
824
|
}
|
package/dist/commands/usage.d.ts
CHANGED
|
@@ -10,4 +10,17 @@
|
|
|
10
10
|
* don't publish per-account usage today)
|
|
11
11
|
*/
|
|
12
12
|
import type { Command } from 'commander';
|
|
13
|
+
import type { AgentId } from '../lib/types.js';
|
|
14
|
+
import { getUsageInfoForIdentity } from '../lib/usage.js';
|
|
15
|
+
/** One agent's usage snapshot — the unit the text and --json renderers share. */
|
|
16
|
+
export interface AgentUsageRecord {
|
|
17
|
+
agent: AgentId;
|
|
18
|
+
/** Plain agent name (no ANSI) — safe to emit in --json; the text table colorizes it. */
|
|
19
|
+
label: string;
|
|
20
|
+
status: 'unsupported' | 'no-version' | 'not-signed-in' | 'ok';
|
|
21
|
+
email?: string;
|
|
22
|
+
usage?: Awaited<ReturnType<typeof getUsageInfoForIdentity>>;
|
|
23
|
+
}
|
|
13
24
|
export declare function registerUsageCommand(program: Command): void;
|
|
25
|
+
/** Render one usage record as the human table section. */
|
|
26
|
+
export declare function formatAgentUsage(rec: AgentUsageRecord): string;
|
package/dist/commands/usage.js
CHANGED
|
@@ -13,13 +13,15 @@ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
|
|
|
13
13
|
export function registerUsageCommand(program) {
|
|
14
14
|
addHostOption(program.command('usage [agent]'))
|
|
15
15
|
.description('Show rate-limit / quota usage per agent')
|
|
16
|
+
.option('--json', 'Emit machine-readable JSON (per-agent usage snapshot) instead of the table')
|
|
16
17
|
.addHelpText('after', `
|
|
17
18
|
Examples:
|
|
18
19
|
agents usage Show usage for all installed agents
|
|
19
20
|
agents usage claude Show usage for Claude only
|
|
20
21
|
agents usage codex Show usage for Codex only
|
|
22
|
+
agents usage --json Machine-readable snapshot for scripts
|
|
21
23
|
`)
|
|
22
|
-
.action(async (agentFilter) => {
|
|
24
|
+
.action(async (agentFilter, options) => {
|
|
23
25
|
let filter;
|
|
24
26
|
if (agentFilter) {
|
|
25
27
|
const resolved = resolveAgentName(agentFilter);
|
|
@@ -33,47 +35,67 @@ Examples:
|
|
|
33
35
|
? [filter]
|
|
34
36
|
: ALL_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
|
|
35
37
|
if (targets.length === 0) {
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log('[]');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
36
42
|
console.log(chalk.gray('No agents installed. Run `agents add <agent>` first.'));
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
45
|
+
const records = await Promise.all(targets.map((agentId) => collectAgentUsage(agentId)));
|
|
46
|
+
if (options.json) {
|
|
47
|
+
console.log(JSON.stringify(records, null, 2));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(records.map(formatAgentUsage).filter(Boolean).join('\n\n'));
|
|
41
51
|
});
|
|
42
52
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
/** Gather one agent's usage snapshot as structured data (shared by both renderers). */
|
|
54
|
+
async function collectAgentUsage(agentId) {
|
|
55
|
+
// Plain name — color is applied only at text-render time (formatAgentUsage), so
|
|
56
|
+
// `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
|
|
57
|
+
const label = AGENTS[agentId].name;
|
|
46
58
|
if (!USAGE_SUPPORTED.has(agentId)) {
|
|
47
|
-
return
|
|
48
|
-
`${heading}`,
|
|
49
|
-
` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`,
|
|
50
|
-
].join('\n');
|
|
59
|
+
return { agent: agentId, label, status: 'unsupported' };
|
|
51
60
|
}
|
|
52
61
|
const versions = listInstalledVersions(agentId);
|
|
53
62
|
const version = getGlobalDefault(agentId) || versions[0];
|
|
54
63
|
if (!version) {
|
|
55
|
-
return
|
|
64
|
+
return { agent: agentId, label, status: 'no-version' };
|
|
56
65
|
}
|
|
57
66
|
const home = getVersionHomePath(agentId, version);
|
|
58
67
|
const info = await getAccountInfo(agentId, home);
|
|
59
68
|
if (!info.usageKey && !info.accountKey) {
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
const usage = await getUsageInfoForIdentity({
|
|
63
|
-
agentId,
|
|
64
|
-
home,
|
|
65
|
-
info,
|
|
66
|
-
cliVersion: null,
|
|
67
|
-
});
|
|
68
|
-
const lines = [heading];
|
|
69
|
-
if (info.email)
|
|
70
|
-
lines.push(` ${chalk.dim(info.email)}`);
|
|
71
|
-
const section = formatUsageSection(usage);
|
|
72
|
-
if (section.length === 0) {
|
|
73
|
-
lines.push(` ${chalk.dim('No usage data available right now.')}`);
|
|
69
|
+
return { agent: agentId, label, status: 'not-signed-in' };
|
|
74
70
|
}
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
const usage = await getUsageInfoForIdentity({ agentId, home, info, cliVersion: null });
|
|
72
|
+
return { agent: agentId, label, status: 'ok', email: info.email ?? undefined, usage };
|
|
73
|
+
}
|
|
74
|
+
/** Render one usage record as the human table section. */
|
|
75
|
+
export function formatAgentUsage(rec) {
|
|
76
|
+
const cfg = AGENTS[rec.agent];
|
|
77
|
+
// Colorize the heading here (not in the record) so the plain `label` stays
|
|
78
|
+
// clean for --json while the text table keeps its per-agent color.
|
|
79
|
+
const heading = agentLabel(rec.agent);
|
|
80
|
+
switch (rec.status) {
|
|
81
|
+
case 'unsupported':
|
|
82
|
+
return [heading, ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`].join('\n');
|
|
83
|
+
case 'no-version':
|
|
84
|
+
return [heading, ` ${chalk.dim('No version installed.')}`].join('\n');
|
|
85
|
+
case 'not-signed-in':
|
|
86
|
+
return [heading, ` ${chalk.dim('Not signed in.')}`].join('\n');
|
|
87
|
+
case 'ok': {
|
|
88
|
+
const lines = [heading];
|
|
89
|
+
if (rec.email)
|
|
90
|
+
lines.push(` ${chalk.dim(rec.email)}`);
|
|
91
|
+
const section = formatUsageSection(rec.usage);
|
|
92
|
+
if (section.length === 0) {
|
|
93
|
+
lines.push(` ${chalk.dim('No usage data available right now.')}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
lines.push(...section);
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
77
100
|
}
|
|
78
|
-
return lines.join('\n');
|
|
79
101
|
}
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -123,6 +123,7 @@ export declare function viewAction(agentArg?: string, options?: {
|
|
|
123
123
|
resources?: string | boolean;
|
|
124
124
|
detailed?: boolean;
|
|
125
125
|
refresh?: boolean;
|
|
126
|
+
live?: boolean;
|
|
126
127
|
} & ViewSectionFilter): Promise<void>;
|
|
127
128
|
/** Register the `agents view` command. */
|
|
128
129
|
export declare function registerViewCommand(program: Command): void;
|
package/dist/commands/view.js
CHANGED
|
@@ -1400,6 +1400,8 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
|
|
|
1400
1400
|
* Exported for use by deprecated aliases.
|
|
1401
1401
|
*/
|
|
1402
1402
|
export async function viewAction(agentArg, options) {
|
|
1403
|
+
// --live is a shorter-to-type alias of --refresh; both force a live probe.
|
|
1404
|
+
const forceRefresh = options?.refresh === true || options?.live === true;
|
|
1403
1405
|
// --resources / --detailed imply --json (they only shape structured output).
|
|
1404
1406
|
const explicitResources = options?.detailed === true || options?.resources !== undefined;
|
|
1405
1407
|
const json = options?.json === true || explicitResources;
|
|
@@ -1440,7 +1442,7 @@ export async function viewAction(agentArg, options) {
|
|
|
1440
1442
|
return;
|
|
1441
1443
|
}
|
|
1442
1444
|
// No argument: show all installed versions
|
|
1443
|
-
await showInstalledVersions(undefined, { forceRefresh
|
|
1445
|
+
await showInstalledVersions(undefined, { forceRefresh });
|
|
1444
1446
|
return;
|
|
1445
1447
|
}
|
|
1446
1448
|
// Parse agent@version syntax
|
|
@@ -1501,7 +1503,7 @@ export async function viewAction(agentArg, options) {
|
|
|
1501
1503
|
}
|
|
1502
1504
|
else {
|
|
1503
1505
|
// Just agent name: show versions for that agent
|
|
1504
|
-
await showInstalledVersions(agentId, { forceRefresh
|
|
1506
|
+
await showInstalledVersions(agentId, { forceRefresh });
|
|
1505
1507
|
}
|
|
1506
1508
|
}
|
|
1507
1509
|
/** Register the `agents view` command. */
|
|
@@ -1512,6 +1514,7 @@ export function registerViewCommand(program) {
|
|
|
1512
1514
|
.option('--resources [sections]', 'In --json mode, include each version\'s resources: "all" (default) or a comma list (skills,plugins,mcp,commands,workflows,memory,hooks). Implies --json.')
|
|
1513
1515
|
.option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
|
|
1514
1516
|
.option('-r, --refresh', 'Force a live usage refresh, bypassing the cache (slower). Repopulates the S:/W: limit bars for every account whose token is reachable.')
|
|
1517
|
+
.option('--live', 'Alias of --refresh (shorter to type).')
|
|
1515
1518
|
.option('--prune', 'Remove older installed versions that share an account with a newer installed version. Skips the global default.')
|
|
1516
1519
|
.option('--dry-run', 'With --prune, show duplicate versions without deleting')
|
|
1517
1520
|
.option('-y, --yes', 'Skip the prune confirmation prompt.')
|
|
@@ -49,6 +49,44 @@ export interface VerdictSummary {
|
|
|
49
49
|
export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
|
|
50
50
|
/** Verdicts that mean "this token was rejected by the server — re-login required". */
|
|
51
51
|
export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A host's rolled-up auth state for the `fleet status` Auth column.
|
|
54
|
+
*
|
|
55
|
+
* The four display buckets are deliberately finer-grained than
|
|
56
|
+
* {@link VerdictSummary}'s live/bad/warn: they separate "present but this agent
|
|
57
|
+
* has no live probe" (`unverified`) and "soft, self-healing expiry"
|
|
58
|
+
* (`expired`/`rate_limited`) from a genuine server rejection (`revoked`). The
|
|
59
|
+
* old three-bucket rollup lumped all of those into `warn` and the column painted
|
|
60
|
+
* them one alarming yellow — so a fleet of perfectly logged-in accounts on
|
|
61
|
+
* codex/grok/etc (which can NEVER be probed live) read as half-degraded. These
|
|
62
|
+
* buckets let the renderer show `unverified` as neutral and reserve red for the
|
|
63
|
+
* only verdict that actually means "re-login now" ({@link isDeadVerdict}).
|
|
64
|
+
*/
|
|
65
|
+
export interface HostAuthSummary {
|
|
66
|
+
/** Live-verified accounts (a real 2xx). */
|
|
67
|
+
live: number;
|
|
68
|
+
/** Signed in but this agent has no live-probe endpoint — benign, neutral. */
|
|
69
|
+
present: number;
|
|
70
|
+
/** Soft/degraded: expired (self-healing) / rate_limited / error. Mild warning. */
|
|
71
|
+
degraded: number;
|
|
72
|
+
/** Server rejected the token — genuinely needs re-login. */
|
|
73
|
+
revoked: number;
|
|
74
|
+
/** Total cached rows for this host (0 → the renderer shows "—"). */
|
|
75
|
+
total: number;
|
|
76
|
+
/** Oldest `checkedAt` (epoch ms) among this host's cached rows, or null when none. */
|
|
77
|
+
oldestCheckedAt: number | null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
|
|
81
|
+
* plus the age of its stalest entry. Pure — reads the map the caller already
|
|
82
|
+
* loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
|
|
83
|
+
* column without any network probe. A host with no cached rows yields an empty
|
|
84
|
+
* summary (total 0), which the renderer shows as "—".
|
|
85
|
+
*
|
|
86
|
+
* Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
|
|
87
|
+
* prefix so agent/version segments can never be mistaken for a host.
|
|
88
|
+
*/
|
|
89
|
+
export declare function summarizeHostAuth(cache: Record<string, AuthHealth>, host: string): HostAuthSummary;
|
|
52
90
|
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
53
91
|
export declare function formatCheckedAge(checkedAt: number, now?: number): string;
|
|
54
92
|
/**
|
package/dist/lib/auth-health.js
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* The rest of the CLI reports "signed in" from a local heuristic — a credential
|
|
6
6
|
* file is present and its email decodes — which cannot distinguish a good token
|
|
7
7
|
* from a revoked-but-unexpired one. This module completes a real request per
|
|
8
|
-
* (agent, account) and records the verdict in a small cache that `agents view
|
|
9
|
-
* `agents fleet status
|
|
10
|
-
*
|
|
8
|
+
* (agent, account) and records the verdict in a small cache that `agents view`
|
|
9
|
+
* (per-version chip), `agents fleet status` (the per-host Auth column, via
|
|
10
|
+
* {@link summarizeHostAuth}), and the run rotation all read. The writers are
|
|
11
|
+
* the daemon (a periodic local refresh) and `agents fleet ping` (which also
|
|
12
|
+
* fans out to write remote hosts' rows into the local cache); everyone else
|
|
13
|
+
* reads.
|
|
11
14
|
*
|
|
12
15
|
* The network probes themselves live in lib/usage.ts (where the per-provider
|
|
13
16
|
* token loaders + endpoints already are); this module classifies their result,
|
|
@@ -95,6 +98,48 @@ export function summarizeVerdicts(verdicts) {
|
|
|
95
98
|
export function isDeadVerdict(verdict) {
|
|
96
99
|
return verdict === 'revoked';
|
|
97
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
|
|
103
|
+
* plus the age of its stalest entry. Pure — reads the map the caller already
|
|
104
|
+
* loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
|
|
105
|
+
* column without any network probe. A host with no cached rows yields an empty
|
|
106
|
+
* summary (total 0), which the renderer shows as "—".
|
|
107
|
+
*
|
|
108
|
+
* Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
|
|
109
|
+
* prefix so agent/version segments can never be mistaken for a host.
|
|
110
|
+
*/
|
|
111
|
+
export function summarizeHostAuth(cache, host) {
|
|
112
|
+
const prefix = `${host}:`;
|
|
113
|
+
let live = 0, present = 0, degraded = 0, revoked = 0, total = 0;
|
|
114
|
+
let oldest = null;
|
|
115
|
+
for (const [key, health] of Object.entries(cache)) {
|
|
116
|
+
if (!key.startsWith(prefix))
|
|
117
|
+
continue;
|
|
118
|
+
// `unconfigured` = no credential at all — not a probed account. Writers
|
|
119
|
+
// already drop these before they reach the cache; skip here too so a stray
|
|
120
|
+
// one never counts toward total or the freshness age (belt-and-suspenders).
|
|
121
|
+
if (health.verdict === 'unconfigured')
|
|
122
|
+
continue;
|
|
123
|
+
total++;
|
|
124
|
+
switch (health.verdict) {
|
|
125
|
+
case 'live':
|
|
126
|
+
live++;
|
|
127
|
+
break;
|
|
128
|
+
case 'unverified':
|
|
129
|
+
present++;
|
|
130
|
+
break; // signed in, no probe — benign
|
|
131
|
+
case 'revoked':
|
|
132
|
+
revoked++;
|
|
133
|
+
break; // server said no — re-login
|
|
134
|
+
default:
|
|
135
|
+
degraded++;
|
|
136
|
+
break; // expired / rate_limited / error — soft
|
|
137
|
+
}
|
|
138
|
+
if (oldest === null || health.checkedAt < oldest)
|
|
139
|
+
oldest = health.checkedAt;
|
|
140
|
+
}
|
|
141
|
+
return { live, present, degraded, revoked, total, oldestCheckedAt: oldest };
|
|
142
|
+
}
|
|
98
143
|
/** Human "3m ago" style age for a checkedAt timestamp. */
|
|
99
144
|
export function formatCheckedAge(checkedAt, now = Date.now()) {
|
|
100
145
|
const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
|
package/dist/lib/commands.d.ts
CHANGED
|
@@ -89,6 +89,16 @@ export interface VersionCommandDiff {
|
|
|
89
89
|
/**
|
|
90
90
|
* Compare a version home's commands against central. Returns the reconciliation diff.
|
|
91
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
|
|
94
|
+
* exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
|
|
95
|
+
* command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
|
|
96
|
+
* `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
|
|
97
|
+
* the orphan detector must NOT flag them — else `prune cleanup` proposes
|
|
98
|
+
* deleting live plugin commands (swarm-plan, code-review, …). Scans user +
|
|
99
|
+
* system + extra marketplaces (no project layer), matching the trusted sources.
|
|
100
|
+
*/
|
|
101
|
+
export declare function listPluginCommandNames(): Set<string>;
|
|
92
102
|
export declare function diffVersionCommands(agent: AgentId, version: string): VersionCommandDiff;
|
|
93
103
|
/**
|
|
94
104
|
* Install a single command from central into a specific version home.
|
package/dist/lib/commands.js
CHANGED
|
@@ -14,6 +14,7 @@ import { capableAgents, isCapable, supports } from './capabilities.js';
|
|
|
14
14
|
import { markdownToToml } from './convert.js';
|
|
15
15
|
import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
|
|
16
16
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
|
|
17
|
+
import { discoverPlugins } from './plugins.js';
|
|
17
18
|
import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
|
|
18
19
|
import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
|
|
19
20
|
function compareVersions(a, b) {
|
|
@@ -311,8 +312,26 @@ function versionCommandMatches(agent, version, commandName) {
|
|
|
311
312
|
/**
|
|
312
313
|
* Compare a version home's commands against central. Returns the reconciliation diff.
|
|
313
314
|
*/
|
|
315
|
+
/**
|
|
316
|
+
* Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
|
|
317
|
+
* exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
|
|
318
|
+
* command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
|
|
319
|
+
* `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
|
|
320
|
+
* the orphan detector must NOT flag them — else `prune cleanup` proposes
|
|
321
|
+
* deleting live plugin commands (swarm-plan, code-review, …). Scans user +
|
|
322
|
+
* system + extra marketplaces (no project layer), matching the trusted sources.
|
|
323
|
+
*/
|
|
324
|
+
export function listPluginCommandNames() {
|
|
325
|
+
const names = new Set();
|
|
326
|
+
for (const plugin of discoverPlugins()) {
|
|
327
|
+
for (const cmd of plugin.commands)
|
|
328
|
+
names.add(`${plugin.name}-${cmd}`);
|
|
329
|
+
}
|
|
330
|
+
return names;
|
|
331
|
+
}
|
|
314
332
|
export function diffVersionCommands(agent, version) {
|
|
315
333
|
const central = new Set(listCentralCommands());
|
|
334
|
+
const pluginCommands = listPluginCommandNames();
|
|
316
335
|
const installed = new Set(listCommandsInVersionHome(agent, version));
|
|
317
336
|
const toAdd = [];
|
|
318
337
|
const toUpdate = [];
|
|
@@ -335,15 +354,20 @@ export function diffVersionCommands(agent, version) {
|
|
|
335
354
|
}
|
|
336
355
|
}
|
|
337
356
|
for (const name of installed) {
|
|
338
|
-
if (
|
|
339
|
-
|
|
357
|
+
if (central.has(name)) {
|
|
358
|
+
const sourcePath = path.join(getCommandsDir(), `${name}.md`);
|
|
359
|
+
const metadata = parseCommandMetadata(sourcePath);
|
|
360
|
+
if (!commandAppliesTo(agent, version, metadata).ok) {
|
|
361
|
+
toRemove.push(name);
|
|
362
|
+
}
|
|
340
363
|
continue;
|
|
341
364
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
365
|
+
// A plugin-bundled command (installed as `<plugin>-<cmd>`) is source-managed
|
|
366
|
+
// by its plugin — not an orphan. Only a name with no central AND no plugin
|
|
367
|
+
// source is genuinely unmanaged.
|
|
368
|
+
if (pluginCommands.has(name))
|
|
369
|
+
continue;
|
|
370
|
+
orphans.push(name);
|
|
347
371
|
}
|
|
348
372
|
return {
|
|
349
373
|
agent,
|
|
@@ -29,6 +29,9 @@ export declare function macHelperAssetUrls(version: string): {
|
|
|
29
29
|
zip: string;
|
|
30
30
|
sha256: string;
|
|
31
31
|
};
|
|
32
|
+
/** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
|
|
33
|
+
* emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
|
|
34
|
+
export declare function parseTeamId(codesignInfo: string): string | null;
|
|
32
35
|
/**
|
|
33
36
|
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
34
37
|
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|