portable-agent-layer 0.85.9 → 0.85.11

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/README.md CHANGED
@@ -81,7 +81,7 @@ pal cli status # check your setup
81
81
  | `pal cli export` | Export user state (telos, memory) to a zip |
82
82
  | `pal cli import` | Import user state from a zip |
83
83
  | `pal cli status` | Show current PAL configuration |
84
- | `pal cli doctor` | Check prerequisites and system health |
84
+ | `pal cli doctor` | Find what is wrong and the command that fixes each problem (`--verbose` lists every check, `--json` for agents); exits 1 on any failure |
85
85
  | `pal cli migrate` | Run pending data migrations (non-destructive) |
86
86
  | `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
87
87
  | `pal cli usage` | Summarize token usage and estimated cost |
@@ -87,7 +87,7 @@ Wraps `bun run dev` in the report directory. Open the URL printed by Next, edit
87
87
  ### 4. Render the PDF
88
88
 
89
89
  ```bash
90
- pal cli skill run consulting-report generate-pdf.mjs <report-dir>
90
+ pal cli skill run consulting-report generate-pdf <report-dir>
91
91
  ```
92
92
 
93
93
  Runs `next build` (which produces a static export at `out/`), then Playwright loads it via a tiny in-process HTTP server and prints the PDF with page-numbered header/footer. Output:
@@ -98,8 +98,6 @@ Runs `next build` (which produces a static export at `out/`), then Playwright lo
98
98
 
99
99
  Override with `--pdf <path>`. Pass `--skip-build` to re-render the PDF from the existing `out/` without re-building.
100
100
 
101
- Run with **Node**, not Bun — Playwright's `chromium.launch()` hangs under Bun on Windows.
102
-
103
101
  ## Directory Layout
104
102
 
105
103
  ```
@@ -149,14 +147,13 @@ Run with **Node**, not Bun — Playwright's `chromium.launch()` hangs under Bun
149
147
  ## Demo
150
148
 
151
149
  ```bash
152
- pal cli skill run consulting-report generate-pdf.mjs ~/.pal/skills/consulting-report/demo
150
+ pal cli skill run consulting-report generate-pdf ~/.pal/skills/consulting-report/demo
153
151
  ```
154
152
 
155
153
  Renders the bundled Acme Industries example end-to-end. Inspect the resulting PDF to see the full layout before authoring your own.
156
154
 
157
155
  ## Important
158
156
 
159
- - Runs on Node (Playwright); the tool ships as a compiled `.mjs`, and `pal cli skill run` picks Node for that extension so no `--experimental-strip-types` is needed
160
157
  - Bundled fonts come from Google Fonts via `next/font/google` — no licensing surface, no CDN at runtime, glyphs embedded at build time
161
158
  - Reports are disposable artifacts of `lib/report-data.ts` + `app/page.tsx`; commit the source, not the PDF
162
159
  - The scaffolder runs `bun install` inside the target by default — pass `--no-install` to skip
@@ -1,25 +1,18 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
 
3
3
  // consulting-report skill tool: build the Next.js report and render it to PDF.
4
4
  // Pipeline: `next build` produces a static export at out/ — Playwright loads
5
5
  // out/index.html and prints a PDF with branded header/footer/page numbers.
6
6
  //
7
- // Run with Node (not Bun) — Playwright's chromium.launch() hangs under Bun on
8
- // Windows because it uses --remote-debugging-pipe over stdio and Bun's Windows
9
- // child-process pipe handling doesn't complete the CDP handshake.
10
- //
11
- // pal-build:mjs — ships as a compiled generate-pdf.mjs sibling (scripts/build-skill-tools.ts)
12
- // and is invoked as that .mjs: a .ts under node_modules can't be type-stripped by Node.
13
- //
14
7
  // Usage:
15
- // pal cli skill run consulting-report generate-pdf.mjs <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
8
+ // pal cli skill run consulting-report generate-pdf <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
16
9
 
17
10
  import { spawnSync } from "node:child_process";
18
- import { createReadStream, constants as fsConstants, realpathSync } from "node:fs";
11
+ import { createReadStream, constants as fsConstants } from "node:fs";
19
12
  import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
20
13
  import { createServer, type Server } from "node:http";
21
14
  import { extname, join, resolve } from "node:path";
22
- import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { pathToFileURL } from "node:url";
23
16
  import { PDFDocument } from "pdf-lib";
24
17
  import { chromium } from "playwright";
25
18
 
@@ -312,22 +305,4 @@ async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
312
305
  console.log(`PDF: ${pdfPath} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
313
306
  }
314
307
 
315
- // Node ≥ 22.6 doesn't expose import.meta.main; gate on argv[1] instead.
316
- // Use realpathSync on both sides so symlinked skill paths (e.g. ~/.pal/skills →
317
- // PAL repo) match the resolved import.meta.url.
318
- // fileURLToPath, never new URL().pathname: on Windows the latter yields
319
- // "/C:/Users/..." — leading slash, forward slashes — which can never equal argv[1]'s
320
- // "C:\Users\...", so the gate silently failed and run() never executed there.
321
- function realResolve(p: string): string {
322
- try {
323
- return realpathSync(resolve(p));
324
- } catch {
325
- return resolve(p);
326
- }
327
- }
328
- const isMain =
329
- process.argv[1] &&
330
- realResolve(process.argv[1]) === realResolve(fileURLToPath(import.meta.url));
331
- if (isMain) {
332
- await run();
333
- }
308
+ if (import.meta.main) await run();
@@ -100,7 +100,7 @@ async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
100
100
  console.log(` 2. Edit lib/report-data.ts (metadata) and app/page.tsx (layout)`);
101
101
  console.log(` 3. Live preview: bun run dev`);
102
102
  console.log(
103
- ` 4. Generate PDF: pal cli skill run consulting-report generate-pdf.mjs ${opts.targetDir}`
103
+ ` 4. Generate PDF: pal cli skill run consulting-report generate-pdf ${opts.targetDir}`
104
104
  );
105
105
  }
106
106
 
@@ -68,13 +68,13 @@ Invoke the skill tool. Flags:
68
68
  Single-file example:
69
69
 
70
70
  ```bash
71
- pal cli skill run create-pdf md-to-html-pdf.mjs /path/to/report.md --pdf /path/to/report.pdf
71
+ pal cli skill run create-pdf md-to-html-pdf /path/to/report.md --pdf /path/to/report.pdf
72
72
  ```
73
73
 
74
74
  Multi-file example (after Step 2):
75
75
 
76
76
  ```bash
77
- pal cli skill run create-pdf md-to-html-pdf.mjs /tmp/combined.md --pdf /path/to/report.pdf --html /path/to/report.html
77
+ pal cli skill run create-pdf md-to-html-pdf /tmp/combined.md --pdf /path/to/report.pdf --html /path/to/report.html
78
78
  ```
79
79
 
80
80
  The tool writes the self-contained HTML (inline CSS, UTF-8) and the PDF, and prints both paths + sizes on stdout.
@@ -95,13 +95,13 @@ Default styling (A4, 25mm margins, GitHub-ish look, table-friendly, page-break-a
95
95
  - `--header <html|file>` / `--footer <html|file>` — running header/footer on every page. The value is either an inline HTML string or a path to an HTML file. Templates may use Playwright's injected classes: `pageNumber`, `totalPages`, `date`, `title`, `url`.
96
96
 
97
97
  ```bash
98
- pal cli skill run create-pdf md-to-html-pdf.mjs report.md --pdf report.pdf \
98
+ pal cli skill run create-pdf md-to-html-pdf report.md --pdf report.pdf \
99
99
  --margin 18mm \
100
100
  --header '<div style="font-size:9px;width:100%;text-align:center;color:#888">CONFIDENTIAL</div>' \
101
101
  --footer '<div style="font-size:9px;width:100%;text-align:right;padding-right:12mm;color:#888"><span class="pageNumber"></span>/<span class="totalPages"></span></div>'
102
102
  ```
103
103
 
104
- Header/footer templates **need an explicit `font-size`** (Playwright defaults them to 0) and render *inside* the page margin — widen `--margin` so they have room. Do NOT also add CSS `@page` margin-box rules; they duplicate. For deeper changes (fonts, base CSS), edit the `css` string in `tools/md-to-html-pdf.ts` (the `.mjs` is generated from it — never edit the `.mjs`).
104
+ Header/footer templates **need an explicit `font-size`** (Playwright defaults them to 0) and render *inside* the page margin — widen `--margin` so they have room. Do NOT also add CSS `@page` margin-box rules; they duplicate. For deeper changes (fonts, base CSS), edit the `css` string in `tools/md-to-html-pdf.ts`.
105
105
 
106
106
  ## Translation Variant
107
107
 
@@ -1,16 +1,9 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  // create-pdf skill tool: Markdown -> HTML (marked, GFM) -> PDF (Playwright).
3
3
  // Self-contained HTML: all CSS inlined, no CDN at render time.
4
4
  //
5
- // Run with Node (not Bun) — Playwright's chromium.launch hangs under Bun on Windows
6
- // because it uses --remote-debugging-pipe over stdio and Bun's Windows child-process
7
- // pipe handling doesn't complete the CDP handshake.
8
- //
9
- // pal-build:mjs — ships as a compiled md-to-html-pdf.mjs sibling (scripts/build-skill-tools.ts)
10
- // and is invoked as that .mjs: a .ts under node_modules can't be type-stripped by Node.
11
- //
12
5
  // Usage:
13
- // pal cli skill run create-pdf md-to-html-pdf.mjs <input.md> \
6
+ // pal cli skill run create-pdf md-to-html-pdf <input.md> \
14
7
  // [--html <out.html>] [--pdf <out.pdf>] [--margin <css>] [--header <html|file>] [--footer <html|file>]
15
8
  // --margin defaults to 25mm (all sides). --header/--footer accept inline HTML or a file path.
16
9
 
@@ -50,18 +50,6 @@ prefix_rule(
50
50
  ],
51
51
  )
52
52
 
53
- prefix_rule(
54
- pattern = ["node", "--experimental-strip-types", "~/.pal/skills/consulting-report/tools/generate-pdf.ts"],
55
- decision = "allow",
56
- justification = "PAL consulting-report PDF generation requires Node for Playwright compatibility",
57
- match = [
58
- "node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
59
- ],
60
- not_match = [
61
- "node ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
62
- ],
63
- )
64
-
65
53
  prefix_rule(
66
54
  pattern = ["pal", "cli", "project"],
67
55
  decision = "allow",
@@ -120,7 +108,7 @@ prefix_rule(
120
108
  justification = "A skill's own tools ship with PAL and are safe to run without repeated approval",
121
109
  match = [
122
110
  "pal cli skill run presentation build ./deck",
123
- "pal cli skill run consulting-report generate-pdf.mjs ./report",
111
+ "pal cli skill run consulting-report generate-pdf ./report",
124
112
  ],
125
113
  not_match = [
126
114
  "pal cli skill link my-skill",
@@ -34,7 +34,6 @@
34
34
  "Bash(rtk gain *)",
35
35
  "Bash(bun ~/.pal/skills/*/tools/*.ts *)",
36
36
  "Bash(bun ~/.pal/tools/*.ts *)",
37
- "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)",
38
37
  "Bash(pal cli skill run *)",
39
38
  "Bash(pal cli algorithm-reflect *)",
40
39
  "Bash(pal cli algorithm-synthesize *)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.9",
3
+ "version": "0.85.11",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,10 +55,9 @@
55
55
  "secretlint": "secretlint \"**/*\"",
56
56
  "lint-staged": "lint-staged",
57
57
  "prepare": "bun .husky/install.mjs",
58
- "build:skill-tools": "bun run scripts/build-skill-tools.ts",
59
58
  "build:ui": "bun run --filter @pal/control-room-ui build",
60
59
  "dev:ui": "bun run --filter @pal/control-room-ui dev",
61
- "prepack": "bun run build:skill-tools && bun run build:ui",
60
+ "prepack": "bun run build:ui",
62
61
  "install:all": "bun run src/cli/index.ts cli install",
63
62
  "uninstall": "bun run src/cli/index.ts cli uninstall",
64
63
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
@@ -0,0 +1,336 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { palPkg, platform } from "../../hooks/lib/paths";
5
+ import { NO_SESSION_AGENT_MESSAGE } from "../session-agent";
6
+ import { type Finding, failing, optional, passed, warning } from "./finding";
7
+
8
+ export type AgentName = "claude" | "opencode" | "cursor" | "copilot" | "codex";
9
+
10
+ const AGENT_NAMES: AgentName[] = ["claude", "codex", "copilot", "cursor", "opencode"];
11
+
12
+ export interface ToolCheck {
13
+ name: string;
14
+ available: boolean;
15
+ version?: string;
16
+ }
17
+
18
+ export interface DoctorResult {
19
+ bun: ToolCheck;
20
+ claude: ToolCheck;
21
+ opencode: ToolCheck;
22
+ cursor: ToolCheck;
23
+ copilot: ToolCheck;
24
+ codex: ToolCheck;
25
+ rtk: ToolCheck;
26
+ hasAgent: boolean;
27
+ }
28
+
29
+ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
30
+ try {
31
+ const result = spawnSync(cmd, versionArgs, {
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ shell: true,
34
+ timeout: 5000,
35
+ });
36
+ if (result.status === 0) {
37
+ const version = (result.stdout?.toString() || "").trim().split("\n")[0];
38
+ return { name: cmd, available: true, version };
39
+ }
40
+ } catch {
41
+ // not found
42
+ }
43
+ return { name: cmd, available: false };
44
+ }
45
+
46
+ /**
47
+ * Copilot ships as a VS Code extension as well as a CLI, and the extension puts
48
+ * no `copilot` binary on PATH — but both read hooks, skills and agents out of
49
+ * ~/.copilot. Fall back to that directory so PAL's copilot checks still run.
50
+ */
51
+ function checkCopilot(): ToolCheck {
52
+ const cli = checkTool("copilot", ["version"]);
53
+ if (cli.available) return cli;
54
+ if (existsSync(platform.copilotDir())) {
55
+ return { name: "copilot", available: true, version: "~/.copilot (no CLI on PATH)" };
56
+ }
57
+ return cli;
58
+ }
59
+
60
+ /**
61
+ * The Cursor CLI installs `cursor-agent` (and `agent`) and no `cursor` command;
62
+ * `cursor` is the editor's shell command. Both read ~/.cursor, so either counts.
63
+ */
64
+ function checkCursor(): ToolCheck {
65
+ const cli = checkTool("cursor-agent");
66
+ if (cli.available) return cli;
67
+ return checkTool("cursor");
68
+ }
69
+
70
+ export function detectAgents(): DoctorResult {
71
+ if (process.env.PAL_SKIP_DOCTOR === "1") {
72
+ return {
73
+ bun: { name: "bun", available: true, version: Bun.version },
74
+ claude: { name: "claude", available: true },
75
+ opencode: { name: "opencode", available: true },
76
+ cursor: { name: "cursor", available: true },
77
+ copilot: { name: "copilot", available: true },
78
+ codex: { name: "codex", available: true },
79
+ rtk: { name: "rtk", available: true },
80
+ hasAgent: true,
81
+ };
82
+ }
83
+ const claude = checkTool("claude");
84
+ const opencode = checkTool("opencode");
85
+ const cursor = checkCursor();
86
+ const copilot = checkCopilot();
87
+ const codex = checkTool("codex");
88
+ return {
89
+ bun: { name: "bun", available: true, version: Bun.version },
90
+ claude,
91
+ opencode,
92
+ cursor,
93
+ copilot,
94
+ codex,
95
+ rtk: checkTool("rtk"),
96
+ hasAgent: [claude, opencode, cursor, copilot, codex].some((tool) => tool.available),
97
+ };
98
+ }
99
+
100
+ export function installedAgents(result: DoctorResult): AgentName[] {
101
+ return AGENT_NAMES.filter((name) => result[name].available);
102
+ }
103
+
104
+ interface AgentLayout {
105
+ label: string;
106
+ skillsDir: () => string;
107
+ hookFile?: () => string;
108
+ instructions?: { file: () => string; name: string };
109
+ }
110
+
111
+ const LAYOUT: Record<AgentName, AgentLayout> = {
112
+ claude: {
113
+ label: "Claude Code",
114
+ skillsDir: () => resolve(platform.claudeDir(), "skills"),
115
+ hookFile: () => resolve(platform.claudeDir(), "settings.json"),
116
+ instructions: {
117
+ file: () => resolve(platform.claudeDir(), "CLAUDE.md"),
118
+ name: "CLAUDE.md",
119
+ },
120
+ },
121
+ codex: {
122
+ label: "Codex",
123
+ skillsDir: () => resolve(platform.codexDir(), "skills"),
124
+ hookFile: () => resolve(platform.codexDir(), "hooks.json"),
125
+ },
126
+ copilot: {
127
+ label: "Copilot",
128
+ skillsDir: () => resolve(platform.copilotDir(), "skills"),
129
+ hookFile: () => resolve(platform.copilotDir(), "hooks", "pal-hooks.json"),
130
+ },
131
+ cursor: {
132
+ label: "Cursor",
133
+ skillsDir: () => resolve(platform.cursorDir(), "skills"),
134
+ hookFile: () => resolve(platform.cursorDir(), "hooks.json"),
135
+ },
136
+ opencode: {
137
+ label: "opencode",
138
+ skillsDir: () => resolve(platform.agentsDir(), "skills"),
139
+ },
140
+ };
141
+
142
+ const reinstall = (agent: AgentName) => ({
143
+ say: `Reinstall PAL for ${LAYOUT[agent].label}`,
144
+ command: `pal cli install --${agent}`,
145
+ });
146
+
147
+ /** Hook-config keys that carry a shell command: cross-platform and per-shell variants. */
148
+ function isHookCommandField(key: string): boolean {
149
+ return key === "command" || key === "bash" || key === "powershell";
150
+ }
151
+
152
+ function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
153
+ if (Array.isArray(obj)) {
154
+ for (const item of obj) extractAllHookCommands(item, out);
155
+ } else if (obj && typeof obj === "object") {
156
+ for (const [k, v] of Object.entries(obj)) {
157
+ if (isHookCommandField(k) && typeof v === "string") {
158
+ out.push(v);
159
+ } else {
160
+ extractAllHookCommands(v, out);
161
+ }
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * True when a hook command names its agent, by env prefix or by argv flag.
169
+ *
170
+ * An env prefix only parses in one shell family, so hook configs whose host
171
+ * shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
172
+ */
173
+ function declaresAgent(cmd: string, agentName: string): boolean {
174
+ return (
175
+ cmd.startsWith(`PAL_AGENT=${agentName} `) ||
176
+ cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
177
+ cmd.includes(`--agent=${agentName}`)
178
+ );
179
+ }
180
+
181
+ const SCRIPT =
182
+ /"([^"]+\.(?:ts|js|mjs|sh|ps1))"|'([^']+\.(?:ts|js|mjs|sh|ps1))'|(\S+\.(?:ts|js|mjs|sh|ps1))(?=\s|$)/g;
183
+
184
+ function scriptPaths(cmd: string): string[] {
185
+ return Array.from(cmd.matchAll(SCRIPT), (m) => m[1] ?? m[2] ?? m[3] ?? "").filter(
186
+ isAbsolute
187
+ );
188
+ }
189
+
190
+ function isPalHook(cmd: string, agent: AgentName): boolean {
191
+ return (
192
+ declaresAgent(cmd, agent) ||
193
+ scriptPaths(cmd).some((path) => /[\\/]src[\\/]hooks[\\/][^\\/]+$/.test(path))
194
+ );
195
+ }
196
+
197
+ function readHookCommands(file: string): string[] | "missing" | "unreadable" {
198
+ if (!existsSync(file)) return "missing";
199
+ try {
200
+ const data = JSON.parse(readFileSync(file, "utf-8"));
201
+ return extractAllHookCommands(data.hooks ?? data);
202
+ } catch {
203
+ return "unreadable";
204
+ }
205
+ }
206
+
207
+ function hookFindings(agent: AgentName, file: string): Finding[] {
208
+ const label = LAYOUT[agent].label;
209
+ const commands = readHookCommands(file);
210
+ if (commands === "unreadable")
211
+ return [
212
+ failing(
213
+ `${agent}.hooks.unreadable`,
214
+ `${label} hook config is not valid JSON (${file})`,
215
+ {
216
+ say: `Fix the JSON in ${file}, then reinstall`,
217
+ command: `pal cli install --${agent}`,
218
+ }
219
+ ),
220
+ ];
221
+ const pal = commands === "missing" ? [] : commands.filter((c) => isPalHook(c, agent));
222
+ if (!pal.some((c) => c.includes("LoadContext")))
223
+ return [
224
+ failing(
225
+ `${agent}.hooks.missing`,
226
+ `${label} hooks are not registered`,
227
+ reinstall(agent)
228
+ ),
229
+ ];
230
+
231
+ const findings = [passed(`${agent}.hooks`, `${label}: ${pal.length} hooks registered`)];
232
+ const undeclared = pal.filter((c) => !declaresAgent(c, agent));
233
+ if (undeclared.length > 0)
234
+ findings.push(
235
+ failing(
236
+ `${agent}.hooks.undeclared`,
237
+ `${label}: ${undeclared.length} of ${pal.length} hooks do not name ${agent} as their agent`,
238
+ reinstall(agent)
239
+ )
240
+ );
241
+ const broken = pal.filter((c) => scriptPaths(c).some((path) => !existsSync(path)));
242
+ if (broken.length > 0)
243
+ findings.push(
244
+ failing(
245
+ `${agent}.hooks.scripts`,
246
+ `${label}: ${broken.length} of ${pal.length} hooks run a script that no longer exists (${scriptPaths(broken[0]).find((p) => !existsSync(p))})`,
247
+ reinstall(agent)
248
+ )
249
+ );
250
+ return findings;
251
+ }
252
+
253
+ function opencodePluginFindings(): Finding[] {
254
+ const installed = resolve(platform.opencodeDir(), "plugins", "pal-plugin.ts");
255
+ const source = resolve(palPkg(), "src", "targets", "opencode", "plugin.ts");
256
+ if (!existsSync(installed))
257
+ return [
258
+ failing(
259
+ "opencode.hooks.missing",
260
+ "opencode plugin is not installed",
261
+ reinstall("opencode")
262
+ ),
263
+ ];
264
+ if (existsSync(source) && statSync(installed).mtimeMs < statSync(source).mtimeMs)
265
+ return [
266
+ warning(
267
+ "opencode.plugin.stale",
268
+ "opencode plugin is older than PAL's — recent fixes have not reached opencode",
269
+ reinstall("opencode")
270
+ ),
271
+ ];
272
+ return [passed("opencode.hooks", "opencode plugin installed and current")];
273
+ }
274
+
275
+ function countSkills(dir: string): number {
276
+ if (!existsSync(dir)) return 0;
277
+ return readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length;
278
+ }
279
+
280
+ function oneAgentFindings(agent: AgentName): Finding[] {
281
+ const layout = LAYOUT[agent];
282
+ const skills = countSkills(layout.skillsDir());
283
+ const findings = [
284
+ skills > 0
285
+ ? passed(`${agent}.skills`, `${layout.label}: ${skills} skills`)
286
+ : warning(`${agent}.skills`, `${layout.label} has no PAL skills`, reinstall(agent)),
287
+ ];
288
+ findings.push(
289
+ ...(layout.hookFile
290
+ ? hookFindings(agent, layout.hookFile())
291
+ : opencodePluginFindings())
292
+ );
293
+ if (layout.instructions) {
294
+ const { file, name } = layout.instructions;
295
+ findings.push(
296
+ existsSync(file())
297
+ ? passed(`${agent}.instructions`, `${name} present`)
298
+ : failing(`${agent}.instructions`, `${name} is missing`, reinstall(agent))
299
+ );
300
+ }
301
+ return findings;
302
+ }
303
+
304
+ export function agentFindings(agents: AgentName[]): Finding[] {
305
+ if (agents.length === 0) return [];
306
+ const agentsMd = resolve(platform.opencodeDir(), "AGENTS.md");
307
+ return [
308
+ existsSync(agentsMd)
309
+ ? passed("agents-md", "AGENTS.md present")
310
+ : failing("agents-md", "AGENTS.md is missing", {
311
+ say: "Reinstall PAL",
312
+ command: "pal cli install",
313
+ }),
314
+ ...agents.flatMap(oneAgentFindings),
315
+ ];
316
+ }
317
+
318
+ export function rosterFindings(agents: AgentName[]): Finding[] {
319
+ if (agents.length === 0)
320
+ return [
321
+ failing("agents.none", "No supported agent is installed", {
322
+ say: NO_SESSION_AGENT_MESSAGE,
323
+ }),
324
+ ];
325
+ const others = AGENT_NAMES.filter((name) => !agents.includes(name));
326
+ if (others.length === 0) return [];
327
+ return [
328
+ optional(
329
+ "agents.others",
330
+ `Other agents PAL supports — ${others.map((name) => LAYOUT[name].label).join(", ")}`,
331
+ {
332
+ say: `install it, then run pal cli install --${others.length === 1 ? others[0] : "<agent>"}`,
333
+ }
334
+ ),
335
+ ];
336
+ }