portable-agent-layer 0.89.1 → 0.91.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.89.1",
3
+ "version": "0.91.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,7 +67,8 @@
67
67
  "eval:failure-principle": "bun eval/run.ts failure-principle",
68
68
  "eval:goal-links": "bun eval/run.ts goal-links",
69
69
  "eval:waiting-on": "bun eval/run.ts waiting-on",
70
- "eval:reaction": "bun eval/run.ts reaction"
70
+ "eval:reaction": "bun eval/run.ts reaction",
71
+ "eval:correction-check": "bun eval/run.ts correction-check"
71
72
  },
72
73
  "lint-staged": {
73
74
  "*.ts": [
@@ -8,7 +8,11 @@ import {
8
8
  skillsDirOf,
9
9
  } from "../../hooks/lib/agent-registry";
10
10
  import { palPkg, platform } from "../../hooks/lib/paths";
11
- import { nativeAgentsDir, staleShippedAgents } from "../../targets/lib";
11
+ import {
12
+ installedShippedAgentCount,
13
+ nativeAgentsDir,
14
+ staleShippedAgents,
15
+ } from "../../targets/lib";
12
16
  import { NO_SESSION_AGENT_MESSAGE } from "../session-agent";
13
17
  import { type Finding, type Fix, failing, optional, passed, warning } from "./finding";
14
18
 
@@ -231,7 +235,13 @@ function hookFindings(agent: AgentName, file: string, contextHook: string): Find
231
235
  ),
232
236
  ];
233
237
 
234
- const findings = [passed(`${agent}.hooks`, `${label}: ${pal.length} hooks registered`)];
238
+ const findings = [
239
+ passed(
240
+ `${agent}.hooks`,
241
+ `${label}: ${pal.length} hooks registered`,
242
+ `${pal.length} hooks`
243
+ ),
244
+ ];
235
245
  const undeclared = pal.filter((c) => !declaresAgent(c, agent));
236
246
  if (undeclared.length > 0)
237
247
  findings.push(
@@ -272,14 +282,20 @@ function opencodePluginFindings(): Finding[] {
272
282
  reinstall("opencode")
273
283
  ),
274
284
  ];
275
- return [passed("opencode.hooks", "opencode plugin installed and current")];
285
+ return [
286
+ passed("opencode.hooks", "opencode plugin installed and current", "plugin current"),
287
+ ];
276
288
  }
277
289
 
278
290
  function subagentFinding(agent: AgentName): Finding {
279
291
  const label = labelOf(agent);
280
292
  const stale = staleShippedAgents(nativeAgentsDir(agent), agent);
281
293
  if (stale.length === 0)
282
- return passed(`${agent}.subagents`, `${label}: subagents match this PAL version`);
294
+ return passed(
295
+ `${agent}.subagents`,
296
+ `${label}: subagents match this PAL version`,
297
+ "subagents current"
298
+ );
283
299
  return warning(
284
300
  `${agent}.subagents`,
285
301
  `${label}: installed subagents differ from this PAL version — ${stale.join(", ")}`,
@@ -298,7 +314,7 @@ function oneAgentFindings(agent: AgentName): Finding[] {
298
314
  const skills = countSkills(skillsDirOf(agent));
299
315
  const findings = [
300
316
  skills > 0
301
- ? passed(`${agent}.skills`, `${label}: ${skills} skills`)
317
+ ? passed(`${agent}.skills`, `${label}: ${skills} skills`, `${skills} skills`)
302
318
  : warning(`${agent}.skills`, `${label} has no PAL skills`, reinstall(agent)),
303
319
  ];
304
320
  findings.push(
@@ -311,13 +327,31 @@ function oneAgentFindings(agent: AgentName): Finding[] {
311
327
  const { file, name } = layout.instructions;
312
328
  findings.push(
313
329
  existsSync(file())
314
- ? passed(`${agent}.instructions`, `${name} present`)
330
+ ? passed(`${agent}.instructions`, `${name} present`, name)
315
331
  : failing(`${agent}.instructions`, `${name} is missing`, reinstall(agent))
316
332
  );
317
333
  }
318
334
  return findings;
319
335
  }
320
336
 
337
+ function palHookCount(agent: AgentName): number {
338
+ const file = WIRING[agent].hookFile?.();
339
+ const commands = file ? readHookCommands(file) : "missing";
340
+ return Array.isArray(commands) ? commands.filter((c) => isPalHook(c, agent)).length : 0;
341
+ }
342
+
343
+ /** What PAL put into an agent, in the few words an install step reports it in. */
344
+ export function agentInventory(agent: AgentName): string[] {
345
+ const plural = (count: number, noun: string) =>
346
+ `${count} ${noun}${count === 1 ? "" : "s"}`;
347
+ const wiring = WIRING[agent].hookFile ? plural(palHookCount(agent), "hook") : "plugin";
348
+ return [
349
+ plural(countSkills(skillsDirOf(agent)), "skill"),
350
+ wiring,
351
+ plural(installedShippedAgentCount(agent), "agent"),
352
+ ];
353
+ }
354
+
321
355
  export function agentFindings(agents: AgentName[]): Finding[] {
322
356
  if (agents.length === 0) return [];
323
357
  const agentsMd = resolve(platform.opencodeDir(), "AGENTS.md");
@@ -345,7 +379,7 @@ export function rosterFindings(agents: AgentName[]): Finding[] {
345
379
  return [
346
380
  optional(
347
381
  "agents.others",
348
- `Other agents PAL supports — ${others.map(labelOf).join(", ")}`,
382
+ `${others.map(labelOf).join(", ")} — other agents PAL supports`,
349
383
  {
350
384
  say: `install it, then run pal cli install --${others.length === 1 ? others[0] : "<agent>"}`,
351
385
  }
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, resolve } from "node:path";
@@ -98,16 +98,24 @@ function thisBrowserHost(): BrowserHost {
98
98
  };
99
99
  }
100
100
 
101
- function runInShell(command: string): boolean {
102
- return spawnSync(command, { cwd: palPkg(), shell: true, stdio: "ignore" }).status === 0;
101
+ function runInShell(command: string): Promise<boolean> {
102
+ return new Promise((done) => {
103
+ const child = spawn(command, { cwd: palPkg(), shell: true, stdio: "ignore" });
104
+ child.on("error", () => done(false));
105
+ child.on("close", (code) => done(code === 0));
106
+ });
103
107
  }
104
108
 
105
- /** A failure is left to the doctor's report that follows, which names the fix. */
106
- export function installChromium(
109
+ /**
110
+ * Null when the right build is already there. A failure is left to the
111
+ * doctor's report that follows, which names the fix.
112
+ */
113
+ export async function installChromium(
107
114
  host: BrowserHost = thisBrowserHost(),
108
- run: (command: string) => boolean = runInShell
109
- ): void {
110
- if (!hasChromium(host)) run(chromiumInstallCommand(host));
115
+ run: (command: string) => Promise<boolean> | boolean = runInShell
116
+ ): Promise<boolean | null> {
117
+ if (hasChromium(host)) return null;
118
+ return run(chromiumInstallCommand(host));
111
119
  }
112
120
 
113
121
  interface PalInstall {
@@ -11,15 +11,20 @@ export type Fix =
11
11
  | { say: string; command?: undefined }
12
12
  | { say: string; command: string; external: boolean };
13
13
 
14
+ export type Group = "Environment" | "Agents" | "Inference" | "State";
15
+
14
16
  export interface Finding {
15
17
  id: string;
16
18
  severity: Severity;
17
19
  title: string;
18
20
  fix?: Fix;
21
+ group?: Group;
22
+ /** The few words a passing check takes up when its agent's checks share one line. */
23
+ brief?: string;
19
24
  }
20
25
 
21
- export function passed(id: string, title: string): Finding {
22
- return { id, severity: "ok", title };
26
+ export function passed(id: string, title: string, brief?: string): Finding {
27
+ return brief ? { id, severity: "ok", title, brief } : { id, severity: "ok", title };
23
28
  }
24
29
 
25
30
  export function failing(id: string, title: string, fix?: Fix): Finding {
@@ -183,7 +183,11 @@ export function cursorPlanFinding(about: string): Finding | null {
183
183
  `Cursor is set to ${model}, but its free plan only runs Auto — background inference fails`,
184
184
  { say: "Switch Cursor's model to Auto, or upgrade the plan" }
185
185
  );
186
- return passed("cursor.model", `Cursor model ${model} runs on the ${tier} plan`);
186
+ return passed(
187
+ "cursor.model",
188
+ `Cursor model ${model} runs on the ${tier} plan`,
189
+ `model ${model} on the ${tier} plan`
190
+ );
187
191
  }
188
192
 
189
193
  function cursorAbout(): string {
@@ -1,80 +1,261 @@
1
- import type { Finding, Fix, Severity } from "./finding";
1
+ import { AGENT_REGISTRY, type AgentName } from "../../hooks/lib/agent-registry";
2
+ import { healthBadge, mark, seconds } from "../ui/rail";
3
+ import { homeShortened, type Style, visibleWidth } from "../ui/style";
4
+ import { wrapWords } from "../ui/wrap";
5
+ import type { Finding, Fix, Group, Severity } from "./finding";
2
6
 
3
7
  export interface DoctorReport {
4
8
  version: string;
5
9
  agents: string[];
6
10
  findings: Finding[];
11
+ elapsedMs?: number;
7
12
  }
8
13
 
9
14
  interface RenderOptions {
10
15
  verbose: boolean;
11
- color: boolean;
16
+ style: Style;
12
17
  }
13
18
 
14
- const MARK: Record<Severity, { glyph: string; ansi: string }> = {
15
- fail: { glyph: "✗", ansi: "\x1b[31m" },
16
- warn: { glyph: "⚠", ansi: "\x1b[33m" },
17
- optional: { glyph: "·", ansi: "\x1b[90m" },
18
- ok: { glyph: "✓", ansi: "\x1b[32m" },
19
- };
19
+ export interface Tally {
20
+ fails: Finding[];
21
+ warns: Finding[];
22
+ optionals: Finding[];
23
+ passes: Finding[];
24
+ }
25
+
26
+ export function tally(findings: Finding[]): Tally {
27
+ const of = (severity: Severity) => findings.filter((f) => f.severity === severity);
28
+ return {
29
+ fails: of("fail"),
30
+ warns: of("warn"),
31
+ optionals: of("optional"),
32
+ passes: of("ok"),
33
+ };
34
+ }
20
35
 
21
36
  function plural(count: number, noun: string): string {
22
37
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
23
38
  }
24
39
 
40
+ export function plainSummary({ fails, warns, passes }: Tally): string {
41
+ return [
42
+ fails.length > 0 ? `${fails.length} failing` : "",
43
+ warns.length > 0 ? plural(warns.length, "warning") : "",
44
+ `${plural(passes.length, "check")} passed`,
45
+ ]
46
+ .filter(Boolean)
47
+ .join(", ");
48
+ }
49
+
50
+ const PLAIN_LEVEL: Record<Severity, string> = {
51
+ fail: "fail",
52
+ warn: "warn",
53
+ optional: "optional",
54
+ ok: "ok ",
55
+ };
56
+
25
57
  function fixText(fix: Fix): string {
26
58
  return fix.command ? `${fix.say}: ${fix.command}` : fix.say;
27
59
  }
28
60
 
29
- function headline(report: DoctorReport, passedCount: number): string {
61
+ function plainLine(finding: Finding): string {
62
+ const fix = finding.fix ? ` -> ${fixText(finding.fix)}` : "";
63
+ return `${PLAIN_LEVEL[finding.severity]} ${finding.title}${fix}`;
64
+ }
65
+
66
+ export function plainProblemLines(t: Tally): string[] {
67
+ return [...t.fails, ...t.warns].map(plainLine);
68
+ }
69
+
70
+ function plainReport(report: DoctorReport, verbose: boolean): string[] {
71
+ const t = tally(report.findings);
30
72
  const agents = report.agents.length > 0 ? report.agents.join(", ") : "no agent";
31
- return `PAL ${report.version} · ${agents} · ${plural(passedCount, "check")} passed`;
73
+ return [
74
+ `PAL ${report.version} · ${agents} · ${plainSummary(t)}`,
75
+ ...(verbose ? t.passes.map(plainLine) : []),
76
+ ...plainProblemLines(t),
77
+ ...t.optionals.map(plainLine),
78
+ ];
79
+ }
80
+
81
+ const TITLE_REST = " — ";
82
+
83
+ interface Segment {
84
+ text: string;
85
+ paint: (text: string) => string;
86
+ }
87
+
88
+ const asIs = (text: string) => text;
89
+
90
+ /** Wraps the words of several differently coloured pieces as if they were one sentence. */
91
+ export function wrapSegments(segments: Segment[], width: number): string[] {
92
+ const text = segments.map((s) => s.text).join("");
93
+ let end = 0;
94
+ const bounds = segments.map((s) => {
95
+ end += s.text.length;
96
+ return end;
97
+ });
98
+ return wrapWords(text, width).map(({ text: chunk, start }) =>
99
+ segments
100
+ .map((segment, i) => {
101
+ const from = Math.max(start, bounds[i - 1] ?? 0);
102
+ const to = Math.min(start + chunk.length, bounds[i]);
103
+ return to > from ? segment.paint(text.slice(from, to)) : "";
104
+ })
105
+ .join("")
106
+ );
32
107
  }
33
108
 
34
- function summary(failCount: number, warnCount: number, passedCount: number): string {
109
+ /** A title reads "what — more about it"; the part after the dash is quieter. */
110
+ function titleSegments(
111
+ style: Style,
112
+ title: string,
113
+ main: (text: string) => string,
114
+ separator = TITLE_REST
115
+ ): Segment[] {
116
+ const text = homeShortened(title);
117
+ const split = text.indexOf(TITLE_REST);
118
+ if (split < 0) return [{ text, paint: main }];
35
119
  return [
36
- failCount > 0 ? `${failCount} failing` : "",
37
- warnCount > 0 ? plural(warnCount, "warning") : "",
38
- `${plural(passedCount, "check")} passed`,
39
- ]
40
- .filter(Boolean)
41
- .join(" · ");
120
+ { text: text.slice(0, split), paint: main },
121
+ { text: `${separator}${text.slice(split + TITLE_REST.length)}`, paint: style.dim },
122
+ ];
123
+ }
124
+
125
+ function hanging(lines: string[], first: string, rest: string): string[] {
126
+ return lines.map((line, i) => `${i === 0 ? first : rest}${line}`);
127
+ }
128
+
129
+ function fixLines(style: Style, fix: Fix, width: number, indent: string): string[] {
130
+ const arrow = `${indent}${style.dim(style.glyph.arrow)} `;
131
+ const pad = " ".repeat(indent.length + style.glyph.arrow.length + 1);
132
+ const segment = fix.command
133
+ ? { text: fix.command, paint: style.cmd }
134
+ : { text: homeShortened(fix.say), paint: asIs };
135
+ return hanging(wrapSegments([segment], width), arrow, pad);
136
+ }
137
+
138
+ const textWidth = (style: Style, indent: string) =>
139
+ Math.max(30, style.term.width - indent.length - 4);
140
+
141
+ export function problemLines(style: Style, t: Tally, indent = " "): string[] {
142
+ const width = textWidth(style, indent);
143
+ return [...t.fails, ...t.warns].flatMap((problem) => {
144
+ const kind = problem.severity === "fail" ? "fail" : "warn";
145
+ const main = kind === "fail" ? style.bold : asIs;
146
+ const head = `${indent}${mark(style, kind)} `;
147
+ return [
148
+ ...hanging(
149
+ wrapSegments(titleSegments(style, problem.title, main), width),
150
+ head,
151
+ `${indent} `
152
+ ),
153
+ ...(problem.fix ? fixLines(style, problem.fix, width, `${indent} `) : []),
154
+ ];
155
+ });
156
+ }
157
+
158
+ function optionalLines(style: Style, item: Finding): string[] {
159
+ const name = homeShortened(item.title.split(TITLE_REST)[0]);
160
+ const how = item.fix?.command
161
+ ? { text: item.fix.command, paint: style.cmd }
162
+ : { text: homeShortened(item.fix?.say ?? ""), paint: style.dim };
163
+ const segments = [
164
+ { text: name, paint: style.soft },
165
+ { text: ` ${style.glyph.dash} `, paint: style.dim },
166
+ how,
167
+ ];
168
+ return hanging(
169
+ wrapSegments(segments, textWidth(style, " ")),
170
+ ` ${style.dim(style.glyph.dot)} `,
171
+ " "
172
+ );
173
+ }
174
+
175
+ const GROUP_ORDER: Group[] = ["Environment", "Agents", "Inference", "State"];
176
+
177
+ function agentOf(finding: Finding, agents: string[]): string | undefined {
178
+ return agents.find((agent) => finding.id.startsWith(`${agent}.`));
179
+ }
180
+
181
+ function passLines(style: Style, finding: Finding): string[] {
182
+ const segments = titleSegments(style, finding.title, asIs, ` ${style.glyph.dot} `);
183
+ return hanging(
184
+ wrapSegments(segments, textWidth(style, " ")),
185
+ ` ${mark(style, "ok")} `,
186
+ " "
187
+ );
188
+ }
189
+
190
+ function agentLines(style: Style, agent: string, passes: Finding[]): string[] {
191
+ const label = (AGENT_REGISTRY[agent as AgentName]?.label ?? agent).padEnd(12);
192
+ const briefs = passes.map((f) => f.brief ?? f.title).join(` ${style.glyph.dot} `);
193
+ const width = textWidth(style, " ") - label.length - 1;
194
+ return hanging(
195
+ wrapSegments([{ text: briefs, paint: style.dim }], width),
196
+ ` ${mark(style, "ok")} ${label} `,
197
+ " ".repeat(label.length + 5)
198
+ );
199
+ }
200
+
201
+ function groupLines(style: Style, group: Group, passes: Finding[], agents: string[]) {
202
+ const inGroup = passes.filter((f) => (f.group ?? "State") === group);
203
+ if (inGroup.length === 0) return [];
204
+ const own = inGroup.filter((f) => !agentOf(f, agents));
205
+ const perAgent = agents
206
+ .map((agent) => [agent, inGroup.filter((f) => agentOf(f, agents) === agent)] as const)
207
+ .filter(([, found]) => found.length > 0);
208
+ return [
209
+ ` ${style.bold(style.gradient(group))}`,
210
+ ...own.flatMap((f) => passLines(style, f)),
211
+ ...perAgent.flatMap(([agent, found]) => agentLines(style, agent, found)),
212
+ "",
213
+ ];
214
+ }
215
+
216
+ function headline(style: Style, t: Tally, report: DoctorReport): string[] {
217
+ const count = { fails: t.fails.length, warns: t.warns.length };
218
+ const agents = style.soft(
219
+ report.agents.length > 0 ? report.agents.join(` ${style.glyph.dot} `) : "no agent"
220
+ );
221
+ const pal = style.bold(`PAL ${report.version}`);
222
+ const head = ` ${healthBadge(style, count, true)} ${pal}`;
223
+ const oneLine = `${head} ${agents}`;
224
+ return visibleWidth(oneLine) <= style.term.width ? [oneLine] : [head, ` ${agents}`];
225
+ }
226
+
227
+ function richReport(report: DoctorReport, verbose: boolean, style: Style): string[] {
228
+ const t = tally(report.findings);
229
+ const elapsed =
230
+ report.elapsedMs === undefined
231
+ ? ""
232
+ : ` ${style.glyph.dot} ${seconds(report.elapsedMs)}`;
233
+ const footer = `${plural(t.passes.length, "check")} passed${elapsed}`;
234
+ const problems = problemLines(style, t);
235
+ return [
236
+ "",
237
+ ...headline(style, t, report),
238
+ "",
239
+ ...(verbose
240
+ ? GROUP_ORDER.flatMap((g) => groupLines(style, g, t.passes, report.agents))
241
+ : []),
242
+ ...(problems.length > 0 ? [...problems, ""] : []),
243
+ ...(t.optionals.length > 0
244
+ ? [
245
+ ` ${style.dim("Optional")}`,
246
+ ...t.optionals.flatMap((o) => optionalLines(style, o)),
247
+ "",
248
+ ]
249
+ : []),
250
+ ` ${style.dim(footer)}`,
251
+ "",
252
+ ];
42
253
  }
43
254
 
44
255
  export function renderReport(report: DoctorReport, options: RenderOptions): string[] {
45
- const mark = (severity: Severity) =>
46
- options.color
47
- ? `${MARK[severity].ansi}${MARK[severity].glyph}\x1b[0m`
48
- : MARK[severity].glyph;
49
- const of = (severity: Severity) =>
50
- report.findings.filter((finding) => finding.severity === severity);
51
- const fails = of("fail");
52
- const warns = of("warn");
53
- const optionals = of("optional");
54
- const passes = of("ok");
55
-
56
- const lines = [headline(report, passes.length)];
57
- if (options.verbose) {
58
- lines.push("", ...passes.map((finding) => ` ${mark("ok")} ${finding.title}`));
59
- }
60
- const problems = [...fails, ...warns];
61
- if (problems.length > 0) lines.push("");
62
- for (const problem of problems) {
63
- lines.push(` ${mark(problem.severity)} ${problem.title}`);
64
- if (problem.fix) lines.push(` → ${fixText(problem.fix)}`);
65
- }
66
- if (optionals.length > 0) {
67
- lines.push("", "Optional — not set up:");
68
- for (const item of optionals) {
69
- const how = item.fix?.command ?? item.fix?.say;
70
- const suffix = how ? `: ${how}` : "";
71
- lines.push(` ${mark("optional")} ${item.title}${suffix}`);
72
- }
73
- }
74
- if (problems.length > 0) {
75
- lines.push("", summary(fails.length, warns.length, passes.length));
76
- }
77
- return lines;
256
+ return options.style.term.rich
257
+ ? richReport(report, options.verbose, options.style)
258
+ : plainReport(report, options.verbose);
78
259
  }
79
260
 
80
261
  export function doctorExitCode(findings: Finding[]): 0 | 1 {
@@ -1,4 +1,5 @@
1
1
  import { getInstalledVersion } from "../../hooks/handlers/update-check";
2
+ import { createStyle } from "../ui/style";
2
3
  import {
3
4
  agentFindings,
4
5
  type DoctorResult,
@@ -7,38 +8,55 @@ import {
7
8
  rosterFindings,
8
9
  } from "./agents";
9
10
  import { environmentFindings } from "./environment";
10
- import type { Finding } from "./finding";
11
+ import type { Finding, Group } from "./finding";
11
12
  import { healthFindings } from "./health";
12
13
  import { inferenceFindings } from "./inference";
13
- import { doctorExitCode, renderReport } from "./render";
14
+ import { type DoctorReport, doctorExitCode, renderReport } from "./render";
14
15
  import { stateFindings } from "./state";
15
16
 
17
+ const inGroup = (group: Group, findings: Finding[]) =>
18
+ findings.map((finding) => ({ ...finding, group }));
19
+
20
+ const healthGroup = (finding: Finding): Group =>
21
+ finding.id.startsWith("hook-errors") ? "Inference" : "State";
22
+
16
23
  function doctorFindings(health: DoctorResult): Finding[] {
17
24
  const agents = installedAgents(health);
18
25
  return [
19
- ...rosterFindings(agents),
20
- ...environmentFindings(health.rtk),
21
- ...stateFindings(),
22
- ...agentFindings(agents),
23
- ...inferenceFindings(agents),
24
- ...healthFindings(),
26
+ ...inGroup("Agents", rosterFindings(agents)),
27
+ ...inGroup("Environment", environmentFindings(health.rtk)),
28
+ ...inGroup("State", stateFindings()),
29
+ ...inGroup("Agents", agentFindings(agents)),
30
+ ...inGroup("Inference", inferenceFindings(agents)),
31
+ ...healthFindings().map((finding) => ({ ...finding, group: healthGroup(finding) })),
25
32
  ];
26
33
  }
27
34
 
28
- /** `pal cli doctor [--verbose] [--json]` — prints the report and returns the exit code. */
29
- export function runDoctor(args: string[], health: DoctorResult = detectAgents()): number {
30
- if (process.env.PAL_SKIP_DOCTOR === "1") return 0;
31
- const report = {
35
+ export function doctorReport(
36
+ health: DoctorResult,
37
+ started = performance.now()
38
+ ): DoctorReport {
39
+ const findings = doctorFindings(health);
40
+ return {
32
41
  version: getInstalledVersion(),
33
42
  agents: installedAgents(health),
34
- findings: doctorFindings(health),
43
+ findings,
44
+ elapsedMs: performance.now() - started,
35
45
  };
46
+ }
47
+
48
+ /** `pal cli doctor [--verbose] [--json]` — prints the report and returns the exit code. */
49
+ export function runDoctor(args: string[], health?: DoctorResult): number {
50
+ if (process.env.PAL_SKIP_DOCTOR === "1") return 0;
51
+ const started = performance.now();
52
+ const report = doctorReport(health ?? detectAgents(), started);
36
53
  if (args.includes("--json")) {
37
- console.log(JSON.stringify(report, null, 2));
54
+ const { elapsedMs: _elapsed, ...json } = report;
55
+ console.log(JSON.stringify(json, null, 2));
38
56
  } else {
39
57
  const lines = renderReport(report, {
40
58
  verbose: args.includes("--verbose"),
41
- color: process.stdout.isTTY === true,
59
+ style: createStyle(),
42
60
  });
43
61
  console.log(lines.join("\n"));
44
62
  }