portable-agent-layer 0.62.1 → 0.63.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.
@@ -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";
@@ -87,14 +87,6 @@ if (Test-Path $projectsDir) {
87
87
  } catch { $OPEN_ISCS = 0 }
88
88
  }
89
89
 
90
- # Rate limits (Pro/Max only - absent for other plans)
91
- $FIVE_H_RAW = $data.rate_limits.five_hour.used_percentage
92
- $SEVEN_D_RAW = $data.rate_limits.seven_day.used_percentage
93
- $RATE_PARTS = @()
94
- if ($FIVE_H_RAW -ne $null) { $RATE_PARTS += "5h: $([int]$FIVE_H_RAW)%" }
95
- if ($SEVEN_D_RAW -ne $null) { $RATE_PARTS += "7d: $([int]$SEVEN_D_RAW)%" }
96
- $RATE_STR = if ($RATE_PARTS.Count -gt 0) { " - " + ($RATE_PARTS -join " | ") } else { "" }
97
-
98
90
  # Create context progress bar - if both are 0, data not yet available (pre-first API call)
99
91
  $NO_DATA = ($USED_RAW -eq $null -and $REM_RAW -eq $null)
100
92
  if ($NO_DATA) { $USED = 0; $REM = 100 }
@@ -115,6 +107,37 @@ $RESET = $ESC + "[0m"
115
107
  # Choose bar color based on context usage
116
108
  $BAR_COLOR = if ($USED -gt 80) { $RED } elseif ($USED -gt 60) { $YELLOW } else { $GREEN }
117
109
 
110
+ # Rate limits (Pro/Max only - absent for other plans)
111
+ $FIVE_H_RAW = $data.rate_limits.five_hour.used_percentage
112
+ $SEVEN_D_RAW = $data.rate_limits.seven_day.used_percentage
113
+
114
+ # Daily soft limit - spending the weekly budget evenly is 100/7 ~ 14% per day.
115
+ # Shows what is still sustainable per day for the rest of the window: remaining
116
+ # budget divided by days left. Dropping under the even-pace ideal means future
117
+ # days have already been eaten into. Both inputs are server-reported for the
118
+ # whole account, so every machine on the account computes the same figure.
119
+ # resets_at is Unix epoch seconds.
120
+ $DAY_STR = ""
121
+ $RESETS_AT = $data.rate_limits.seven_day.resets_at
122
+ if (($SEVEN_D_RAW -ne $null) -and ($RESETS_AT -ne $null)) {
123
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
124
+ $daysLeft = ([double]$RESETS_AT - $now) / 86400
125
+ if ($daysLeft -gt 0) {
126
+ $dayIdeal = 100 / 7
127
+ $dayRate = (100 - [double]$SEVEN_D_RAW) / $daysLeft
128
+ if ($dayRate -lt 0) { $dayRate = 0 }
129
+ $DAY_COLOR = if ($dayRate -lt $dayIdeal) { $RED } elseif ($dayRate -lt ($dayIdeal * 1.1)) { $YELLOW } else { $GREEN }
130
+ $DAY_FLAG = if ($dayRate -lt $dayIdeal) { " !" } else { "" }
131
+ $DAY_STR = $DAY_COLOR + "day: $([int][math]::Round($dayRate))/$([int][math]::Round($dayIdeal))%$DAY_FLAG" + $RESET + $DIM
132
+ }
133
+ }
134
+
135
+ $RATE_PARTS = @()
136
+ if ($FIVE_H_RAW -ne $null) { $RATE_PARTS += "5h: $([int]$FIVE_H_RAW)%" }
137
+ if ($SEVEN_D_RAW -ne $null) { $RATE_PARTS += "7d: $([int]$SEVEN_D_RAW)%" }
138
+ if ($DAY_STR) { $RATE_PARTS += $DAY_STR }
139
+ $RATE_STR = if ($RATE_PARTS.Count -gt 0) { " - " + ($RATE_PARTS -join " | ") } else { "" }
140
+
118
141
  # PAL: Signal trend (reads 10-min cache written by session intelligence)
119
142
  $SIGNAL_STR = ""
120
143
  $signalCache = Join-Path $env:USERPROFILE ".pal\memory\state\signal-cache.json"
@@ -107,17 +107,6 @@ if [ -d "$PROJECTS_DIR" ]; then
107
107
  done
108
108
  fi
109
109
 
110
- # Rate limits (Pro/Max only — absent for other plans and on Cursor)
111
- FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
112
- SEVEN_D=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
113
- RATE_PARTS=()
114
- [ -n "$FIVE_H" ] && RATE_PARTS+=("5h: ${FIVE_H%.*}%")
115
- [ -n "$SEVEN_D" ] && RATE_PARTS+=("7d: ${SEVEN_D%.*}%")
116
- RATE_STR=""
117
- if [ ${#RATE_PARTS[@]} -gt 0 ]; then
118
- RATE_STR=" │ $(IFS=" | "; echo "${RATE_PARTS[*]}")"
119
- fi
120
-
121
110
  # Create context progress bar (20 chars wide)
122
111
  FILLED=$((USED / 5))
123
112
  EMPTY=$((20 - FILLED))
@@ -147,6 +136,51 @@ else
147
136
  CONTEXT_COLOR=$GREEN
148
137
  fi
149
138
 
139
+ # Rate limits (Pro/Max only — absent for other plans and on Cursor)
140
+ FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
141
+ SEVEN_D=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
142
+
143
+ # Daily soft limit — spending the weekly budget evenly is 100/7 ≈ 14% per day.
144
+ # Shows what is still sustainable per day for the rest of the window: remaining
145
+ # budget divided by days left. Dropping under the even-pace ideal means future
146
+ # days have already been eaten into. Both inputs are server-reported for the
147
+ # whole account, so every machine on the account computes the same figure.
148
+ # resets_at is Unix epoch seconds.
149
+ DAY_STR=""
150
+ RESETS_AT=$(echo "$input" | jq -r '.rate_limits.seven_day.resets_at // empty')
151
+ if [ -n "$SEVEN_D" ] && [ -n "$RESETS_AT" ]; then
152
+ DAYS_LEFT=$(echo "($RESETS_AT - $(date +%s)) / 86400" | bc -l)
153
+ if (( $(echo "$DAYS_LEFT > 0" | bc -l) )); then
154
+ DAY_IDEAL=$(echo "100 / 7" | bc -l)
155
+ DAY_RATE=$(echo "(100 - $SEVEN_D) / $DAYS_LEFT" | bc -l)
156
+ (( $(echo "$DAY_RATE < 0" | bc -l) )) && DAY_RATE=0
157
+ if (( $(echo "$DAY_RATE < $DAY_IDEAL" | bc -l) )); then
158
+ DAY_COLOR=$RED
159
+ DAY_FLAG=" ⚠️"
160
+ elif (( $(echo "$DAY_RATE < $DAY_IDEAL * 1.1" | bc -l) )); then
161
+ DAY_COLOR=$YELLOW
162
+ DAY_FLAG=""
163
+ else
164
+ DAY_COLOR=$GREEN
165
+ DAY_FLAG=""
166
+ fi
167
+ DAY_STR="${DAY_COLOR}day: $(printf '%.0f' "$DAY_RATE")/$(printf '%.0f' "$DAY_IDEAL")%${DAY_FLAG}${RESET}${DIM}"
168
+ fi
169
+ fi
170
+
171
+ RATE_PARTS=()
172
+ [ -n "$FIVE_H" ] && RATE_PARTS+=("5h: ${FIVE_H%.*}%")
173
+ [ -n "$SEVEN_D" ] && RATE_PARTS+=("7d: ${SEVEN_D%.*}%")
174
+ [ -n "$DAY_STR" ] && RATE_PARTS+=("$DAY_STR")
175
+ RATE_STR=""
176
+ if [ ${#RATE_PARTS[@]} -gt 0 ]; then
177
+ RATE_JOINED="${RATE_PARTS[0]}"
178
+ for PART in "${RATE_PARTS[@]:1}"; do
179
+ RATE_JOINED="${RATE_JOINED} | ${PART}"
180
+ done
181
+ RATE_STR=" │ ${RATE_JOINED}"
182
+ fi
183
+
150
184
  # PAL: Signal trend (reads 10-min cache written by session intelligence)
151
185
  SIGNAL_STR=""
152
186
  SIGNAL_CACHE="$HOME/.pal/memory/state/signal-cache.json"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.62.1",
3
+ "version": "0.63.0",
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",