portable-agent-layer 0.62.0 → 0.62.2

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.
@@ -78,7 +78,7 @@ Wraps `bun run dev` in the report directory. Open the URL printed by Next, edit
78
78
  ### 4. Render the PDF
79
79
 
80
80
  ```bash
81
- node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts <report-dir>
81
+ node ~/.pal/skills/consulting-report/tools/generate-pdf.mjs <report-dir>
82
82
  ```
83
83
 
84
84
  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:
@@ -140,14 +140,14 @@ Run with **Node**, not Bun — Playwright's `chromium.launch()` hangs under Bun
140
140
  ## Demo
141
141
 
142
142
  ```bash
143
- node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts ~/.pal/skills/consulting-report/demo
143
+ node ~/.pal/skills/consulting-report/tools/generate-pdf.mjs ~/.pal/skills/consulting-report/demo
144
144
  ```
145
145
 
146
146
  Renders the bundled Acme Industries example end-to-end. Inspect the resulting PDF to see the full layout before authoring your own.
147
147
 
148
148
  ## Important
149
149
 
150
- - Run on Node ≥ 22.6 (Playwright + `--experimental-strip-types`)
150
+ - Run on Node (Playwright); the tool ships as a compiled `.mjs` so no `--experimental-strip-types` is needed
151
151
  - Bundled fonts come from Google Fonts via `next/font/google` — no licensing surface, no CDN at runtime, glyphs embedded at build time
152
152
  - Reports are disposable artifacts of `lib/report-data.ts` + `app/page.tsx`; commit the source, not the PDF
153
153
  - The scaffolder runs `bun install` inside the target by default — pass `--no-install` to skip
@@ -0,0 +1,226 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createReadStream, constants as fsConstants, realpathSync } from "node:fs";
3
+ import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import { extname, join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { PDFDocument } from "pdf-lib";
8
+ import { chromium } from "playwright";
9
+ const COLOR = {
10
+ navy: "#0f172a",
11
+ blue: "#1d4ed8",
12
+ red: "#dc2626"
13
+ };
14
+ async function exists(p) {
15
+ try {
16
+ await access(p, fsConstants.F_OK);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+ function escapeHtml(s) {
23
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
24
+ }
25
+ function slugify(s) {
26
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
27
+ }
28
+ async function logoDataUri(outDir, publicPath) {
29
+ if (!publicPath)
30
+ return null;
31
+ const filePath = join(outDir, publicPath);
32
+ if (!await exists(filePath))
33
+ return null;
34
+ const buf = await readFile(filePath);
35
+ const ext = extname(filePath).toLowerCase();
36
+ let mime = "image/jpeg";
37
+ if (ext === ".svg") {
38
+ mime = "image/svg+xml";
39
+ } else if (ext === ".png") {
40
+ mime = "image/png";
41
+ }
42
+ return `data:${mime};base64,${buf.toString("base64")}`;
43
+ }
44
+ async function loadMeta(reportDir) {
45
+ const dataPath = join(reportDir, "lib", "report-data.ts");
46
+ if (!await exists(dataPath)) {
47
+ throw new Error(`lib/report-data.ts not found at ${dataPath}`);
48
+ }
49
+ const mod = await import(pathToFileURL(dataPath).href);
50
+ if (!mod.reportData) {
51
+ throw new Error(`lib/report-data.ts must export a named 'reportData' constant`);
52
+ }
53
+ return mod.reportData;
54
+ }
55
+ function buildNext(reportDir) {
56
+ const result = spawnSync("bun", ["run", "build"], {
57
+ cwd: reportDir,
58
+ stdio: "inherit",
59
+ shell: true
60
+ });
61
+ if (result.status !== 0) {
62
+ throw new Error(`next build failed (exit ${result.status})`);
63
+ }
64
+ }
65
+ function serveStatic(rootDir) {
66
+ const MIME = {
67
+ ".html": "text/html; charset=utf-8",
68
+ ".css": "text/css; charset=utf-8",
69
+ ".js": "application/javascript; charset=utf-8",
70
+ ".json": "application/json; charset=utf-8",
71
+ ".woff": "font/woff",
72
+ ".woff2": "font/woff2",
73
+ ".svg": "image/svg+xml",
74
+ ".png": "image/png",
75
+ ".jpg": "image/jpeg",
76
+ ".jpeg": "image/jpeg",
77
+ ".webp": "image/webp",
78
+ ".ico": "image/x-icon"
79
+ };
80
+ return new Promise((res) => {
81
+ const server = createServer((req, response) => {
82
+ const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
83
+ let filePath = join(rootDir, urlPath === "/" ? "/index.html" : urlPath);
84
+ if (filePath.endsWith("/"))
85
+ filePath = join(filePath, "index.html");
86
+ const ext = extname(filePath).toLowerCase();
87
+ response.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
88
+ const stream = createReadStream(filePath);
89
+ stream.on("error", () => {
90
+ response.statusCode = 404;
91
+ response.end();
92
+ });
93
+ stream.pipe(response);
94
+ });
95
+ server.listen(0, "127.0.0.1", () => {
96
+ const addr = server.address();
97
+ const port = typeof addr === "object" && addr ? addr.port : 0;
98
+ res({ server, url: `http://127.0.0.1:${port}/` });
99
+ });
100
+ });
101
+ }
102
+ async function renderPdf(htmlPath, pdfPath, meta) {
103
+ const outDir = resolve(htmlPath, "..");
104
+ const { server, url } = await serveStatic(outDir);
105
+ const browser = await chromium.launch();
106
+ try {
107
+ const page = await browser.newPage();
108
+ await page.goto(url, { waitUntil: "networkidle" });
109
+ await page.evaluate(async () => {
110
+ await document.fonts?.ready;
111
+ const imgs = Array.from(document.querySelectorAll("img"));
112
+ await Promise.all(imgs.map((img) => img.complete ? Promise.resolve() : new Promise((res) => {
113
+ img.onload = () => res(null);
114
+ img.onerror = () => res(null);
115
+ setTimeout(() => res(null), 5000);
116
+ })));
117
+ });
118
+ const [clientUri, consultancyUri] = await Promise.all([
119
+ logoDataUri(outDir, meta.clientLogoSrc),
120
+ logoDataUri(outDir, meta.consultancyLogoSrc)
121
+ ]);
122
+ const clientSlot = clientUri ? `<img src="${clientUri}" style="height:18px; width:auto; object-fit:contain; display:block;">` : `<span style="font-weight:600; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(meta.clientName.toUpperCase())}</span>`;
123
+ const consultancySlot = consultancyUri ? `<img src="${consultancyUri}" style="height:14px; width:auto; object-fit:contain; display:block;">` : `<span style="color:${COLOR.navy};">${escapeHtml(meta.consultancyName)}</span>`;
124
+ const header = `
125
+ <div style="width:100%; font-family:Inter,'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.7in; display:flex; justify-content:space-between; align-items:center;">
126
+ ${clientSlot}
127
+ <span style="color:#94a3b8;">${escapeHtml(meta.reportTitle)}</span>
128
+ </div>`;
129
+ const footer = `
130
+ <div style="width:100%; font-family:Inter,'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.7in; display:flex; justify-content:space-between; align-items:center;">
131
+ ${consultancySlot}
132
+ <span style="color:${COLOR.navy};"><span class="pageNumber"></span></span>
133
+ </div>`;
134
+ const margin = { top: "0.7in", right: "0.7in", bottom: "0.7in", left: "0.7in" };
135
+ const tmpCover = `${pdfPath}.cover.tmp.pdf`;
136
+ const tmpBody = `${pdfPath}.body.tmp.pdf`;
137
+ await Promise.all([
138
+ page.pdf({
139
+ path: tmpCover,
140
+ format: "A4",
141
+ pageRanges: "1",
142
+ printBackground: true,
143
+ displayHeaderFooter: false,
144
+ margin: { top: "0", right: "0", bottom: "0", left: "0" },
145
+ preferCSSPageSize: false
146
+ }),
147
+ page.pdf({
148
+ path: tmpBody,
149
+ format: "A4",
150
+ pageRanges: "2-",
151
+ printBackground: true,
152
+ displayHeaderFooter: true,
153
+ headerTemplate: header,
154
+ footerTemplate: footer,
155
+ margin,
156
+ preferCSSPageSize: false
157
+ })
158
+ ]);
159
+ const [coverBytes, bodyBytes] = await Promise.all([
160
+ readFile(tmpCover),
161
+ readFile(tmpBody)
162
+ ]);
163
+ const [coverDoc, bodyDoc] = await Promise.all([
164
+ PDFDocument.load(coverBytes),
165
+ PDFDocument.load(bodyBytes)
166
+ ]);
167
+ const [coverPage] = await bodyDoc.copyPages(coverDoc, [0]);
168
+ bodyDoc.insertPage(0, coverPage);
169
+ await writeFile(pdfPath, await bodyDoc.save());
170
+ await Promise.all([unlink(tmpCover), unlink(tmpBody)]);
171
+ } finally {
172
+ await browser.close();
173
+ server.close();
174
+ }
175
+ }
176
+ async function generate(opts) {
177
+ const dir = resolve(opts.reportDir);
178
+ if (!await exists(join(dir, "package.json"))) {
179
+ throw new Error(`not a scaffolded report (missing package.json): ${dir}`);
180
+ }
181
+ if (!opts.skipBuild) {
182
+ buildNext(dir);
183
+ }
184
+ const htmlPath = join(dir, "out", "index.html");
185
+ if (!await exists(htmlPath)) {
186
+ throw new Error(`static export missing: ${htmlPath} — run without --skip-build`);
187
+ }
188
+ const meta = await loadMeta(dir);
189
+ const slug = `${slugify(meta.clientName)}-${slugify(meta.reportTitle)}-${slugify(new Date().toISOString().slice(0, 10))}`;
190
+ const pdfPath = opts.pdfOut ? resolve(opts.pdfOut) : join(dir, `${slug}.pdf`);
191
+ await renderPdf(htmlPath, pdfPath, meta);
192
+ return { htmlPath, pdfPath };
193
+ }
194
+ function parseArgs(argv) {
195
+ if (argv.length === 0) {
196
+ throw new Error("usage: generate-pdf.ts <report-dir> [--pdf <out>] [--skip-build]");
197
+ }
198
+ const opts = { reportDir: argv[0] };
199
+ for (let i = 1;i < argv.length; i++) {
200
+ if (argv[i] === "--pdf")
201
+ opts.pdfOut = argv[++i];
202
+ else if (argv[i] === "--html")
203
+ opts.htmlOut = argv[++i];
204
+ else if (argv[i] === "--skip-build")
205
+ opts.skipBuild = true;
206
+ }
207
+ return opts;
208
+ }
209
+ async function run(argv = process.argv.slice(2)) {
210
+ const opts = parseArgs(argv);
211
+ const { htmlPath, pdfPath } = await generate(opts);
212
+ const [htmlStat, pdfStat] = await Promise.all([stat(htmlPath), stat(pdfPath)]);
213
+ console.log(`HTML: ${htmlPath} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
214
+ console.log(`PDF: ${pdfPath} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
215
+ }
216
+ function realResolve(p) {
217
+ try {
218
+ return realpathSync(resolve(p));
219
+ } catch {
220
+ return resolve(p);
221
+ }
222
+ }
223
+ const isMain = process.argv[1] && realResolve(process.argv[1]) === realResolve(new URL(import.meta.url).pathname);
224
+ if (isMain) {
225
+ await run();
226
+ }
@@ -8,8 +8,11 @@
8
8
  // Windows because it uses --remote-debugging-pipe over stdio and Bun's Windows
9
9
  // child-process pipe handling doesn't complete the CDP handshake.
10
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
+ //
11
14
  // Usage:
12
- // node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
15
+ // node ~/.pal/skills/consulting-report/tools/generate-pdf.mjs <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
13
16
 
14
17
  import { spawnSync } from "node:child_process";
15
18
  import { createReadStream, constants as fsConstants, realpathSync } from "node:fs";
@@ -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: node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts ${opts.targetDir}`
103
+ ` 4. Generate PDF: node ~/.pal/skills/consulting-report/tools/generate-pdf.mjs ${opts.targetDir}`
104
104
  );
105
105
  }
106
106
 
@@ -55,13 +55,13 @@ Invoke the skill tool. Flags:
55
55
  Single-file example:
56
56
 
57
57
  ```bash
58
- node --experimental-strip-types ~/.pal/skills/create-pdf/tools/md-to-html-pdf.ts /path/to/report.md --pdf /path/to/report.pdf
58
+ node ~/.pal/skills/create-pdf/tools/md-to-html-pdf.mjs /path/to/report.md --pdf /path/to/report.pdf
59
59
  ```
60
60
 
61
61
  Multi-file example (after Step 2):
62
62
 
63
63
  ```bash
64
- node --experimental-strip-types ~/.pal/skills/create-pdf/tools/md-to-html-pdf.ts /tmp/combined.md --pdf /path/to/report.pdf --html /path/to/report.html
64
+ node ~/.pal/skills/create-pdf/tools/md-to-html-pdf.mjs /tmp/combined.md --pdf /path/to/report.pdf --html /path/to/report.html
65
65
  ```
66
66
 
67
67
  The tool writes the self-contained HTML (inline CSS, UTF-8) and the PDF, and prints both paths + sizes on stdout.
@@ -82,13 +82,13 @@ Default styling (A4, 25mm margins, GitHub-ish look, table-friendly, page-break-a
82
82
  - `--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`.
83
83
 
84
84
  ```bash
85
- node --experimental-strip-types ~/.pal/skills/create-pdf/tools/md-to-html-pdf.ts report.md --pdf report.pdf \
85
+ node ~/.pal/skills/create-pdf/tools/md-to-html-pdf.mjs report.md --pdf report.pdf \
86
86
  --margin 18mm \
87
87
  --header '<div style="font-size:9px;width:100%;text-align:center;color:#888">CONFIDENTIAL</div>' \
88
88
  --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>'
89
89
  ```
90
90
 
91
- 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`.
91
+ 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`).
92
92
 
93
93
  ## Translation Variant
94
94
 
@@ -0,0 +1,103 @@
1
+ import { readFile, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, extname, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { marked } from "marked";
5
+ import { chromium } from "playwright";
6
+ const args = process.argv.slice(2);
7
+ if (args.length === 0) {
8
+ console.error("usage: md-to-html-pdf.ts <input.md> [--html <out>] [--pdf <out>] [--margin <css>] [--header <html|file>] [--footer <html|file>]");
9
+ process.exit(1);
10
+ }
11
+ const input = resolve(args[0]);
12
+ let htmlOut = "";
13
+ let pdfOut = "";
14
+ let margin = "25mm";
15
+ let headerArg = "";
16
+ let footerArg = "";
17
+ for (let i = 1;i < args.length; i++) {
18
+ if (args[i] === "--html")
19
+ htmlOut = resolve(args[++i]);
20
+ else if (args[i] === "--pdf")
21
+ pdfOut = resolve(args[++i]);
22
+ else if (args[i] === "--margin")
23
+ margin = args[++i];
24
+ else if (args[i] === "--header")
25
+ headerArg = args[++i];
26
+ else if (args[i] === "--footer")
27
+ footerArg = args[++i];
28
+ }
29
+ const stem = basename(input, extname(input));
30
+ const dir = dirname(input);
31
+ htmlOut ||= resolve(dir, `${stem}.html`);
32
+ pdfOut ||= resolve(dir, `${stem}.pdf`);
33
+ async function resolveTemplate(value) {
34
+ if (!value)
35
+ return "";
36
+ try {
37
+ return await readFile(resolve(value), "utf8");
38
+ } catch {
39
+ return value;
40
+ }
41
+ }
42
+ const headerTemplate = await resolveTemplate(headerArg);
43
+ const footerTemplate = await resolveTemplate(footerArg);
44
+ const displayHeaderFooter = Boolean(headerTemplate || footerTemplate);
45
+ const md = await readFile(input, "utf8");
46
+ marked.setOptions({ gfm: true, breaks: false });
47
+ const body = await marked.parse(md);
48
+ const css = `
49
+ html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
50
+ body {
51
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
52
+ font-size: 11px; line-height: 1.6; color: #1a1a1a; margin: 0;
53
+ }
54
+ h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }
55
+ h2 { font-size: 17px; margin-top: 1.6em; }
56
+ h3 { font-size: 14px; }
57
+ h1, h2, h3, h4 { page-break-after: avoid; }
58
+ p, li { orphans: 3; widows: 3; }
59
+ hr { border: none; border-top: 1px solid #ccc; margin: 20px 0; }
60
+ a { color: #0366d6; text-decoration: none; }
61
+ blockquote { border-left: 3px solid #666; padding-left: 12px; color: #444; margin: 12px 0; }
62
+ code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10px; }
63
+ pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }
64
+ pre code { background: transparent; padding: 0; }
65
+ table { border-collapse: collapse; width: 100%; margin: 12px 0; page-break-inside: avoid; }
66
+ th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; font-size: 10px; vertical-align: top; }
67
+ th { background: #f0f0f0; }
68
+ tr { page-break-inside: avoid; }
69
+ ul, ol { padding-left: 1.4em; }
70
+ `;
71
+ const html = `<!doctype html>
72
+ <html>
73
+ <head>
74
+ <meta charset="utf-8">
75
+ <title>${stem}</title>
76
+ <style>${css}</style>
77
+ </head>
78
+ <body>
79
+ ${body}
80
+ </body>
81
+ </html>
82
+ `;
83
+ await writeFile(htmlOut, html, "utf8");
84
+ const browser = await chromium.launch();
85
+ try {
86
+ const page = await browser.newPage();
87
+ await page.goto(pathToFileURL(htmlOut).href, { waitUntil: "networkidle" });
88
+ await page.pdf({
89
+ path: pdfOut,
90
+ format: "A4",
91
+ margin: { top: margin, right: margin, bottom: margin, left: margin },
92
+ printBackground: true,
93
+ preferCSSPageSize: false,
94
+ displayHeaderFooter,
95
+ headerTemplate: headerTemplate || "<span></span>",
96
+ footerTemplate: footerTemplate || "<span></span>"
97
+ });
98
+ } finally {
99
+ await browser.close();
100
+ }
101
+ const [htmlStat, pdfStat] = await Promise.all([stat(htmlOut), stat(pdfOut)]);
102
+ console.log(`HTML: ${htmlOut} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
103
+ console.log(`PDF: ${pdfOut} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
@@ -6,8 +6,11 @@
6
6
  // because it uses --remote-debugging-pipe over stdio and Bun's Windows child-process
7
7
  // pipe handling doesn't complete the CDP handshake.
8
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
+ //
9
12
  // Usage:
10
- // node --experimental-strip-types ~/.pal/skills/create-pdf/tools/md-to-html-pdf.ts <input.md> \
13
+ // node ~/.pal/skills/create-pdf/tools/md-to-html-pdf.mjs <input.md> \
11
14
  // [--html <out.html>] [--pdf <out.pdf>] [--margin <css>] [--header <html|file>] [--footer <html|file>]
12
15
  // --margin defaults to 25mm (all sides). --header/--footer accept inline HTML or a file path.
13
16
 
@@ -23,7 +23,7 @@ If neither engine is usable, the tool prints `NO_PLAYWRIGHT_CLI` and exits non-z
23
23
  2. Run the tool (it prints the absolute PNG path as its last stdout line):
24
24
 
25
25
  ```bash
26
- node --experimental-strip-types ~/.pal/skills/playwright/tools/shot.ts <url> \
26
+ node ~/.pal/skills/playwright/tools/shot.mjs <url> \
27
27
  [--viewport 1440x900] [--full-page] [--selector "<css>"] [-o <out.png>]
28
28
  ```
29
29
 
@@ -0,0 +1,44 @@
1
+ const USAGE = "usage: shot.ts <url> [-o <file>] [--viewport WxH] [--full-page] [--selector <css>] [--wait <ms>]";
2
+ export function parseArgs(argv) {
3
+ let url = "";
4
+ let out = "";
5
+ let viewport;
6
+ let fullPage = false;
7
+ let selector;
8
+ let waitMs;
9
+ for (let i = 0;i < argv.length; i++) {
10
+ const a = argv[i];
11
+ if (a === "-o" || a === "--out")
12
+ out = argv[++i] ?? "";
13
+ else if (a === "--viewport") {
14
+ const m = /^(\d+)[x,](\d+)$/.exec(argv[++i] ?? "");
15
+ if (!m)
16
+ throw new Error("--viewport expects WxH, e.g. 1440x900");
17
+ viewport = { width: Number(m[1]), height: Number(m[2]) };
18
+ } else if (a === "--full-page")
19
+ fullPage = true;
20
+ else if (a === "--selector")
21
+ selector = argv[++i];
22
+ else if (a === "--wait") {
23
+ const n = Number(argv[++i]);
24
+ if (!Number.isFinite(n))
25
+ throw new Error("--wait expects a number of milliseconds");
26
+ waitMs = n;
27
+ } else if (!a.startsWith("-") && !url)
28
+ url = a;
29
+ else
30
+ throw new Error(`unknown argument: ${a}
31
+ ${USAGE}`);
32
+ }
33
+ if (!url)
34
+ throw new Error(`a URL is required
35
+ ${USAGE}`);
36
+ return { url, out, viewport, fullPage, selector, waitMs };
37
+ }
38
+ export function chooseTier(opts) {
39
+ if (!opts.cliAvailable)
40
+ return "node";
41
+ if (opts.viewport || opts.fullPage)
42
+ return "node";
43
+ return "cli";
44
+ }
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { chooseTier, parseArgs } from "./shot-lib.mjs";
6
+ function playwrightCliAvailable() {
7
+ try {
8
+ return spawnSync("playwright-cli", ["--version"], { stdio: "ignore" }).status === 0;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+ function runViaCli(opts, out) {
14
+ const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
15
+ const run = (args, quiet = false) => spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
16
+ try {
17
+ if (run(["open", opts.url]).status !== 0)
18
+ return false;
19
+ const args = ["screenshot", `--filename=${out}`];
20
+ if (opts.selector)
21
+ args.push(opts.selector);
22
+ const shot = run(args);
23
+ run(["close"], true);
24
+ return shot.status === 0 && existsSync(out);
25
+ } finally {
26
+ rmSync(work, { recursive: true, force: true });
27
+ }
28
+ }
29
+ async function runViaNode(opts, out) {
30
+ let chromium;
31
+ try {
32
+ ({ chromium } = await import("playwright"));
33
+ } catch {
34
+ return "unavailable";
35
+ }
36
+ let browser;
37
+ try {
38
+ browser = await chromium.launch();
39
+ } catch (e) {
40
+ console.error(`chromium launch failed: ${e.message}`);
41
+ return "unavailable";
42
+ }
43
+ try {
44
+ const page = await browser.newPage(opts.viewport ? { viewport: opts.viewport } : {});
45
+ await page.goto(opts.url, { waitUntil: "networkidle" });
46
+ if (opts.waitMs)
47
+ await page.waitForTimeout(opts.waitMs);
48
+ if (opts.selector)
49
+ await page.locator(opts.selector).screenshot({ path: out });
50
+ else
51
+ await page.screenshot({ path: out, fullPage: opts.fullPage });
52
+ return existsSync(out) ? "ok" : "error";
53
+ } catch (e) {
54
+ console.error(`screenshot failed: ${e.message}`);
55
+ return "error";
56
+ } finally {
57
+ await browser.close();
58
+ }
59
+ }
60
+ async function main() {
61
+ let opts;
62
+ try {
63
+ opts = parseArgs(process.argv.slice(2));
64
+ } catch (e) {
65
+ console.error(e.message);
66
+ process.exit(2);
67
+ }
68
+ const out = opts.out ? resolve(opts.out) : join(tmpdir(), `pal-shot-${Date.now()}.png`);
69
+ const tier = chooseTier({
70
+ cliAvailable: playwrightCliAvailable(),
71
+ viewport: opts.viewport,
72
+ fullPage: opts.fullPage
73
+ });
74
+ if (tier === "cli" && runViaCli(opts, out)) {
75
+ console.log(out);
76
+ return;
77
+ }
78
+ const result = await runViaNode(opts, out);
79
+ if (result === "ok") {
80
+ console.log(out);
81
+ return;
82
+ }
83
+ if (result === "unavailable") {
84
+ console.error("NO_PLAYWRIGHT_CLI");
85
+ process.exit(3);
86
+ }
87
+ process.exit(1);
88
+ }
89
+ await main();
@@ -9,8 +9,10 @@
9
9
  // If neither engine works, prints NO_PLAYWRIGHT_CLI on stderr and exits non-zero so the
10
10
  // caller (SKILL.md) can fall back to the Playwright MCP.
11
11
  //
12
- // Run with Node:
13
- // node --experimental-strip-types ~/.pal/skills/playwright/tools/shot.ts <url> [opts]
12
+ // pal-build:mjs ships as a compiled shot.mjs sibling (scripts/build-skill-tools.ts).
13
+ // Run under Node via the compiled file (a .ts under node_modules can't be type-stripped;
14
+ // plain .mjs needs no stripping and runs on every OS, keeping the Windows fallback):
15
+ // node ~/.pal/skills/playwright/tools/shot.mjs <url> [opts]
14
16
 
15
17
  import { spawnSync } from "node:child_process";
16
18
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -18,6 +18,20 @@
18
18
  "Bash(stat //*)",
19
19
  "Bash(readlink //*)",
20
20
  "Bash(lsof *)",
21
+ "Bash(rtk read *)",
22
+ "Bash(rtk ls *)",
23
+ "Bash(rtk tree *)",
24
+ "Bash(rtk find *)",
25
+ "Bash(rtk grep *)",
26
+ "Bash(rtk rg *)",
27
+ "Bash(rtk wc *)",
28
+ "Bash(rtk diff *)",
29
+ "Bash(rtk json *)",
30
+ "Bash(rtk log *)",
31
+ "Bash(rtk deps *)",
32
+ "Bash(rtk env *)",
33
+ "Bash(rtk smart *)",
34
+ "Bash(rtk gain *)",
21
35
  "Bash(bun ~/.pal/skills/*/tools/*.ts *)",
22
36
  "Bash(bun ~/.pal/tools/*.ts *)",
23
37
  "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.62.0",
3
+ "version": "0.62.2",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,8 @@
46
46
  "jscpd": "jscpd --noTips",
47
47
  "lint-staged": "lint-staged",
48
48
  "prepare": "bun .husky/install.mjs",
49
+ "build:skill-tools": "bun run scripts/build-skill-tools.ts",
50
+ "prepack": "bun run build:skill-tools",
49
51
  "install:all": "bun run src/cli/index.ts cli install",
50
52
  "uninstall": "bun run src/cli/index.ts cli uninstall",
51
53
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
@@ -26,17 +26,24 @@ export function flagshipAuthorModel(agent: AgentType): string | undefined {
26
26
  return FLAGSHIP_AUTHOR_MODEL[agent];
27
27
  }
28
28
 
29
+ export interface ModelPricing {
30
+ input: number;
31
+ output: number;
32
+ cacheWrite5m: number;
33
+ cacheWrite1h: number;
34
+ cacheRead: number;
35
+ }
36
+
37
+ export interface TokenUsage {
38
+ input: number;
39
+ output: number;
40
+ cacheWrite5m: number;
41
+ cacheWrite1h: number;
42
+ cacheRead: number;
43
+ }
44
+
29
45
  /** Pricing per million tokens (USD) — from https://platform.claude.com/docs/en/about-claude/pricing */
30
- export const MODEL_PRICING: Record<
31
- string,
32
- {
33
- input: number;
34
- output: number;
35
- cacheWrite5m: number;
36
- cacheWrite1h: number;
37
- cacheRead: number;
38
- }
39
- > = {
46
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
40
47
  [HAIKU_MODEL]: {
41
48
  input: 1,
42
49
  output: 5,
@@ -51,6 +58,13 @@ export const MODEL_PRICING: Record<
51
58
  cacheWrite1h: 20,
52
59
  cacheRead: 1,
53
60
  },
61
+ "claude-opus-5": {
62
+ input: 5,
63
+ output: 25,
64
+ cacheWrite5m: 6.25,
65
+ cacheWrite1h: 10,
66
+ cacheRead: 0.5,
67
+ },
54
68
  "claude-opus-4-8": {
55
69
  input: 5,
56
70
  output: 25,
@@ -72,6 +86,13 @@ export const MODEL_PRICING: Record<
72
86
  cacheWrite1h: 10,
73
87
  cacheRead: 0.5,
74
88
  },
89
+ "claude-opus-4-5": {
90
+ input: 5,
91
+ output: 25,
92
+ cacheWrite5m: 6.25,
93
+ cacheWrite1h: 10,
94
+ cacheRead: 0.5,
95
+ },
75
96
  // Claude Sonnet 5 — introductory pricing through 2026-08-31; standard (3/15/3.75/6/0.30) applies from 2026-09-01.
76
97
  "claude-sonnet-5": {
77
98
  input: 2,
@@ -95,3 +116,37 @@ export const MODEL_PRICING: Record<
95
116
  cacheRead: 0.3,
96
117
  },
97
118
  };
119
+
120
+ function longestPrefixKey(model: string): string | null {
121
+ let best: string | null = null;
122
+ for (const key of Object.keys(MODEL_PRICING)) {
123
+ if (model.startsWith(key) && (best === null || key.length > best.length)) best = key;
124
+ }
125
+ return best;
126
+ }
127
+
128
+ /**
129
+ * Rates for a model ID. Transcripts carry variants of the same model — dated
130
+ * (`claude-opus-5-20260115`) and context-tagged (`claude-opus-5[1m]`) — so an
131
+ * exact miss falls back to the longest table key the ID starts with.
132
+ */
133
+ export function pricingFor(model: string): ModelPricing | null {
134
+ const exact = MODEL_PRICING[model];
135
+ if (exact) return exact;
136
+ const prefix = longestPrefixKey(model);
137
+ return prefix ? MODEL_PRICING[prefix] : null;
138
+ }
139
+
140
+ /** USD cost of a token usage record. Unpriced models bill as 0. */
141
+ export function costOfUsage(model: string, usage: TokenUsage): number {
142
+ const p = pricingFor(model);
143
+ if (!p) return 0;
144
+ return (
145
+ (usage.input * p.input +
146
+ usage.output * p.output +
147
+ usage.cacheWrite5m * p.cacheWrite5m +
148
+ usage.cacheWrite1h * p.cacheWrite1h +
149
+ usage.cacheRead * p.cacheRead) /
150
+ 1_000_000
151
+ );
152
+ }
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
- import { MODEL_PRICING } from "../hooks/lib/models";
12
+ import { costOfUsage } from "../hooks/lib/models";
13
13
 
14
14
  // ── Types ──
15
15
 
@@ -142,16 +142,13 @@ function parseSession(filepath: string, sessionId: string): Usage {
142
142
  : (u.cache_creation_input_tokens ?? 0);
143
143
  const cacheWrite1h = cw1h ?? 0;
144
144
 
145
- const p = MODEL_PRICING[model];
146
- if (p) {
147
- usage.cost +=
148
- (input * p.input +
149
- output * p.output +
150
- cacheWrite5m * p.cacheWrite5m +
151
- cacheWrite1h * p.cacheWrite1h +
152
- cr * p.cacheRead) /
153
- 1_000_000;
154
- }
145
+ usage.cost += costOfUsage(model, {
146
+ input,
147
+ output,
148
+ cacheWrite5m,
149
+ cacheWrite1h,
150
+ cacheRead: cr,
151
+ });
155
152
 
156
153
  usage.input += input;
157
154
  usage.output += output;
@@ -13,7 +13,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { resolve } from "node:path";
15
15
  import { parseArgs } from "node:util";
16
- import { MODEL_PRICING } from "../hooks/lib/models";
16
+ import { costOfUsage } from "../hooks/lib/models";
17
17
  import { palHome } from "../hooks/lib/paths";
18
18
  import { findBinaryOnPath } from "../hooks/lib/which";
19
19
 
@@ -59,34 +59,6 @@ function emptyTimeBuckets(): TimeBuckets {
59
59
 
60
60
  // ── Helpers ──
61
61
 
62
- function findPricing(model: string): (typeof MODEL_PRICING)[string] | null {
63
- if (MODEL_PRICING[model]) return MODEL_PRICING[model];
64
- for (const key of Object.keys(MODEL_PRICING)) {
65
- if (model.startsWith(key)) return MODEL_PRICING[key];
66
- }
67
- return null;
68
- }
69
-
70
- function costForUsage(
71
- model: string,
72
- input: number,
73
- output: number,
74
- cacheWrite5m: number,
75
- cacheWrite1h: number,
76
- cacheRead: number
77
- ): number {
78
- const p = findPricing(model);
79
- if (!p) return 0;
80
- return (
81
- (input * p.input +
82
- output * p.output +
83
- cacheWrite5m * p.cacheWrite5m +
84
- cacheWrite1h * p.cacheWrite1h +
85
- cacheRead * p.cacheRead) /
86
- 1_000_000
87
- );
88
- }
89
-
90
62
  function addToBucket(
91
63
  bucket: Bucket,
92
64
  model: string,
@@ -101,14 +73,13 @@ function addToBucket(
101
73
  bucket.cacheWrite5m += cacheWrite5m;
102
74
  bucket.cacheWrite1h += cacheWrite1h;
103
75
  bucket.cacheRead += cacheRead;
104
- bucket.cost += costForUsage(
105
- model,
76
+ bucket.cost += costOfUsage(model, {
106
77
  input,
107
78
  output,
108
79
  cacheWrite5m,
109
80
  cacheWrite1h,
110
- cacheRead
111
- );
81
+ cacheRead,
82
+ });
112
83
  bucket.calls++;
113
84
  }
114
85