@phnx-labs/agents-cli 1.20.87 → 1.20.89
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 +323 -0
- package/README.md +12 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +25 -3
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +40 -12
- package/dist/commands/ssh.js +3 -3
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +28 -2
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +75 -6
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +41 -8
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +16 -1
- package/dist/lib/runner.js +58 -16
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +21 -27
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prioritized, fleet-aware findings model for `agents doctor` (RUSH-2069).
|
|
3
|
+
*
|
|
4
|
+
* The redesign is a HYBRID, comprehensive-by-default readout (no `--verbose`):
|
|
5
|
+
*
|
|
6
|
+
* 1. `✗ CRITICAL — needs you now (N)` — EVERY critical across the whole fleet,
|
|
7
|
+
* worst-first, each `device · harness@version · account? · message →
|
|
8
|
+
* remediation`. A healthy machine can never bury a critical.
|
|
9
|
+
* 2. `─── by computer ───` — one block per device (worst-first): that machine's
|
|
10
|
+
* WARNINGS plus a compact accounts/versions line listing every installed
|
|
11
|
+
* version and its account (provable ✓ / ✗). A device that has criticals
|
|
12
|
+
* carries a `✗ N critical (above)` marker; the criticals stay at the top.
|
|
13
|
+
*
|
|
14
|
+
* A single-machine `agents doctor` (no `--devices`) collapses to the CRITICAL
|
|
15
|
+
* section, then one `▸ <machine>` block.
|
|
16
|
+
*
|
|
17
|
+
* Severity rubric — every kind the builders emit, by the severity they emit it
|
|
18
|
+
* with. Keep this list exhaustive; a kind missing from it is a doc that lies.
|
|
19
|
+
* CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
|
|
20
|
+
* unwired-hook (a hook on disk that settings.json never fires) ·
|
|
21
|
+
* never-synced WHEN the version's declared resources are absent ·
|
|
22
|
+
* duplicate-hook-drift (copies that DIFFER) · cli-missing.
|
|
23
|
+
* WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
|
|
24
|
+
* stale · never-synced when the version declares nothing to miss ·
|
|
25
|
+
* repo-behind · repo-drift · version-skew · fleet-resource-gap ·
|
|
26
|
+
* orphan · duplicate-hook (identical copies) · host-cli-missing ·
|
|
27
|
+
* host-cli-invalid · rc-secret-export · exec-policy · stale-cli.
|
|
28
|
+
*
|
|
29
|
+
* This module is pure: it maps already-collected signals (drift rows, orphan
|
|
30
|
+
* rows, repo-behind markers, per-version resource diffs, cross-device divergence,
|
|
31
|
+
* and per-version sign-in) into {@link DoctorFinding}s and renders them. The SSH
|
|
32
|
+
* fan-out and the live probes live in the doctor command; here we only shape and
|
|
33
|
+
* format, so the layout is unit-tested against fixtures with no live fleet.
|
|
34
|
+
*/
|
|
35
|
+
import chalk from 'chalk';
|
|
36
|
+
import { AGENTS, ALL_AGENT_IDS, supportsAccountInspection } from '../agents.js';
|
|
37
|
+
import { blocksLocalScripts } from '../platform/winpath.js';
|
|
38
|
+
import { loginHint } from '../signin-badge.js';
|
|
39
|
+
import { CONFIG_ENV_ISOLATED_AGENTS } from '../shims.js';
|
|
40
|
+
import { padToWidth, stringWidth } from '../session/width.js';
|
|
41
|
+
const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
|
|
42
|
+
/** Agents with NO per-version credential isolation: their login is shared across
|
|
43
|
+
* every installed version, so a "log into THIS version" remediation would be a
|
|
44
|
+
* lie. Derived from `CONFIG_ENV_ISOLATED_AGENTS` in `lib/shims.ts` — the shim
|
|
45
|
+
* generator is what actually exports the per-version isolation env var, so it is
|
|
46
|
+
* the source of truth. Do not hand-maintain a second copy: an agent gaining
|
|
47
|
+
* isolation there must not leave a stale "login is shared" hint here. */
|
|
48
|
+
const ISOLATED_LOGIN = new Set(CONFIG_ENV_ISOLATED_AGENTS);
|
|
49
|
+
const NO_PER_VERSION_LOGIN = new Set(ALL_AGENT_IDS.filter((a) => !ISOLATED_LOGIN.has(a)));
|
|
50
|
+
/** How an agent's login is actually reached — the three shapes `loginHint`
|
|
51
|
+
* encodes (`lib/signin-badge.ts:23-36`), split apart because a per-version fix
|
|
52
|
+
* has to build a different command for each:
|
|
53
|
+
* `subcommand` — `<cli> login` / `<cli> auth login`, runnable via `--`
|
|
54
|
+
* `in-tui` — claude only: a `/login` slash command inside its own TUI
|
|
55
|
+
* `on-launch` — the device/oauth flow starts when the agent launches */
|
|
56
|
+
const LOGIN_SUBCOMMAND = {
|
|
57
|
+
codex: 'login',
|
|
58
|
+
grok: 'login',
|
|
59
|
+
opencode: 'auth login',
|
|
60
|
+
};
|
|
61
|
+
/** Kinds whose fix is inherently PER VERSION, so a row collapsed across versions
|
|
62
|
+
* could not carry a correct remediation. A login is the whole set: there is no
|
|
63
|
+
* `@all` selector for it, and dropping the version falls back to the bare native
|
|
64
|
+
* hint, which the shim points at the DEFAULT version. */
|
|
65
|
+
const NEVER_COLLAPSED = new Set(['logged-out', 'logout-unprovable']);
|
|
66
|
+
function loginShape(agent) {
|
|
67
|
+
if (LOGIN_SUBCOMMAND[agent])
|
|
68
|
+
return 'subcommand';
|
|
69
|
+
return agent === 'claude' ? 'in-tui' : 'on-launch';
|
|
70
|
+
}
|
|
71
|
+
/** A machine-stable class for a finding — drives {@link remediationFor} and lets
|
|
72
|
+
* the JSON consumer group by kind. */
|
|
73
|
+
export const ALL_FINDING_KINDS = [
|
|
74
|
+
'logged-out', // provable per-version logout (CRITICAL)
|
|
75
|
+
'logout-unprovable', // credential absent but not provable (WARNING)
|
|
76
|
+
'missing-hook', // a declared hook absent from a version home (CRITICAL)
|
|
77
|
+
'missing-plugin', // a declared plugin absent from a version home (CRITICAL)
|
|
78
|
+
'unwired-hook', // hook present on disk but not wired into settings.json (CRITICAL)
|
|
79
|
+
'cli-missing', // a managed agent whose binary won't resolve (CRITICAL)
|
|
80
|
+
'missing-resource', // a missing command/skill/rule/mcp/permission/subagent (WARNING)
|
|
81
|
+
'content-drift', // a resource diverged from source (WARNING)
|
|
82
|
+
'never-synced', // installed but never synced — CRITICAL when its declared
|
|
83
|
+
// resources are therefore absent, WARNING when it declares none
|
|
84
|
+
'stale', // sources changed since last sync (WARNING)
|
|
85
|
+
'repo-behind', // a config repo behind origin (WARNING)
|
|
86
|
+
'repo-drift', // a config repo diverged from the fleet baseline (WARNING)
|
|
87
|
+
'fleet-resource-gap', // a resource in another box's central repos, absent here (WARNING)
|
|
88
|
+
'host-cli-missing', // a declared host CLI not installed on this box (WARNING)
|
|
89
|
+
'host-cli-invalid', // a host-CLI manifest that failed to parse (WARNING)
|
|
90
|
+
'version-skew', // an agent version present elsewhere, absent here (WARNING)
|
|
91
|
+
'orphan', // orphan resources in a version home (WARNING)
|
|
92
|
+
'duplicate-hook', // one hook materialized in several version homes, byte-identical (WARNING)
|
|
93
|
+
'duplicate-hook-drift', // …with differing content, so a stale copy can disagree (CRITICAL)
|
|
94
|
+
'rc-secret-export', // credential-shaped export in a shell rc file (WARNING)
|
|
95
|
+
'exec-policy', // Windows execution policy blocks agents.ps1 (WARNING)
|
|
96
|
+
'stale-cli',
|
|
97
|
+
];
|
|
98
|
+
function agentName(agent) {
|
|
99
|
+
return AGENT_NAMES[agent] || agent;
|
|
100
|
+
}
|
|
101
|
+
/** Agent ids in the registry's display order — the one stable ordering, used
|
|
102
|
+
* wherever output would otherwise inherit a map's insertion order. */
|
|
103
|
+
function sortedAgentIds(ids) {
|
|
104
|
+
const rank = (a) => {
|
|
105
|
+
const i = ALL_AGENT_IDS.indexOf(a);
|
|
106
|
+
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
|
107
|
+
};
|
|
108
|
+
return [...ids].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The exact remediation for a finding. Login fixes are harness-native
|
|
112
|
+
* (`loginHint`); a per-version login is offered ONLY for agents that isolate the
|
|
113
|
+
* credential per home (`agents run <agent>@<version>` then log in) — for
|
|
114
|
+
* gemini/antigravity/droid/cursor the login is shared, so we say so instead of
|
|
115
|
+
* faking a per-version fix. Every other kind maps to its canonical command.
|
|
116
|
+
*/
|
|
117
|
+
export function remediationFor(finding) {
|
|
118
|
+
const { kind, agent, version } = finding;
|
|
119
|
+
const idLabel = agent && version ? `${agent}@${version}` : agent ?? '';
|
|
120
|
+
switch (kind) {
|
|
121
|
+
case 'logged-out':
|
|
122
|
+
case 'logout-unprovable': {
|
|
123
|
+
if (!agent)
|
|
124
|
+
return 'log in';
|
|
125
|
+
const native = loginHint(agent);
|
|
126
|
+
if (!version || NO_PER_VERSION_LOGIN.has(agent)) {
|
|
127
|
+
// Shared login across versions — no per-version isolation to target.
|
|
128
|
+
return NO_PER_VERSION_LOGIN.has(agent)
|
|
129
|
+
? `${native} (shared across all ${agentName(agent)} versions)`
|
|
130
|
+
: native;
|
|
131
|
+
}
|
|
132
|
+
// Isolated per-version home: the login must RUN INSIDE that home. A bare
|
|
133
|
+
// `<cli> login` afterwards would NOT — the native shim resolves to the
|
|
134
|
+
// project/default version (`lib/shims.ts:471-474`), so it would log into
|
|
135
|
+
// whichever version is default, not the one that is logged out.
|
|
136
|
+
switch (loginShape(agent)) {
|
|
137
|
+
case 'subcommand':
|
|
138
|
+
// `-- <args>` is forwarded verbatim to the binary in that version home
|
|
139
|
+
// (`commands/exec.ts:723`, `lib/exec.ts:1004`) — one correct invocation.
|
|
140
|
+
return `agents run ${idLabel} -- ${LOGIN_SUBCOMMAND[agent]}`;
|
|
141
|
+
case 'in-tui':
|
|
142
|
+
// Claude has no login subcommand; it logs in from inside its own TUI.
|
|
143
|
+
return `agents run ${idLabel}, then /login`;
|
|
144
|
+
case 'on-launch':
|
|
145
|
+
// The device/oauth flow starts when the agent launches — nothing to add.
|
|
146
|
+
return `agents run ${idLabel}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
case 'missing-hook':
|
|
150
|
+
case 'missing-plugin':
|
|
151
|
+
case 'unwired-hook':
|
|
152
|
+
case 'missing-resource':
|
|
153
|
+
case 'content-drift':
|
|
154
|
+
case 'stale':
|
|
155
|
+
return idLabel ? `agents doctor ${idLabel} --fix` : 'agents doctor --fix';
|
|
156
|
+
case 'never-synced':
|
|
157
|
+
// A bare `agents sync <agent>` targets only the default/sole installed
|
|
158
|
+
// version (`commands/sync.ts:8`), so a row collapsed across versions must
|
|
159
|
+
// ask for the `@all` selector or it silently fixes just one of them.
|
|
160
|
+
if (!agent)
|
|
161
|
+
return 'agents sync';
|
|
162
|
+
return version ? `agents sync ${agent}@${version} --yes` : `agents sync ${agent}@all --yes`;
|
|
163
|
+
case 'cli-missing':
|
|
164
|
+
return agent ? `agents add ${agent}` : 'agents add <agent>';
|
|
165
|
+
case 'orphan':
|
|
166
|
+
// Without `--all`, cleanup sweeps only each agent's DEFAULT version
|
|
167
|
+
// (`commands/prune.ts:351`, `collectOrphans(..., options.all === true)`) —
|
|
168
|
+
// and this row aggregates every version on the machine.
|
|
169
|
+
return 'agents prune cleanup --all';
|
|
170
|
+
case 'repo-behind':
|
|
171
|
+
return `agents repo pull ${finding.version ?? 'user'}`;
|
|
172
|
+
case 'repo-drift':
|
|
173
|
+
// `version` carries the repo alias (`user` for ~/.agents, `system` for
|
|
174
|
+
// ~/.agents/.system) — hardcoding `user` would send a `.system` drift at
|
|
175
|
+
// the wrong repo.
|
|
176
|
+
return `agents repo pull ${version ?? 'user'}`;
|
|
177
|
+
case 'fleet-resource-gap':
|
|
178
|
+
// The resource is absent from this box's CENTRAL repos, not from a version
|
|
179
|
+
// home, so `agents doctor --fix` (which reconciles central -> homes) has
|
|
180
|
+
// nothing to copy. The divergence row cannot say WHICH repo declares it,
|
|
181
|
+
// and neither `agents repo pull` nor the sync umbrella touches the system
|
|
182
|
+
// repo (`commands/repo.ts:1186`, `lib/sync-umbrella.ts:104`) — that one is
|
|
183
|
+
// npm-shipped and moves with the CLI. So name both paths rather than a
|
|
184
|
+
// single command that silently covers half the cases.
|
|
185
|
+
return 'agents repo pull user (or upgrade agents-cli if it ships in .system)';
|
|
186
|
+
case 'version-skew':
|
|
187
|
+
return idLabel ? `agents add ${idLabel}` : 'agents add <agent>@<version>';
|
|
188
|
+
case 'duplicate-hook':
|
|
189
|
+
case 'duplicate-hook-drift':
|
|
190
|
+
// The copies live in SEVERAL version homes, so the reconcile has to reach
|
|
191
|
+
// every one — `agents sync <agent>@<one-version>` would leave the others
|
|
192
|
+
// holding their stale copy.
|
|
193
|
+
return agent ? `agents sync ${agent}@all --yes` : 'agents sync';
|
|
194
|
+
case 'host-cli-missing':
|
|
195
|
+
return 'agents cli install';
|
|
196
|
+
case 'host-cli-invalid':
|
|
197
|
+
// Nothing installs a manifest the loader cannot parse — the file has to be
|
|
198
|
+
// fixed where it is declared.
|
|
199
|
+
return 'fix the manifest';
|
|
200
|
+
case 'rc-secret-export':
|
|
201
|
+
return 'agents secrets add';
|
|
202
|
+
case 'exec-policy':
|
|
203
|
+
return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
|
|
204
|
+
case 'stale-cli':
|
|
205
|
+
return 'upgrade';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function finding(f) {
|
|
209
|
+
return { ...f, remediation: remediationFor({ ...f, remediation: '' }) };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Emit at most ONE finding for a list of same-kind resources on one version.
|
|
213
|
+
* A single affected resource is named in full (`hook 'git-guard' missing`); two
|
|
214
|
+
* or more collapse into a count plus the first two subjects
|
|
215
|
+
* (`32 hooks missing (incl. 'git-guard', 'rm-guard')`). Naming every item — the
|
|
216
|
+
* pre-RUSH-2069-review behavior — flooded the section with dozens of near-identical
|
|
217
|
+
* rows for one root cause; the count carries the same signal and `--fix` is the
|
|
218
|
+
* same command either way.
|
|
219
|
+
*/
|
|
220
|
+
function emitGroup(out, items, severity, kind, device, agent, version, noun, verb) {
|
|
221
|
+
if (items.length === 0)
|
|
222
|
+
return;
|
|
223
|
+
const message = items.length === 1
|
|
224
|
+
? items[0].full
|
|
225
|
+
: `${items.length} ${noun}s ${verb} (incl. ${items.slice(0, 2).map((i) => i.short).join(', ')})`;
|
|
226
|
+
out.push(finding({ severity, kind, device, agent, version, message }));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Fold this machine's signals into findings. Missing hooks/plugins and unwired
|
|
230
|
+
* hooks are CRITICAL; provable logouts are CRITICAL and unprovable ones WARNING;
|
|
231
|
+
* everything else (other missing kinds, drift, stale/never-synced, repo-behind,
|
|
232
|
+
* orphans) is a WARNING. Pure.
|
|
233
|
+
*/
|
|
234
|
+
export function buildLocalFindings(input) {
|
|
235
|
+
const out = [];
|
|
236
|
+
const device = input.device;
|
|
237
|
+
/** `<agent>@<version>` keys that already named their specific drift/missing
|
|
238
|
+
* resources below — a `stale` row for those would repeat the same fact in
|
|
239
|
+
* vaguer words, so it is suppressed. */
|
|
240
|
+
const detailedVersions = new Set();
|
|
241
|
+
// cli-missing (managed agent, binary broken) — critical.
|
|
242
|
+
for (const agent of input.cliMissing ?? []) {
|
|
243
|
+
out.push(finding({
|
|
244
|
+
severity: 'critical', kind: 'cli-missing', device, agent,
|
|
245
|
+
message: `${agentName(agent)} binary not found`,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
// Per-version resource reports → missing hook/plugin (critical), unwired hook
|
|
249
|
+
// (critical), other missing kinds (warning), content drift (warning).
|
|
250
|
+
for (const report of input.reports) {
|
|
251
|
+
const agent = report.agent;
|
|
252
|
+
const version = report.version;
|
|
253
|
+
const w = report.hookWiring;
|
|
254
|
+
if (w?.supported) {
|
|
255
|
+
if (w.settingsMissing) {
|
|
256
|
+
out.push(finding({
|
|
257
|
+
severity: 'critical', kind: 'unwired-hook', device, agent, version,
|
|
258
|
+
message: `settings.json missing — ${w.expected ?? 0} declared hook${(w.expected ?? 0) === 1 ? '' : 's'} never fire`,
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
else if (w.settingsUnparseable) {
|
|
262
|
+
out.push(finding({
|
|
263
|
+
severity: 'critical', kind: 'unwired-hook', device, agent, version,
|
|
264
|
+
message: `settings.json unparseable — hook wiring can't be verified`,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
for (const u of w.unwired) {
|
|
269
|
+
out.push(finding({
|
|
270
|
+
severity: 'critical', kind: 'unwired-hook', device, agent, version,
|
|
271
|
+
message: `hook '${u.name}' present on disk but not wired into settings.json`,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// A never-synced version has EVERY declared resource "missing" — that's one
|
|
277
|
+
// root cause (never synced), not one emergency per hook. Collapse it to a
|
|
278
|
+
// single critical rather than flooding the top section with 100+ lines. The
|
|
279
|
+
// per-version `never-synced` warning below carries the sync remediation.
|
|
280
|
+
const neverSynced = input.syncRows.some((s) => s.agent === agent && s.version === version && s.status === 'never-synced');
|
|
281
|
+
const missingHooks = [];
|
|
282
|
+
const missingPlugins = [];
|
|
283
|
+
const missingOther = [];
|
|
284
|
+
const drifted = [];
|
|
285
|
+
for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents', 'plugins', 'promptcuts']) {
|
|
286
|
+
const singular = kind.replace(/s$/, '');
|
|
287
|
+
for (const r of report.kinds[kind] ?? []) {
|
|
288
|
+
if (r.status === 'missing') {
|
|
289
|
+
if (kind === 'hooks')
|
|
290
|
+
missingHooks.push({ short: `'${r.name}'`, full: `hook '${r.name}' missing` });
|
|
291
|
+
else if (kind === 'plugins')
|
|
292
|
+
missingPlugins.push({ short: `'${r.name}'`, full: `plugin '${r.name}' missing` });
|
|
293
|
+
else
|
|
294
|
+
missingOther.push({ short: `${singular} '${r.name}'`, full: `${singular} '${r.name}' missing` });
|
|
295
|
+
}
|
|
296
|
+
else if (r.status === 'diff') {
|
|
297
|
+
drifted.push({
|
|
298
|
+
short: `${singular} '${r.name}'`,
|
|
299
|
+
full: r.detail
|
|
300
|
+
? `${singular} '${r.name}' — ${r.detail}`
|
|
301
|
+
: `${singular} '${r.name}' changed upstream — re-sync`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (neverSynced) {
|
|
307
|
+
// Everything is "missing" because it was never synced — one line, and a
|
|
308
|
+
// CRITICAL one: nothing this version declares is actually installed.
|
|
309
|
+
const total = missingHooks.length + missingPlugins.length + missingOther.length;
|
|
310
|
+
if (total > 0) {
|
|
311
|
+
const breakdown = [
|
|
312
|
+
missingHooks.length ? `${missingHooks.length} hook${missingHooks.length === 1 ? '' : 's'}` : '',
|
|
313
|
+
missingPlugins.length ? `${missingPlugins.length} plugin${missingPlugins.length === 1 ? '' : 's'}` : '',
|
|
314
|
+
].filter(Boolean).join(', ');
|
|
315
|
+
out.push(finding({
|
|
316
|
+
severity: 'critical', kind: 'never-synced', device, agent, version,
|
|
317
|
+
message: `never synced — ${total} resource${total === 1 ? '' : 's'}${breakdown ? ` (incl. ${breakdown})` : ''} not installed`,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Synced-but-drifted: one line per kind of gap on this version.
|
|
323
|
+
emitGroup(out, missingHooks, 'critical', 'missing-hook', device, agent, version, 'hook', 'missing');
|
|
324
|
+
emitGroup(out, missingPlugins, 'critical', 'missing-plugin', device, agent, version, 'plugin', 'missing');
|
|
325
|
+
emitGroup(out, missingOther, 'warning', 'missing-resource', device, agent, version, 'resource', 'missing');
|
|
326
|
+
emitGroup(out, drifted, 'warning', 'content-drift', device, agent, version, 'resource', 'drifted');
|
|
327
|
+
if (missingHooks.length + missingPlugins.length + missingOther.length + drifted.length > 0) {
|
|
328
|
+
detailedVersions.add(`${agent}@${version}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Sync status → stale (warning). A NEVER-SYNCED version already surfaced a
|
|
333
|
+
// single collapsed critical above (its resources aren't installed at all), so
|
|
334
|
+
// we don't ALSO emit a never-synced warning — that would double-report the same
|
|
335
|
+
// root cause. Likewise a version that just listed its drifted/missing resources
|
|
336
|
+
// by name gets no vaguer `sources changed since last sync` row on top.
|
|
337
|
+
for (const row of input.syncRows) {
|
|
338
|
+
if (row.status === 'stale') {
|
|
339
|
+
if (detailedVersions.has(`${row.agent}@${row.version}`))
|
|
340
|
+
continue;
|
|
341
|
+
out.push(finding({
|
|
342
|
+
severity: 'warning', kind: 'stale', device, agent: row.agent, version: row.version,
|
|
343
|
+
message: 'sources changed since last sync',
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
else if (row.status === 'never-synced') {
|
|
347
|
+
// Only surface a never-synced warning when the collapsed critical above did
|
|
348
|
+
// NOT fire (a version with zero declared resources to miss — nothing landed
|
|
349
|
+
// in the critical section, so name the never-synced state here).
|
|
350
|
+
const hadCritical = input.reports.some((rep) => rep.agent === row.agent && rep.version === row.version &&
|
|
351
|
+
Object.values(rep.kinds).some((rows) => rows.some((r) => r.status === 'missing')));
|
|
352
|
+
if (!hadCritical) {
|
|
353
|
+
out.push(finding({
|
|
354
|
+
severity: 'warning', kind: 'never-synced', device, agent: row.agent, version: row.version,
|
|
355
|
+
message: 'installed but never synced',
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Repo-behind markers (warning). `version` carries the alias so remediationFor
|
|
361
|
+
// can build `agents repo pull <alias>`.
|
|
362
|
+
for (const m of input.repoBehind) {
|
|
363
|
+
if (m.behind <= 0)
|
|
364
|
+
continue;
|
|
365
|
+
const stales = input.syncRows.filter((r) => r.status === 'stale').length;
|
|
366
|
+
const staleNote = stales > 0 ? ` → stales ${stales} version${stales === 1 ? '' : 's'}` : '';
|
|
367
|
+
out.push(finding({
|
|
368
|
+
severity: 'warning', kind: 'repo-behind', device, version: m.alias,
|
|
369
|
+
message: `${m.behind} behind ${m.branch}${staleNote}`,
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
// Orphans (warning) — ONE line for the whole device. Orphans are cleanup-only
|
|
373
|
+
// and `agents prune cleanup` fixes every version at once, so a row per version
|
|
374
|
+
// was the single largest block of noise in the readout for zero added action.
|
|
375
|
+
out.push(...orphanFinding(device, input.orphanRows));
|
|
376
|
+
// Host CLIs declared but not on PATH. One row for the machine, naming the
|
|
377
|
+
// count and two examples — `agents cli install <name>` is per-CLI, so the
|
|
378
|
+
// names have to survive into the message.
|
|
379
|
+
const missingClis = (input.hostClis?.statuses ?? []).filter((c) => !c.installed).map((c) => c.name);
|
|
380
|
+
if (missingClis.length > 0) {
|
|
381
|
+
out.push({
|
|
382
|
+
severity: 'warning', kind: 'host-cli-missing', device,
|
|
383
|
+
message: missingClis.length === 1
|
|
384
|
+
? `host CLI '${missingClis[0]}' declared but not installed`
|
|
385
|
+
: `${missingClis.length} declared host CLIs not installed (${missingClis.slice(0, 2).join(', ')}${missingClis.length > 2 ? ', …' : ''})`,
|
|
386
|
+
// `agents cli install <name>` takes ONE optional name; with none it installs
|
|
387
|
+
// every declared CLI that is missing (`commands/cli.ts:118,133-146`). A
|
|
388
|
+
// second positional — or a literal ellipsis — is not a runnable command.
|
|
389
|
+
remediation: missingClis.length === 1
|
|
390
|
+
? `agents cli install ${missingClis[0]}`
|
|
391
|
+
: 'agents cli install',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// A manifest the loader rejected declares a CLI that can never install — one
|
|
395
|
+
// row per bad file, since each needs its own edit.
|
|
396
|
+
for (const e of input.hostClis?.errors ?? []) {
|
|
397
|
+
out.push(finding({
|
|
398
|
+
severity: 'warning', kind: 'host-cli-invalid', device,
|
|
399
|
+
message: `host-CLI manifest ${e.file} could not be read: ${e.reason}`,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
out.push(...duplicateHookFindings(device, input.duplicateHooks ?? []));
|
|
403
|
+
// Credential-shaped exports in shell rc files (RUSH-1968) — a warning per class
|
|
404
|
+
// of fix: the file-store master key moves to its own file, everything else goes
|
|
405
|
+
// into `agents secrets`.
|
|
406
|
+
for (const f of rcSecretFindings(device, input.rcSecrets ?? []))
|
|
407
|
+
out.push(f);
|
|
408
|
+
// Windows execution policy blocking the generated agents.ps1 launcher.
|
|
409
|
+
const policyFinding = execPolicyFinding(device, input.execPolicy);
|
|
410
|
+
if (policyFinding)
|
|
411
|
+
out.push(policyFinding);
|
|
412
|
+
// Per-version sign-in → logged-out (critical, provable) / logout-unprovable
|
|
413
|
+
// (warning). Signed-in versions produce no finding — the accounts line shows
|
|
414
|
+
// them. Agents that can't be inspected never yield a logout finding.
|
|
415
|
+
out.push(...signInToFindings(device, input.signIn));
|
|
416
|
+
return collapseAcrossVersions(out, new Set(input.isolatedVersions ?? []));
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Fold every orphan row on a device into one warning. Returns `[]` when nothing
|
|
420
|
+
* is orphaned. Pure.
|
|
421
|
+
*/
|
|
422
|
+
function orphanFinding(device, rows) {
|
|
423
|
+
const affected = rows.filter((r) => r.commands + r.skills + r.hooks > 0);
|
|
424
|
+
if (affected.length === 0)
|
|
425
|
+
return [];
|
|
426
|
+
const total = affected.reduce((n, r) => n + r.commands + r.skills + r.hooks, 0);
|
|
427
|
+
const where = affected.length === 1
|
|
428
|
+
? `${affected[0].agent}@${affected[0].version}`
|
|
429
|
+
: `${affected.length} versions`;
|
|
430
|
+
return [finding({
|
|
431
|
+
severity: 'warning', kind: 'orphan', device,
|
|
432
|
+
message: `${total} orphaned resource${total === 1 ? '' : 's'} on ${where} (cleanup only)`,
|
|
433
|
+
})];
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Findings for hooks materialized into several version homes at once. Differing
|
|
437
|
+
* content is CRITICAL — a stale copy can gate differently from the active one;
|
|
438
|
+
* byte-identical copies are a WARNING (noise plus duplicated runtime cost).
|
|
439
|
+
*
|
|
440
|
+
* One row per (agent, severity), not per hook: `agents sync <agent>@<active>
|
|
441
|
+
* --yes` reconciles every copy in one command, and a machine with five installed
|
|
442
|
+
* claudes otherwise emits two dozen identical rows. Pure.
|
|
443
|
+
*/
|
|
444
|
+
function duplicateHookFindings(device, dups) {
|
|
445
|
+
const out = [];
|
|
446
|
+
const byAgentKind = new Map();
|
|
447
|
+
for (const d of dups) {
|
|
448
|
+
const key = `${d.agent} ${d.kind}`;
|
|
449
|
+
if (!byAgentKind.has(key))
|
|
450
|
+
byAgentKind.set(key, []);
|
|
451
|
+
byAgentKind.get(key).push(d);
|
|
452
|
+
}
|
|
453
|
+
for (const group of byAgentKind.values()) {
|
|
454
|
+
const drift = group[0].kind === 'drift';
|
|
455
|
+
const agent = group[0].agent;
|
|
456
|
+
const active = group[0].authoritative.version;
|
|
457
|
+
const versions = Array.from(new Set(group.flatMap((d) => d.copies.map((c) => c.version))));
|
|
458
|
+
const authority = `${active} is authoritative`;
|
|
459
|
+
const message = group.length === 1
|
|
460
|
+
? drift
|
|
461
|
+
? `hook '${group[0].name}' differs across ${versions.join(', ')} — ${authority}`
|
|
462
|
+
: `hook '${group[0].name}' duplicated (identical) across ${versions.join(', ')} — ${authority}`
|
|
463
|
+
: `${group.length} hooks ${drift ? 'differ' : 'duplicated (identical)'} across ` +
|
|
464
|
+
`${versions.length} version${versions.length === 1 ? '' : 's'} ` +
|
|
465
|
+
`(incl. ${group.slice(0, 2).map((d) => `'${d.name}'`).join(', ')}) — ${authority}`;
|
|
466
|
+
out.push({
|
|
467
|
+
severity: drift ? 'critical' : 'warning',
|
|
468
|
+
kind: drift ? 'duplicate-hook-drift' : 'duplicate-hook',
|
|
469
|
+
device, agent, versions, message,
|
|
470
|
+
remediation: `agents sync ${agent}@all --yes`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Warnings for credential-shaped exports in the user's shell rc files. The
|
|
477
|
+
* file-store master passphrase and ordinary credentials have different fixes, so
|
|
478
|
+
* they get one row each (never one per export — the count plus two examples
|
|
479
|
+
* carries the same signal). Pure.
|
|
480
|
+
*/
|
|
481
|
+
function rcSecretFindings(device, rc) {
|
|
482
|
+
if (rc.length === 0)
|
|
483
|
+
return [];
|
|
484
|
+
const out = [];
|
|
485
|
+
const groups = [
|
|
486
|
+
{
|
|
487
|
+
rows: rc.filter((f) => f.isMasterPassphrase),
|
|
488
|
+
remediation: 'move it to ~/.agents/.secrets-key/passphrase (chmod 600)',
|
|
489
|
+
},
|
|
490
|
+
// `agents secrets add [bundle] [key]` stores exactly ONE variable
|
|
491
|
+
// (`commands/secrets.ts:1349`), and nothing edits the rc file — so an
|
|
492
|
+
// aggregated row has to say both that the command repeats and that the
|
|
493
|
+
// deletion is manual, or following it once leaves most of the leak in place.
|
|
494
|
+
{ rows: rc.filter((f) => !f.isMasterPassphrase), remediation: '' },
|
|
495
|
+
];
|
|
496
|
+
for (const g of groups) {
|
|
497
|
+
if (g.rows.length === 0)
|
|
498
|
+
continue;
|
|
499
|
+
const n = g.rows.length;
|
|
500
|
+
const examples = g.rows.slice(0, 2).map((f) => `${f.file}:${f.line} ${f.name}`).join(', ');
|
|
501
|
+
const master = g.rows[0].isMasterPassphrase;
|
|
502
|
+
const what = master
|
|
503
|
+
? `file-store master key exported from a shell rc file`
|
|
504
|
+
: `${n} credential-shaped export${n === 1 ? '' : 's'} in shell rc files`;
|
|
505
|
+
const remediation = master
|
|
506
|
+
? g.remediation
|
|
507
|
+
: n === 1
|
|
508
|
+
? 'agents secrets add, then delete the rc line'
|
|
509
|
+
: `agents secrets add once per export (${n}), then delete each rc line`;
|
|
510
|
+
out.push({
|
|
511
|
+
severity: 'warning', kind: 'rc-secret-export', device,
|
|
512
|
+
message: `${what} (${examples}) — readable by any same-user process`,
|
|
513
|
+
remediation,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The Windows-only advisory: when the effective PowerShell execution policy
|
|
520
|
+
* blocks unsigned local scripts (`Restricted`/`AllSigned`), the generated
|
|
521
|
+
* `agents.ps1` launcher fails even though it is on PATH. The `agents.cmd`
|
|
522
|
+
* companion still works, so this is a warning and doctor never changes the
|
|
523
|
+
* policy itself. Returns null off Windows or on a permissive policy — pure, so
|
|
524
|
+
* both branches are testable without invoking PowerShell.
|
|
525
|
+
*/
|
|
526
|
+
function execPolicyFinding(device, execPolicy) {
|
|
527
|
+
if (!execPolicy || execPolicy.platform !== 'win32')
|
|
528
|
+
return null;
|
|
529
|
+
if (!blocksLocalScripts(execPolicy.policy))
|
|
530
|
+
return null;
|
|
531
|
+
return finding({
|
|
532
|
+
severity: 'warning', kind: 'exec-policy', device,
|
|
533
|
+
message: `PowerShell execution policy is ${execPolicy.policy} — it blocks the generated agents.ps1 launcher (agents.cmd still works)`,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Fold findings that say the SAME thing about several versions of one agent into
|
|
538
|
+
* a single row carrying `versions`, and widen its remediation to the agent-wide
|
|
539
|
+
* sweep (`agents doctor claude --fix` heals every non-isolated version in one
|
|
540
|
+
* go). Five identical `plugin 'code' — mirror missing` rows, one per installed
|
|
541
|
+
* claude, is the same fact five times.
|
|
542
|
+
*
|
|
543
|
+
* Three things never merge, because for each of them the widened remediation
|
|
544
|
+
* would be wrong:
|
|
545
|
+
* - **Isolated copies** — the agent-wide sweep deliberately skips them
|
|
546
|
+
* (`runFix`), so a folded row would print a command that leaves one broken.
|
|
547
|
+
* - **Findings with no agent** (repo-behind, rc-secret-export, …) — their
|
|
548
|
+
* `version` field is an alias, not a version.
|
|
549
|
+
* - **Logouts** ({@link NEVER_COLLAPSED}) — a login is inherently per-version:
|
|
550
|
+
* the fix is `agents run <agent>@<version> -- login`, and there is no `@all`
|
|
551
|
+
* equivalent. Dropping `version` would fall back to the bare native hint,
|
|
552
|
+
* which the shim resolves to the DEFAULT version — logging into the wrong one
|
|
553
|
+
* and leaving the finding to reappear.
|
|
554
|
+
*
|
|
555
|
+
* Pure; input order is kept.
|
|
556
|
+
*/
|
|
557
|
+
export function collapseAcrossVersions(findings, isolated) {
|
|
558
|
+
const groups = new Map();
|
|
559
|
+
const order = [];
|
|
560
|
+
for (const f of findings) {
|
|
561
|
+
const mergeable = f.agent && f.version
|
|
562
|
+
&& !isolated.has(`${f.agent}@${f.version}`)
|
|
563
|
+
&& !NEVER_COLLAPSED.has(f.kind);
|
|
564
|
+
// A non-mergeable finding gets a unique key so it passes through untouched.
|
|
565
|
+
const key = mergeable
|
|
566
|
+
// `account` is part of the key: two versions with the SAME problem but
|
|
567
|
+
// DIFFERENT signed-in accounts are not one row — merging them would
|
|
568
|
+
// attribute every version to the first member's account.
|
|
569
|
+
? `${f.device}\0${f.agent}\0${f.kind}\0${f.severity}\0${f.account ?? ''}\0${f.message}`
|
|
570
|
+
: `${order.length}`;
|
|
571
|
+
if (!groups.has(key)) {
|
|
572
|
+
groups.set(key, []);
|
|
573
|
+
order.push(key);
|
|
574
|
+
}
|
|
575
|
+
groups.get(key).push(f);
|
|
576
|
+
}
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const key of order) {
|
|
579
|
+
const group = groups.get(key);
|
|
580
|
+
if (group.length === 1) {
|
|
581
|
+
out.push(group[0]);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const versions = group.map((f) => f.version);
|
|
585
|
+
const merged = {
|
|
586
|
+
...group[0], version: undefined, versions, remediation: '',
|
|
587
|
+
};
|
|
588
|
+
merged.remediation = remediationFor(merged);
|
|
589
|
+
out.push(merged);
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Map a device's per-version sign-in into logout findings: a PROVABLE logout is
|
|
595
|
+
* CRITICAL, an unprovable one is a hedged WARNING ("could not verify sign-in"),
|
|
596
|
+
* and a signed-in version yields nothing.
|
|
597
|
+
*
|
|
598
|
+
* An agent with no inspectable identity never appears at all — not even as the
|
|
599
|
+
* hedged warning: agents-cli knows no credential file for it, so "logged out" is
|
|
600
|
+
* unknowable and silence beats a false claim. Membership is
|
|
601
|
+
* `supportsAccountInspection` (`lib/agents.ts`) and is deliberately NOT listed
|
|
602
|
+
* here — agents move between the sets, and a copy of the list in prose becomes a
|
|
603
|
+
* lie the next time one does. Note the caller also requires
|
|
604
|
+
* `CredentialPresence.knownLocation`: the inspection set and the credential-path
|
|
605
|
+
* map move independently, so being inspectable is not on its own enough to call a
|
|
606
|
+
* logout provable. Pure.
|
|
607
|
+
*/
|
|
608
|
+
export function signInToFindings(device, signIn) {
|
|
609
|
+
const out = [];
|
|
610
|
+
// Iterate in the registry's agent order, NOT the map's insertion order:
|
|
611
|
+
// `collectLocalFleetSignIn` fills its map inside a `Promise.all`, so key order
|
|
612
|
+
// is whatever order the account probes happened to finish in. The renderer
|
|
613
|
+
// preserves input order within a device, so consuming the map order directly
|
|
614
|
+
// would make two runs on identical state print their logout rows differently.
|
|
615
|
+
for (const agentId of sortedAgentIds(Object.keys(signIn))) {
|
|
616
|
+
const rows = signIn[agentId];
|
|
617
|
+
const agent = agentId;
|
|
618
|
+
if (!supportsAccountInspection(agent))
|
|
619
|
+
continue;
|
|
620
|
+
for (const row of rows) {
|
|
621
|
+
if (row.signedIn)
|
|
622
|
+
continue;
|
|
623
|
+
if (row.provable) {
|
|
624
|
+
out.push(finding({
|
|
625
|
+
severity: 'critical', kind: 'logged-out', device, agent, version: row.version,
|
|
626
|
+
account: row.account ?? null,
|
|
627
|
+
message: 'logged out — no account signed in',
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
out.push(finding({
|
|
632
|
+
severity: 'warning', kind: 'logout-unprovable', device, agent, version: row.version,
|
|
633
|
+
account: row.account ?? null,
|
|
634
|
+
message: 'could not verify sign-in',
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Map cross-device divergence (from {@link compareFleetInventories}) into
|
|
643
|
+
* warnings: an agent version present elsewhere but absent on a device is a
|
|
644
|
+
* version-skew warning; a diverged config repo is a repo-drift warning; a
|
|
645
|
+
* missing resource is a missing-resource warning. Baseline = the local machine.
|
|
646
|
+
* Only the *lagging* box is attributed (a `*-missing-local` finding is the
|
|
647
|
+
* baseline's gap). Pure.
|
|
648
|
+
*/
|
|
649
|
+
export function fleetDivergenceToFindings(divergences, baseline) {
|
|
650
|
+
const out = [];
|
|
651
|
+
for (const d of divergences) {
|
|
652
|
+
const laggingDevice = d.kind.endsWith('-missing-local') ? baseline : d.device;
|
|
653
|
+
switch (d.kind) {
|
|
654
|
+
case 'agent-version-missing-remote':
|
|
655
|
+
case 'agent-version-missing-local':
|
|
656
|
+
out.push(finding({
|
|
657
|
+
severity: 'warning', kind: 'version-skew', device: laggingDevice,
|
|
658
|
+
agent: d.category, version: d.name,
|
|
659
|
+
message: 'not installed (present elsewhere in the fleet)',
|
|
660
|
+
}));
|
|
661
|
+
break;
|
|
662
|
+
case 'repo-drift':
|
|
663
|
+
out.push(finding({
|
|
664
|
+
severity: 'warning', kind: 'repo-drift', device: laggingDevice,
|
|
665
|
+
// `category` is the repo ('agents' | 'system'); carry it as the alias
|
|
666
|
+
// `agents repo pull` expects — ~/.agents is the `user` repo.
|
|
667
|
+
version: d.category === 'system' ? 'system' : 'user',
|
|
668
|
+
message: d.message,
|
|
669
|
+
}));
|
|
670
|
+
break;
|
|
671
|
+
case 'resource-missing-remote':
|
|
672
|
+
case 'resource-missing-local':
|
|
673
|
+
out.push(finding({
|
|
674
|
+
severity: 'warning', kind: 'fleet-resource-gap', device: laggingDevice,
|
|
675
|
+
message: `${d.category.replace(/s$/, '')} '${d.name}' missing (present elsewhere)`,
|
|
676
|
+
}));
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
// ─── rendering ──────────────────────────────────────────────────────────────
|
|
683
|
+
/** Sort key so the worst device floats to the top: criticals, then warnings. */
|
|
684
|
+
function deviceSeverityRank(findings) {
|
|
685
|
+
const crit = findings.filter((f) => f.severity === 'critical').length;
|
|
686
|
+
const warn = findings.filter((f) => f.severity === 'warning').length;
|
|
687
|
+
return crit * 1000 + warn;
|
|
688
|
+
}
|
|
689
|
+
/** `claude @2.1.170` for one version, `claude (5 versions)` for a collapsed row,
|
|
690
|
+
* the bare agent id when neither applies. */
|
|
691
|
+
function subjectLabel(f) {
|
|
692
|
+
if (!f.agent)
|
|
693
|
+
return '';
|
|
694
|
+
if (f.versions && f.versions.length > 1)
|
|
695
|
+
return `${f.agent} (${f.versions.length} versions)`;
|
|
696
|
+
return f.version ? `${f.agent} @${f.version}` : f.agent;
|
|
697
|
+
}
|
|
698
|
+
function critLabel(f) {
|
|
699
|
+
return {
|
|
700
|
+
left: subjectLabel(f),
|
|
701
|
+
account: f.account ?? '',
|
|
702
|
+
message: f.message,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** Pad a plain string to a DISPLAY width, ignoring that the caller may color it
|
|
706
|
+
* later (we pad before coloring so alignment is on visible text). Uses the
|
|
707
|
+
* repo's width helpers, not `.length`: a CJK character or a compound emoji is
|
|
708
|
+
* one-to-several UTF-16 code units but a different number of terminal columns,
|
|
709
|
+
* so `.length` skews every column in the row — and account labels, device
|
|
710
|
+
* names, and org badges are all user-supplied. */
|
|
711
|
+
function pad(s, width) {
|
|
712
|
+
return padToWidth(s, width);
|
|
713
|
+
}
|
|
714
|
+
/** Display width of the widest entry, floored at `min`. */
|
|
715
|
+
function widestOf(values, min) {
|
|
716
|
+
return Math.max(...values.map(stringWidth), min);
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Render the two-part hybrid layout from a flat findings list. Pure — returns the
|
|
720
|
+
* lines so the exact output is snapshot-tested. Criticals across ALL devices go
|
|
721
|
+
* to the top section, worst-first; the per-computer section lists each device's
|
|
722
|
+
* warnings + a `✗ N critical (above)` marker, worst device first.
|
|
723
|
+
*/
|
|
724
|
+
export function renderFindings(findings, accounts, opts) {
|
|
725
|
+
const lines = [];
|
|
726
|
+
// Header.
|
|
727
|
+
if (opts.header)
|
|
728
|
+
lines.push(opts.header);
|
|
729
|
+
lines.push('');
|
|
730
|
+
// Group by device up front: the CRITICAL section orders its rows by the SAME
|
|
731
|
+
// worst-device-first ranking the per-computer blocks use, so the two sections
|
|
732
|
+
// agree and the worst machine's criticals lead.
|
|
733
|
+
const byDevice = new Map();
|
|
734
|
+
for (const f of findings) {
|
|
735
|
+
(byDevice.get(f.device) ?? byDevice.set(f.device, []).get(f.device)).push(f);
|
|
736
|
+
}
|
|
737
|
+
// Also include devices that have accounts but no findings (a clean box still
|
|
738
|
+
// needs its block + accounts line).
|
|
739
|
+
for (const device of Object.keys(accounts)) {
|
|
740
|
+
if (!byDevice.has(device))
|
|
741
|
+
byDevice.set(device, []);
|
|
742
|
+
}
|
|
743
|
+
const devices = Array.from(byDevice.keys()).sort((a, b) => {
|
|
744
|
+
const ra = deviceSeverityRank(byDevice.get(a));
|
|
745
|
+
const rb = deviceSeverityRank(byDevice.get(b));
|
|
746
|
+
if (rb !== ra)
|
|
747
|
+
return rb - ra; // worst first
|
|
748
|
+
// Baseline (local) first among ties, then alphabetical.
|
|
749
|
+
if (a === opts.baseline)
|
|
750
|
+
return -1;
|
|
751
|
+
if (b === opts.baseline)
|
|
752
|
+
return 1;
|
|
753
|
+
return a.localeCompare(b);
|
|
754
|
+
});
|
|
755
|
+
const deviceOrder = new Map(devices.map((d, i) => [d, i]));
|
|
756
|
+
// ── CRITICAL section — all devices, worst device first, input order within ──
|
|
757
|
+
// Array.prototype.sort is stable, so equal device ranks keep the order the
|
|
758
|
+
// builders emitted — deterministic across runs.
|
|
759
|
+
const criticals = findings
|
|
760
|
+
.filter((f) => f.severity === 'critical')
|
|
761
|
+
.sort((a, b) => (deviceOrder.get(a.device) ?? 0) - (deviceOrder.get(b.device) ?? 0));
|
|
762
|
+
lines.push(`${chalk.red('✗')} ${chalk.red('CRITICAL — needs you now')} (${criticals.length})`);
|
|
763
|
+
if (criticals.length === 0) {
|
|
764
|
+
lines.push(` ${chalk.green('✓')} ${chalk.gray('nothing critical across the fleet')}`);
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
// Column widths computed on visible text.
|
|
768
|
+
const rows = criticals.map((f) => ({ f, ...critLabel(f) }));
|
|
769
|
+
const showDevice = opts.fleet;
|
|
770
|
+
const devW = showDevice ? widestOf(rows.map((r) => r.f.device), 6) : 0;
|
|
771
|
+
const leftW = widestOf(rows.map((r) => r.left), 4);
|
|
772
|
+
const acctW = widestOf(rows.map((r) => r.account), 0);
|
|
773
|
+
const msgW = widestOf(rows.map((r) => r.message), 4);
|
|
774
|
+
for (const r of rows) {
|
|
775
|
+
const dev = showDevice ? `${pad(r.f.device, devW)} ` : '';
|
|
776
|
+
const left = pad(r.left, leftW);
|
|
777
|
+
const acct = acctW > 0 ? ` ${pad(r.account, acctW)}` : '';
|
|
778
|
+
const msg = pad(r.message, msgW);
|
|
779
|
+
lines.push(` ${chalk.hex('#a3e635')(dev)}${chalk.bold(left)}${acct ? chalk.cyan(acct) : ''} ${msg} ${chalk.blue('→')} ${chalk.blue(r.f.remediation)}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// ── by-computer section ──
|
|
783
|
+
if (opts.fleet) {
|
|
784
|
+
lines.push('');
|
|
785
|
+
lines.push(chalk.gray('─── by computer ───'));
|
|
786
|
+
}
|
|
787
|
+
for (const device of devices) {
|
|
788
|
+
const df = byDevice.get(device);
|
|
789
|
+
lines.push('');
|
|
790
|
+
const critN = df.filter((f) => f.severity === 'critical').length;
|
|
791
|
+
const tags = [];
|
|
792
|
+
if (device === opts.baseline)
|
|
793
|
+
tags.push('this machine');
|
|
794
|
+
const tagStr = tags.length ? chalk.gray(` · ${tags.join(' · ')}`) : '';
|
|
795
|
+
const critMarker = critN > 0
|
|
796
|
+
? ` ${chalk.red(`✗ ${critN} critical (above)`)}`
|
|
797
|
+
: '';
|
|
798
|
+
lines.push(`${chalk.hex('#a3e635')(`▸ ${device}`)}${tagStr}${critMarker}`);
|
|
799
|
+
// Warnings for this device.
|
|
800
|
+
const warnings = df.filter((f) => f.severity === 'warning');
|
|
801
|
+
if (warnings.length === 0) {
|
|
802
|
+
lines.push(` ${chalk.green('✓')} ${chalk.gray('no warnings')}`);
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
const subjW = widestOf(warnings.map(warningSubject), 4);
|
|
806
|
+
for (const w of warnings) {
|
|
807
|
+
const subj = pad(warningSubject(w), subjW);
|
|
808
|
+
lines.push(` ${chalk.yellow('⚠')} ${chalk.yellow(subj)} ${w.message} ${chalk.blue('→')} ${chalk.blue(w.remediation)}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// Accounts / versions line for this device.
|
|
812
|
+
const acctLine = renderAccountsLine(accounts[device] ?? {});
|
|
813
|
+
if (acctLine)
|
|
814
|
+
lines.push(` ${acctLine}`);
|
|
815
|
+
}
|
|
816
|
+
return lines;
|
|
817
|
+
}
|
|
818
|
+
/** The left-hand subject label for a warning row (agent@version, repo alias, or
|
|
819
|
+
* a short category). */
|
|
820
|
+
function warningSubject(f) {
|
|
821
|
+
// Both the user and system repos live under ~/.agents — name which one, or the
|
|
822
|
+
// two rows read as duplicates of each other.
|
|
823
|
+
if (f.kind === 'repo-behind')
|
|
824
|
+
return f.version ? `~/.agents (${f.version})` : '~/.agents';
|
|
825
|
+
if (f.kind === 'repo-drift')
|
|
826
|
+
return 'config repo';
|
|
827
|
+
// Never "this device" — the row already sits under its own `▸ <device>` block,
|
|
828
|
+
// so a self-referential subject reads as the local machine in fleet mode.
|
|
829
|
+
if (f.kind === 'stale-cli')
|
|
830
|
+
return 'agents-cli';
|
|
831
|
+
if (f.kind === 'orphan')
|
|
832
|
+
return 'orphans';
|
|
833
|
+
if (f.kind === 'rc-secret-export')
|
|
834
|
+
return 'shell rc';
|
|
835
|
+
if (f.kind === 'exec-policy')
|
|
836
|
+
return 'PowerShell';
|
|
837
|
+
if (f.kind === 'fleet-resource-gap')
|
|
838
|
+
return 'fleet gap';
|
|
839
|
+
if (f.kind === 'host-cli-missing')
|
|
840
|
+
return 'host CLIs';
|
|
841
|
+
if (f.kind === 'missing-resource' && !f.agent)
|
|
842
|
+
return 'fleet gap';
|
|
843
|
+
if (f.agent)
|
|
844
|
+
return subjectLabel(f);
|
|
845
|
+
return f.kind;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The compact accounts/versions line for one device: every installed version and
|
|
849
|
+
* its account, grouped by agent — green ✓ signed in, red ✗ provably logged out,
|
|
850
|
+
* gray ? unknown (see {@link badge}). e.g.
|
|
851
|
+
* `claude 2.1.170 ✓muqsit@gmail(Max) 2.1.999 ✓team(Team) · codex ✗ · grok ✓`
|
|
852
|
+
*/
|
|
853
|
+
export function renderAccountsLine(signIn) {
|
|
854
|
+
const parts = [];
|
|
855
|
+
// Stable agent order matches AGENT display order.
|
|
856
|
+
const agents = sortedAgentIds(Object.keys(signIn));
|
|
857
|
+
for (const agentId of agents) {
|
|
858
|
+
const rows = signIn[agentId];
|
|
859
|
+
if (!rows || rows.length === 0)
|
|
860
|
+
continue;
|
|
861
|
+
const agent = agentId;
|
|
862
|
+
if (rows.length === 1) {
|
|
863
|
+
// Single version — collapse to `<agent> <badge>` (omit the version to keep
|
|
864
|
+
// the healthy fleet line short), matching the target layout's `codex ✓`.
|
|
865
|
+
const r = rows[0];
|
|
866
|
+
parts.push(`${agentId} ${badge(agent, r)}`);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
const versionParts = rows
|
|
870
|
+
.map((r) => `${r.version} ${badge(agent, r)}`)
|
|
871
|
+
.join(' ');
|
|
872
|
+
parts.push(`${agentId} ${versionParts}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return parts.join(chalk.gray(' · '));
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* The per-version sign-in badge: green `✓` + cyan account when signed in, red `✗`
|
|
879
|
+
* only for a PROVABLE logout, gray `?` when the state is unknown.
|
|
880
|
+
*
|
|
881
|
+
* The third case is load-bearing. A probe that threw, or an agent whose
|
|
882
|
+
* credential location we do not know, yields `signedIn: false` with
|
|
883
|
+
* `provable: false` — and the finding for that row is deliberately the hedged
|
|
884
|
+
* "could not verify sign-in". Painting it red here would have the same report say
|
|
885
|
+
* "unverifiable" in the warning and "logged out" in the accounts line.
|
|
886
|
+
*/
|
|
887
|
+
function badge(agent, row) {
|
|
888
|
+
if (row.signedIn) {
|
|
889
|
+
const who = row.account ?? '';
|
|
890
|
+
return who ? `${chalk.green('✓')}${chalk.cyan(who)}` : chalk.green('✓');
|
|
891
|
+
}
|
|
892
|
+
return row.provable ? chalk.red('✗') : chalk.gray('?');
|
|
893
|
+
}
|