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.
- package/assets/skills/consulting-report/SKILL.md +3 -3
- package/assets/skills/consulting-report/tools/generate-pdf.mjs +226 -0
- package/assets/skills/consulting-report/tools/generate-pdf.ts +4 -1
- package/assets/skills/consulting-report/tools/scaffold.ts +1 -1
- package/assets/skills/create-pdf/SKILL.md +4 -4
- package/assets/skills/create-pdf/tools/md-to-html-pdf.mjs +103 -0
- package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +4 -1
- package/assets/skills/playwright/SKILL.md +1 -1
- package/assets/skills/playwright/tools/shot-lib.mjs +44 -0
- package/assets/skills/playwright/tools/shot.mjs +89 -0
- package/assets/skills/playwright/tools/shot.ts +4 -2
- package/assets/statusline.ps1 +31 -8
- package/assets/statusline.sh +45 -11
- package/package.json +3 -1
|
@@ -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
|
|
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
|
|
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
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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";
|
package/assets/statusline.ps1
CHANGED
|
@@ -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"
|
package/assets/statusline.sh
CHANGED
|
@@ -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.
|
|
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",
|