portable-agent-layer 0.26.0 → 0.27.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 +115 -0
- package/assets/skills/consulting-report/demo/content/current-state.md +33 -0
- package/assets/skills/consulting-report/demo/content/executive-summary.md +19 -0
- package/assets/skills/consulting-report/demo/content/report-data.ts +101 -0
- package/assets/skills/consulting-report/demo/diagrams/.gitkeep +0 -0
- package/assets/skills/consulting-report/template/README.md +28 -0
- package/assets/skills/consulting-report/template/content/executive-summary.md +19 -0
- package/assets/skills/consulting-report/template/content/report-data.ts +59 -0
- package/assets/skills/consulting-report/template/diagrams/.gitkeep +0 -0
- package/assets/skills/consulting-report/tools/generate-pdf.ts +508 -0
- package/assets/skills/consulting-report/tools/scaffold.ts +74 -0
- package/assets/skills/create-pdf/SKILL.md +33 -42
- package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +91 -0
- package/assets/skills/think/SKILL.md +70 -7
- package/package.json +4 -2
- package/src/cli/index.ts +51 -13
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// consulting-report skill tool: render a structured report directory to a branded PDF.
|
|
4
|
+
// Pipeline: report-data.ts + section markdown + diagrams -> HTML -> PDF (Playwright).
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// bun ~/.pal/skills/consulting-report/tools/generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>]
|
|
8
|
+
//
|
|
9
|
+
// <report-dir> must contain content/report-data.ts (default export or named `report`).
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { constants as fsConstants } from "node:fs";
|
|
13
|
+
import { access, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
import { marked } from "marked";
|
|
16
|
+
import { chromium } from "playwright";
|
|
17
|
+
|
|
18
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface Brand {
|
|
21
|
+
businessName: string;
|
|
22
|
+
brandLabel?: string; // sub-label shown under logo on cover (e.g. "TELOS Assessment")
|
|
23
|
+
logoPath?: string; // absolute filesystem path; text-only cover if missing
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Section {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
content: string; // markdown string OR a path relative to content/ ending in .md
|
|
30
|
+
subsections?: Section[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Finding {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
37
|
+
evidence: string; // markdown
|
|
38
|
+
impact?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Recommendation {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
priority: "immediate" | "short-term" | "long-term";
|
|
45
|
+
detail: string; // markdown
|
|
46
|
+
owner?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Conclusion {
|
|
50
|
+
assessorNote?: string;
|
|
51
|
+
contextNote?: string;
|
|
52
|
+
closingRemarks?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ConsultingReport {
|
|
56
|
+
clientName: string;
|
|
57
|
+
reportTitle: string;
|
|
58
|
+
reportDate: string;
|
|
59
|
+
classification: string;
|
|
60
|
+
version: string;
|
|
61
|
+
brand?: Brand;
|
|
62
|
+
sections: Section[];
|
|
63
|
+
findings?: Finding[];
|
|
64
|
+
recommendations?: Recommendation[];
|
|
65
|
+
conclusion?: Conclusion;
|
|
66
|
+
supportingEvidence?: Record<string, string[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const DEFAULT_BRAND: Brand = {
|
|
72
|
+
businessName: "Konvert7",
|
|
73
|
+
brandLabel: "Konvert7 Assessment",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Color palette (ported verbatim from PAI's ConsultingReport workflow).
|
|
77
|
+
const COLOR = {
|
|
78
|
+
navy: "#1B2A4A",
|
|
79
|
+
blue: "#2E5090",
|
|
80
|
+
red: "#DC2626",
|
|
81
|
+
amber: "#D97706",
|
|
82
|
+
green: "#059669",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function exists(p: string): Promise<boolean> {
|
|
88
|
+
try {
|
|
89
|
+
await access(p, fsConstants.F_OK);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function escapeHtml(s: string): string {
|
|
97
|
+
return s
|
|
98
|
+
.replace(/&/g, "&")
|
|
99
|
+
.replace(/</g, "<")
|
|
100
|
+
.replace(/>/g, ">")
|
|
101
|
+
.replace(/"/g, """);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function slugify(s: string): string {
|
|
105
|
+
return s
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
108
|
+
.replace(/^-|-$/g, "");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveMarkdown(content: string, contentDir: string): Promise<string> {
|
|
112
|
+
// If `content` looks like a relative path to an existing .md file, load it.
|
|
113
|
+
// Otherwise treat it as inline markdown.
|
|
114
|
+
if (content.endsWith(".md") && !content.includes("\n")) {
|
|
115
|
+
const p = isAbsolute(content) ? content : join(contentDir, content);
|
|
116
|
+
if (await exists(p)) return readFile(p, "utf8");
|
|
117
|
+
}
|
|
118
|
+
return content;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Compress diagrams with sips (macOS) to JPEG 70% max 1200px wide.
|
|
122
|
+
// Idempotent — outputs to diagrams-compressed/. Gracefully skips if sips missing.
|
|
123
|
+
async function compressDiagrams(reportDir: string): Promise<string> {
|
|
124
|
+
const srcDir = join(reportDir, "diagrams");
|
|
125
|
+
const outDir = join(reportDir, "diagrams-compressed");
|
|
126
|
+
if (!(await exists(srcDir))) return srcDir;
|
|
127
|
+
|
|
128
|
+
const sipsCheck = spawnSync("which", ["sips"], { stdio: "ignore" });
|
|
129
|
+
if (sipsCheck.status !== 0) {
|
|
130
|
+
return srcDir; // no sips — serve originals
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await spawnSync("mkdir", ["-p", outDir], { stdio: "ignore" });
|
|
134
|
+
const files = await readdir(srcDir);
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
if (!/\.(png|jpg|jpeg)$/i.test(file)) continue;
|
|
137
|
+
const base = file.replace(/\.[^.]+$/, "");
|
|
138
|
+
const src = join(srcDir, file);
|
|
139
|
+
const dst = join(outDir, `${base}.jpg`);
|
|
140
|
+
if (await exists(dst)) {
|
|
141
|
+
const [srcStat, dstStat] = await Promise.all([stat(src), stat(dst)]);
|
|
142
|
+
if (dstStat.mtimeMs >= srcStat.mtimeMs) continue;
|
|
143
|
+
}
|
|
144
|
+
spawnSync(
|
|
145
|
+
"sips",
|
|
146
|
+
[
|
|
147
|
+
"-s",
|
|
148
|
+
"format",
|
|
149
|
+
"jpeg",
|
|
150
|
+
"-s",
|
|
151
|
+
"formatOptions",
|
|
152
|
+
"70",
|
|
153
|
+
"--resampleWidth",
|
|
154
|
+
"1200",
|
|
155
|
+
src,
|
|
156
|
+
"--out",
|
|
157
|
+
dst,
|
|
158
|
+
],
|
|
159
|
+
{ stdio: "ignore" }
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return outDir;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── HTML builders ────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function css(): string {
|
|
168
|
+
return `
|
|
169
|
+
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
170
|
+
body {
|
|
171
|
+
font-family: Georgia, Garamond, "Times New Roman", serif;
|
|
172
|
+
font-size: 10.5pt;
|
|
173
|
+
line-height: 1.55;
|
|
174
|
+
color: #1a1a2e;
|
|
175
|
+
margin: 0;
|
|
176
|
+
}
|
|
177
|
+
h1, h2, h3, h4 { font-family: Inter, "Helvetica Neue", Arial, sans-serif; page-break-after: avoid; }
|
|
178
|
+
h1 { font-size: 22pt; color: ${COLOR.navy}; border-bottom: 2px solid ${COLOR.navy}; padding-bottom: 6px; margin-top: 0; page-break-before: always; }
|
|
179
|
+
h2 { font-size: 15pt; color: ${COLOR.blue}; margin-top: 1.4em; }
|
|
180
|
+
h3 { font-size: 12pt; color: ${COLOR.navy}; margin-top: 1.1em; }
|
|
181
|
+
p, li { orphans: 3; widows: 3; }
|
|
182
|
+
a { color: ${COLOR.blue}; text-decoration: none; }
|
|
183
|
+
blockquote { border-left: 3px solid ${COLOR.blue}; padding-left: 12px; color: #333; margin: 12px 0; font-style: italic; }
|
|
184
|
+
code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 9.5pt; font-family: "SF Mono", Menlo, Consolas, monospace; }
|
|
185
|
+
pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; page-break-inside: avoid; }
|
|
186
|
+
pre code { background: transparent; padding: 0; }
|
|
187
|
+
hr { border: none; border-top: 1px solid #e2e8f0; margin: 20px 0; }
|
|
188
|
+
ul, ol { padding-left: 1.4em; }
|
|
189
|
+
img { max-width: 100%; height: auto; }
|
|
190
|
+
|
|
191
|
+
table { border-collapse: collapse; width: 100%; margin: 12px 0; page-break-inside: avoid; font-family: Inter, "Helvetica Neue", Arial, sans-serif; font-size: 9.5pt; }
|
|
192
|
+
thead th { background: ${COLOR.navy}; color: #fff; font-size: 9pt; padding: 8px 10px; text-align: left; }
|
|
193
|
+
tbody td { padding: 7px 10px; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
|
|
194
|
+
tbody tr:nth-child(even) { background: #f8fafc; }
|
|
195
|
+
tr { page-break-inside: avoid; }
|
|
196
|
+
|
|
197
|
+
.cover { height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 0 1in; page-break-after: always; }
|
|
198
|
+
.cover .classification { position: absolute; top: 40px; font-family: Inter, sans-serif; font-size: 10pt; font-weight: 700; color: ${COLOR.red}; letter-spacing: 0.2em; }
|
|
199
|
+
.cover .logo { max-width: 220px; max-height: 120px; margin-bottom: 16px; }
|
|
200
|
+
.cover .brand-label { font-family: Inter, sans-serif; font-size: 11pt; letter-spacing: 0.15em; color: ${COLOR.blue}; text-transform: uppercase; margin-bottom: 48px; }
|
|
201
|
+
.cover .report-title { font-family: Inter, sans-serif; font-size: 30pt; font-weight: 700; color: ${COLOR.navy}; line-height: 1.2; margin-bottom: 16px; }
|
|
202
|
+
.cover .prepared-for { font-size: 14pt; color: #334155; margin-bottom: 28px; }
|
|
203
|
+
.cover .divider { width: 120px; height: 2px; background: ${COLOR.navy}; margin: 16px auto; }
|
|
204
|
+
.cover .meta { font-family: Inter, sans-serif; font-size: 10pt; color: #64748b; letter-spacing: 0.05em; }
|
|
205
|
+
.cover .company-name { position: absolute; bottom: 80px; font-family: Inter, sans-serif; font-size: 12pt; font-weight: 700; color: ${COLOR.navy}; letter-spacing: 0.2em; }
|
|
206
|
+
.cover .footer-note { position: absolute; bottom: 40px; font-size: 8.5pt; color: #94a3b8; font-style: italic; }
|
|
207
|
+
|
|
208
|
+
.toc { page-break-after: always; }
|
|
209
|
+
.toc h1 { page-break-before: avoid; }
|
|
210
|
+
.toc ol { list-style: none; padding-left: 0; font-family: Inter, sans-serif; font-size: 11pt; }
|
|
211
|
+
.toc ol ol { padding-left: 1.4em; font-size: 10pt; margin-top: 4px; }
|
|
212
|
+
.toc li { margin: 6px 0; }
|
|
213
|
+
.toc a { color: ${COLOR.navy}; display: flex; justify-content: space-between; border-bottom: 1px dotted #cbd5e1; padding-bottom: 3px; }
|
|
214
|
+
.toc .toc-title { }
|
|
215
|
+
.toc .toc-page { color: #94a3b8; font-size: 9pt; }
|
|
216
|
+
|
|
217
|
+
.box { padding: 0.7rem 1rem; border-radius: 4px; border-left: 4px solid; margin: 12px 0; page-break-inside: avoid; }
|
|
218
|
+
.box-red { border-color: ${COLOR.red}; background: #fef2f2; color: #7f1d1d; }
|
|
219
|
+
.box-green { border-color: ${COLOR.green}; background: #f0fdf4; color: #14532d; }
|
|
220
|
+
.box-amber { border-color: ${COLOR.amber}; background: #fffbeb; color: #78350f; }
|
|
221
|
+
.box-blue { border-color: ${COLOR.blue}; background: #f0f4fa; color: ${COLOR.navy}; }
|
|
222
|
+
|
|
223
|
+
.badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-family: Inter, sans-serif; font-size: 8.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 8px; vertical-align: middle; }
|
|
224
|
+
.badge-critical, .badge-immediate { background: ${COLOR.red}; color: #fff; }
|
|
225
|
+
.badge-short-term { background: ${COLOR.amber}; color: #fff; }
|
|
226
|
+
.badge-long-term { background: ${COLOR.green}; color: #fff; }
|
|
227
|
+
.badge-high { background: ${COLOR.amber}; color: #fff; }
|
|
228
|
+
.badge-medium { background: ${COLOR.blue}; color: #fff; }
|
|
229
|
+
.badge-low { background: #64748b; color: #fff; }
|
|
230
|
+
|
|
231
|
+
.finding, .recommendation { margin: 16px 0; page-break-inside: avoid; }
|
|
232
|
+
.finding h3, .recommendation h3 { margin-top: 0; }
|
|
233
|
+
.finding .label, .recommendation .label { display: block; font-family: Inter, sans-serif; font-size: 9pt; font-weight: 700; letter-spacing: 0.08em; color: #64748b; text-transform: uppercase; margin-bottom: 4px; }
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function renderSections(
|
|
238
|
+
sections: Section[],
|
|
239
|
+
contentDir: string,
|
|
240
|
+
depth = 1
|
|
241
|
+
): Promise<string> {
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
for (const s of sections) {
|
|
244
|
+
const md = await resolveMarkdown(s.content, contentDir);
|
|
245
|
+
const htmlBody = await marked.parse(md);
|
|
246
|
+
const tag = depth === 1 ? "h1" : depth === 2 ? "h2" : "h3";
|
|
247
|
+
out.push(`<${tag} id="${s.id}">${escapeHtml(s.title)}</${tag}>`);
|
|
248
|
+
out.push(htmlBody as string);
|
|
249
|
+
if (s.subsections && s.subsections.length) {
|
|
250
|
+
out.push(await renderSections(s.subsections, contentDir, depth + 1));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out.join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderToc(sections: Section[]): string {
|
|
257
|
+
const renderItems = (items: Section[]): string =>
|
|
258
|
+
`<ol>${items
|
|
259
|
+
.map(
|
|
260
|
+
(s) =>
|
|
261
|
+
`<li><a href="#${s.id}"><span class="toc-title">${escapeHtml(s.title)}</span></a>${
|
|
262
|
+
s.subsections?.length ? renderItems(s.subsections) : ""
|
|
263
|
+
}</li>`
|
|
264
|
+
)
|
|
265
|
+
.join("")}</ol>`;
|
|
266
|
+
|
|
267
|
+
return `<div class="toc"><h1 id="toc">Table of Contents</h1>${renderItems(sections)}</div>`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderCover(r: ConsultingReport, brand: Brand): string {
|
|
271
|
+
const logo =
|
|
272
|
+
brand.logoPath && brand.logoPath.length
|
|
273
|
+
? `<img class="logo" src="file://${brand.logoPath}" alt="${escapeHtml(brand.businessName)}">`
|
|
274
|
+
: "";
|
|
275
|
+
const brandLabel = brand.brandLabel || `${brand.businessName} Assessment`;
|
|
276
|
+
return `
|
|
277
|
+
<div class="cover">
|
|
278
|
+
<div class="classification">${escapeHtml(r.classification)}</div>
|
|
279
|
+
${logo}
|
|
280
|
+
<div class="brand-label">${escapeHtml(brandLabel)}</div>
|
|
281
|
+
<div class="report-title">${escapeHtml(r.reportTitle)}</div>
|
|
282
|
+
<div class="prepared-for">Prepared for ${escapeHtml(r.clientName)}</div>
|
|
283
|
+
<div class="divider"></div>
|
|
284
|
+
<div class="meta">${escapeHtml(r.reportDate)} · Version ${escapeHtml(r.version)}</div>
|
|
285
|
+
<div class="company-name">${escapeHtml(brand.businessName.toUpperCase())} CONSULTING</div>
|
|
286
|
+
<div class="footer-note">${escapeHtml(r.classification)} — For Authorized Recipients Only</div>
|
|
287
|
+
</div>
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function renderFindings(findings: Finding[] | undefined): Promise<string> {
|
|
292
|
+
if (!findings || !findings.length) return "";
|
|
293
|
+
const parts = [`<h1 id="findings">Findings</h1>`];
|
|
294
|
+
for (const f of findings) {
|
|
295
|
+
const boxClass =
|
|
296
|
+
f.severity === "critical" || f.severity === "high"
|
|
297
|
+
? "box-red"
|
|
298
|
+
: f.severity === "medium"
|
|
299
|
+
? "box-amber"
|
|
300
|
+
: "box-blue";
|
|
301
|
+
const evidenceHtml = await marked.parse(f.evidence);
|
|
302
|
+
const impactHtml = f.impact ? await marked.parse(f.impact) : "";
|
|
303
|
+
parts.push(`
|
|
304
|
+
<div class="finding box ${boxClass}" id="${f.id}">
|
|
305
|
+
<span class="label">Finding — ${escapeHtml(f.severity)}<span class="badge badge-${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span></span>
|
|
306
|
+
<h3>${escapeHtml(f.title)}</h3>
|
|
307
|
+
${evidenceHtml}
|
|
308
|
+
${impactHtml ? `<p><strong>Impact:</strong></p>${impactHtml}` : ""}
|
|
309
|
+
</div>`);
|
|
310
|
+
}
|
|
311
|
+
return parts.join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function renderRecommendations(
|
|
315
|
+
recs: Recommendation[] | undefined
|
|
316
|
+
): Promise<string> {
|
|
317
|
+
if (!recs || !recs.length) return "";
|
|
318
|
+
const parts = [`<h1 id="recommendations">Recommendations</h1>`];
|
|
319
|
+
for (const r of recs) {
|
|
320
|
+
const detailHtml = await marked.parse(r.detail);
|
|
321
|
+
parts.push(`
|
|
322
|
+
<div class="recommendation box box-blue" id="${r.id}">
|
|
323
|
+
<span class="label">Recommendation<span class="badge badge-${escapeHtml(r.priority)}">${escapeHtml(r.priority)}</span></span>
|
|
324
|
+
<h3>${escapeHtml(r.title)}</h3>
|
|
325
|
+
${detailHtml}
|
|
326
|
+
${r.owner ? `<p><strong>Owner:</strong> ${escapeHtml(r.owner)}</p>` : ""}
|
|
327
|
+
</div>`);
|
|
328
|
+
}
|
|
329
|
+
return parts.join("\n");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function renderConclusion(c: Conclusion | undefined): Promise<string> {
|
|
333
|
+
if (!c) return "";
|
|
334
|
+
const bits: string[] = [`<h1 id="conclusion">Conclusion</h1>`];
|
|
335
|
+
if (c.assessorNote)
|
|
336
|
+
bits.push(
|
|
337
|
+
`<h3>Assessor's Note</h3>${(await marked.parse(c.assessorNote)) as string}`
|
|
338
|
+
);
|
|
339
|
+
if (c.contextNote)
|
|
340
|
+
bits.push(`<h3>Context</h3>${(await marked.parse(c.contextNote)) as string}`);
|
|
341
|
+
if (c.closingRemarks)
|
|
342
|
+
bits.push(
|
|
343
|
+
`<h3>Closing Remarks</h3>${(await marked.parse(c.closingRemarks)) as string}`
|
|
344
|
+
);
|
|
345
|
+
return bits.join("\n");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function renderAppendix(
|
|
349
|
+
evidence: Record<string, string[]> | undefined
|
|
350
|
+
): Promise<string> {
|
|
351
|
+
if (!evidence) return "";
|
|
352
|
+
const entries = Object.entries(evidence);
|
|
353
|
+
if (!entries.length) return "";
|
|
354
|
+
const parts = [`<h1 id="appendix">Appendix — Supporting Evidence</h1>`];
|
|
355
|
+
for (const [heading, items] of entries) {
|
|
356
|
+
parts.push(`<h3>${escapeHtml(heading)}</h3>`);
|
|
357
|
+
parts.push(`<ul>${items.map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>`);
|
|
358
|
+
}
|
|
359
|
+
return parts.join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function buildHtml(report: ConsultingReport, contentDir: string): Promise<string> {
|
|
363
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
364
|
+
const brand: Brand = { ...DEFAULT_BRAND, ...(report.brand || {}) };
|
|
365
|
+
|
|
366
|
+
const [sectionsHtml, findingsHtml, recsHtml, conclusionHtml, appendixHtml] =
|
|
367
|
+
await Promise.all([
|
|
368
|
+
renderSections(report.sections, contentDir),
|
|
369
|
+
renderFindings(report.findings),
|
|
370
|
+
renderRecommendations(report.recommendations),
|
|
371
|
+
renderConclusion(report.conclusion),
|
|
372
|
+
renderAppendix(report.supportingEvidence),
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
return `<!doctype html>
|
|
376
|
+
<html lang="en">
|
|
377
|
+
<head>
|
|
378
|
+
<meta charset="utf-8">
|
|
379
|
+
<title>${escapeHtml(report.reportTitle)} — ${escapeHtml(report.clientName)}</title>
|
|
380
|
+
<style>${css()}</style>
|
|
381
|
+
</head>
|
|
382
|
+
<body>
|
|
383
|
+
${renderCover(report, brand)}
|
|
384
|
+
${renderToc(report.sections)}
|
|
385
|
+
${sectionsHtml}
|
|
386
|
+
${findingsHtml}
|
|
387
|
+
${recsHtml}
|
|
388
|
+
${conclusionHtml}
|
|
389
|
+
${appendixHtml}
|
|
390
|
+
</body>
|
|
391
|
+
</html>
|
|
392
|
+
`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Playwright ───────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
async function renderPdf(
|
|
398
|
+
htmlPath: string,
|
|
399
|
+
pdfPath: string,
|
|
400
|
+
report: ConsultingReport,
|
|
401
|
+
brand: Brand
|
|
402
|
+
) {
|
|
403
|
+
const browser = await chromium.launch();
|
|
404
|
+
try {
|
|
405
|
+
const page = await browser.newPage();
|
|
406
|
+
await page.goto(`file://${htmlPath}`, { waitUntil: "networkidle" });
|
|
407
|
+
|
|
408
|
+
// Wait for all images
|
|
409
|
+
await page.evaluate(async () => {
|
|
410
|
+
const imgs = Array.from(document.querySelectorAll("img"));
|
|
411
|
+
await Promise.all(
|
|
412
|
+
imgs.map((img) =>
|
|
413
|
+
img.complete
|
|
414
|
+
? Promise.resolve()
|
|
415
|
+
: new Promise((res) => {
|
|
416
|
+
img.onload = () => res(null);
|
|
417
|
+
img.onerror = () => res(null);
|
|
418
|
+
setTimeout(() => res(null), 5000);
|
|
419
|
+
})
|
|
420
|
+
)
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const header = `
|
|
425
|
+
<div style="width:100%; font-family:'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.9in 4px; display:flex; justify-content:space-between; align-items:center; border-bottom:0.5px solid #d0d5dd;">
|
|
426
|
+
<span style="font-weight:700; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(report.clientName.toUpperCase())}</span>
|
|
427
|
+
<span style="color:#94a3b8; letter-spacing:0.03em;">${escapeHtml(report.reportTitle)}</span>
|
|
428
|
+
</div>`;
|
|
429
|
+
|
|
430
|
+
const footer = `
|
|
431
|
+
<div style="width:100%; font-family:'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:4px 0.9in 0; display:flex; justify-content:space-between; align-items:center; border-top:0.5px solid #d0d5dd;">
|
|
432
|
+
<span style="color:${COLOR.red}; font-weight:600; letter-spacing:0.05em;">${escapeHtml(report.classification)}</span>
|
|
433
|
+
<span style="color:${COLOR.navy};">${escapeHtml(brand.businessName)} Consulting</span>
|
|
434
|
+
<span style="color:${COLOR.navy};">Page <span class="pageNumber"></span></span>
|
|
435
|
+
</div>`;
|
|
436
|
+
|
|
437
|
+
await page.pdf({
|
|
438
|
+
path: pdfPath,
|
|
439
|
+
format: "A4",
|
|
440
|
+
printBackground: true,
|
|
441
|
+
displayHeaderFooter: true,
|
|
442
|
+
headerTemplate: header,
|
|
443
|
+
footerTemplate: footer,
|
|
444
|
+
margin: { top: "0.8in", right: "0.9in", bottom: "0.7in", left: "0.9in" },
|
|
445
|
+
preferCSSPageSize: false,
|
|
446
|
+
});
|
|
447
|
+
} finally {
|
|
448
|
+
await browser.close();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
async function loadReport(
|
|
455
|
+
reportDir: string
|
|
456
|
+
): Promise<{ report: ConsultingReport; contentDir: string }> {
|
|
457
|
+
const dataPath = join(reportDir, "content", "report-data.ts");
|
|
458
|
+
if (!(await exists(dataPath))) {
|
|
459
|
+
throw new Error(`report-data.ts not found at ${dataPath}`);
|
|
460
|
+
}
|
|
461
|
+
const mod: { default?: ConsultingReport; report?: ConsultingReport } = await import(
|
|
462
|
+
dataPath
|
|
463
|
+
);
|
|
464
|
+
const report = mod.default || mod.report;
|
|
465
|
+
if (!report) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`report-data.ts must export a default ConsultingReport or a named export 'report'`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return { report, contentDir: join(reportDir, "content") };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function main() {
|
|
474
|
+
const args = process.argv.slice(2);
|
|
475
|
+
if (args.length === 0) {
|
|
476
|
+
console.error("usage: generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>]");
|
|
477
|
+
process.exit(1);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const reportDir = resolve(args[0]);
|
|
481
|
+
let pdfOut = "";
|
|
482
|
+
let htmlOut = "";
|
|
483
|
+
for (let i = 1; i < args.length; i++) {
|
|
484
|
+
if (args[i] === "--pdf") pdfOut = resolve(args[++i]);
|
|
485
|
+
else if (args[i] === "--html") htmlOut = resolve(args[++i]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const { report, contentDir } = await loadReport(reportDir);
|
|
489
|
+
const brand: Brand = { ...DEFAULT_BRAND, ...(report.brand || {}) };
|
|
490
|
+
|
|
491
|
+
// Compress diagrams (no-op if dir missing or sips absent)
|
|
492
|
+
await compressDiagrams(reportDir);
|
|
493
|
+
|
|
494
|
+
const slug = `${slugify(report.clientName)}-${slugify(report.reportTitle)}-${slugify(report.reportDate)}`;
|
|
495
|
+
if (!pdfOut) pdfOut = join(reportDir, `${slug}.pdf`);
|
|
496
|
+
if (!htmlOut) htmlOut = join(reportDir, `${slug}.html`);
|
|
497
|
+
|
|
498
|
+
const html = await buildHtml(report, contentDir);
|
|
499
|
+
await writeFile(htmlOut, html, "utf8");
|
|
500
|
+
|
|
501
|
+
await renderPdf(htmlOut, pdfOut, report, brand);
|
|
502
|
+
|
|
503
|
+
const [htmlStat, pdfStat] = await Promise.all([stat(htmlOut), stat(pdfOut)]);
|
|
504
|
+
console.log(`HTML: ${htmlOut} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
|
|
505
|
+
console.log(`PDF: ${pdfOut} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await main();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// consulting-report skill tool: scaffold a new report directory from the template.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// bun ~/.pal/skills/consulting-report/tools/scaffold.ts <target-dir> [--client "Client Name"] [--title "Report Title"]
|
|
7
|
+
|
|
8
|
+
import { constants as fsConstants } from "node:fs";
|
|
9
|
+
import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
if (args.length === 0) {
|
|
15
|
+
console.error(
|
|
16
|
+
'usage: scaffold.ts <target-dir> [--client "Client Name"] [--title "Report Title"]'
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const targetDir = resolve(args[0]);
|
|
22
|
+
let clientName = "";
|
|
23
|
+
let reportTitle = "";
|
|
24
|
+
for (let i = 1; i < args.length; i++) {
|
|
25
|
+
if (args[i] === "--client") clientName = args[++i];
|
|
26
|
+
else if (args[i] === "--title") reportTitle = args[++i];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function exists(p: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await access(p, fsConstants.F_OK);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const templateDir = resolve(here, "..", "template");
|
|
40
|
+
|
|
41
|
+
if (!(await exists(templateDir))) {
|
|
42
|
+
console.error(`template not found at ${templateDir}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
if (await exists(targetDir)) {
|
|
46
|
+
console.error(`target already exists: ${targetDir}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await mkdir(dirname(targetDir), { recursive: true });
|
|
51
|
+
await cp(templateDir, targetDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
// Stamp today's date + client/title + tool import path into the new report-data.ts.
|
|
54
|
+
// Scaffolded reports live outside the skill tree, so the type-import needs an
|
|
55
|
+
// absolute path to the generator rather than a relative one.
|
|
56
|
+
const dataPath = join(targetDir, "content", "report-data.ts");
|
|
57
|
+
const toolPath = resolve(here, "generate-pdf").replace(/\.ts$/, "");
|
|
58
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
59
|
+
let data = await readFile(dataPath, "utf8");
|
|
60
|
+
data = data.replace(/\{\{DATE\}\}/g, today);
|
|
61
|
+
data = data.replace(/\{\{TOOL_PATH\}\}/g, toolPath);
|
|
62
|
+
if (clientName) data = data.replace(/\{\{CLIENT_NAME\}\}/g, clientName);
|
|
63
|
+
if (reportTitle) data = data.replace(/\{\{REPORT_TITLE\}\}/g, reportTitle);
|
|
64
|
+
await writeFile(dataPath, data, "utf8");
|
|
65
|
+
|
|
66
|
+
console.log(`Scaffolded: ${targetDir}`);
|
|
67
|
+
console.log("Next steps:");
|
|
68
|
+
console.log(` 1. Edit ${join(targetDir, "content", "report-data.ts")}`);
|
|
69
|
+
console.log(` 2. Fill ${join(targetDir, "content")} with your section markdown files`);
|
|
70
|
+
console.log(` 3. Drop images into ${join(targetDir, "diagrams")}`);
|
|
71
|
+
console.log(` 4. Render:`);
|
|
72
|
+
console.log(
|
|
73
|
+
` bun ~/.pal/skills/consulting-report/tools/generate-pdf.ts ${targetDir}`
|
|
74
|
+
);
|