@loviusc/sa-spec 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +23 -7
- package/README.ja.md +22 -6
- package/README.ko.md +22 -6
- package/README.zh-CN.md +22 -6
- package/README.zh-TW.md +49 -14
- package/install.bat +43 -0
- package/install.sh +43 -0
- package/package.json +8 -2
- package/readme.md +49 -14
- package/src/cli.js +235 -10
- package/src/pdf.js +605 -0
- package/templates/AGENTS.md +13 -8
- package/templates/readme.md +49 -14
- package/templates/skills/system-analysis/SKILL.md +14 -9
- package/uninstall.bat +22 -0
- package/uninstall.sh +15 -0
- package/update.bat +43 -0
- package/update.sh +30 -0
package/src/pdf.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { spawnSync } = require("child_process");
|
|
5
|
+
const { pathToFileURL } = require("url");
|
|
6
|
+
|
|
7
|
+
function escapeHtml(str) {
|
|
8
|
+
return String(str)
|
|
9
|
+
.replace(/&/g, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseInline(text) {
|
|
16
|
+
if (!text) return "";
|
|
17
|
+
const codeSpans = [];
|
|
18
|
+
let processed = String(text).replace(/`([^`]+)`/g, (_, code) => {
|
|
19
|
+
const idx = codeSpans.length;
|
|
20
|
+
codeSpans.push(`<code>${escapeHtml(code)}</code>`);
|
|
21
|
+
return `\x00CODE_${idx}\x00`;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
processed = escapeHtml(processed);
|
|
25
|
+
|
|
26
|
+
// Images: 
|
|
27
|
+
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
|
28
|
+
return `<img alt="${alt}" src="${src}" style="max-width:100%;" />`;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Links: [text](url)
|
|
32
|
+
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
|
|
33
|
+
return `<a href="${href}">${label}</a>`;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Bold + Italic
|
|
37
|
+
processed = processed.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
38
|
+
// Bold
|
|
39
|
+
processed = processed.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
40
|
+
processed = processed.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
41
|
+
// Italic
|
|
42
|
+
processed = processed.replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
|
|
43
|
+
// Strikethrough
|
|
44
|
+
processed = processed.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
45
|
+
|
|
46
|
+
// Restore inline code spans
|
|
47
|
+
processed = processed.replace(/\x00CODE_(\d+)\x00/g, (_, idx) => codeSpans[Number(idx)]);
|
|
48
|
+
return processed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseTableRow(line) {
|
|
52
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
53
|
+
return trimmed.split("|").map((cell) => cell.trim());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isTableSeparator(line) {
|
|
57
|
+
if (!line || !line.includes("|")) return false;
|
|
58
|
+
const cells = parseTableRow(line);
|
|
59
|
+
return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getTableAlignments(sepLine) {
|
|
63
|
+
return parseTableRow(sepLine).map((c) => {
|
|
64
|
+
if (c.startsWith(":") && c.endsWith(":")) return "center";
|
|
65
|
+
if (c.endsWith(":")) return "right";
|
|
66
|
+
return "left";
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function markdownToHtmlBody(markdown) {
|
|
71
|
+
const lines = String(markdown).replace(/\r\n/g, "\n").split("\n");
|
|
72
|
+
const out = [];
|
|
73
|
+
let i = 0;
|
|
74
|
+
|
|
75
|
+
while (i < lines.length) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
|
|
78
|
+
// Page break comment
|
|
79
|
+
if (/^<!--\s*pagebreak\s*-->$/i.test(line.trim())) {
|
|
80
|
+
out.push('<div class="page-break"></div>');
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Empty line
|
|
86
|
+
if (!line.trim()) {
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fenced code block (``` or ````)
|
|
92
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})(\S*)\s*$/);
|
|
93
|
+
if (fenceMatch) {
|
|
94
|
+
const fence = fenceMatch[1];
|
|
95
|
+
const lang = fenceMatch[2] || "";
|
|
96
|
+
const codeLines = [];
|
|
97
|
+
i++;
|
|
98
|
+
while (i < lines.length && !lines[i].startsWith(fence)) {
|
|
99
|
+
codeLines.push(lines[i]);
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
if (i < lines.length) i++; // skip closing fence
|
|
103
|
+
const langLabel = lang ? `<div class="code-lang">${escapeHtml(lang)}</div>` : "";
|
|
104
|
+
out.push(
|
|
105
|
+
`<div class="code-block">${langLabel}<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(
|
|
106
|
+
codeLines.join("\n")
|
|
107
|
+
)}</code></pre></div>`
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Horizontal rule
|
|
113
|
+
if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line.trim())) {
|
|
114
|
+
out.push("<hr />");
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Heading
|
|
120
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
121
|
+
if (headingMatch) {
|
|
122
|
+
const level = headingMatch[1].length;
|
|
123
|
+
const content = parseInline(headingMatch[2].trim());
|
|
124
|
+
out.push(`<h${level}>${content}</h${level}>`);
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Table
|
|
130
|
+
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
131
|
+
const headers = parseTableRow(line);
|
|
132
|
+
const alignments = getTableAlignments(lines[i + 1]);
|
|
133
|
+
i += 2;
|
|
134
|
+
const rows = [];
|
|
135
|
+
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
|
136
|
+
rows.push(parseTableRow(lines[i]));
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
let tableHtml = "<table><thead><tr>";
|
|
140
|
+
headers.forEach((h, idx) => {
|
|
141
|
+
const align = alignments[idx] || "left";
|
|
142
|
+
tableHtml += `<th style="text-align:${align}">${parseInline(h)}</th>`;
|
|
143
|
+
});
|
|
144
|
+
tableHtml += "</tr></thead><tbody>";
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
tableHtml += "<tr>";
|
|
147
|
+
headers.forEach((_, idx) => {
|
|
148
|
+
const align = alignments[idx] || "left";
|
|
149
|
+
const cellVal = row[idx] !== undefined ? row[idx] : "";
|
|
150
|
+
tableHtml += `<td style="text-align:${align}">${parseInline(cellVal)}</td>`;
|
|
151
|
+
});
|
|
152
|
+
tableHtml += "</tr>";
|
|
153
|
+
}
|
|
154
|
+
tableHtml += "</tbody></table>";
|
|
155
|
+
out.push(tableHtml);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Blockquote
|
|
160
|
+
if (line.trim().startsWith(">")) {
|
|
161
|
+
const quoteLines = [];
|
|
162
|
+
while (i < lines.length && lines[i].trim().startsWith(">")) {
|
|
163
|
+
quoteLines.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
const firstLine = quoteLines[0] || "";
|
|
167
|
+
const alertMatch = firstLine.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i);
|
|
168
|
+
if (alertMatch) {
|
|
169
|
+
const type = alertMatch[1].toUpperCase();
|
|
170
|
+
const restFirst = alertMatch[2];
|
|
171
|
+
const bodyLines = restFirst ? [restFirst, ...quoteLines.slice(1)] : quoteLines.slice(1);
|
|
172
|
+
out.push(
|
|
173
|
+
`<div class="alert alert-${type.toLowerCase()}"><div class="alert-title">${type}</div><p>${bodyLines
|
|
174
|
+
.map((l) => parseInline(l))
|
|
175
|
+
.join("<br/>")}</p></div>`
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
out.push(`<blockquote>${quoteLines.map((l) => parseInline(l)).join("<br/>")}</blockquote>`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Unordered or Ordered List
|
|
184
|
+
const ulMatch = line.match(/^(\s*)([-*+])\s+(.+)$/);
|
|
185
|
+
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
186
|
+
if (ulMatch || olMatch) {
|
|
187
|
+
const isOrdered = Boolean(olMatch);
|
|
188
|
+
const tag = isOrdered ? "ol" : "ul";
|
|
189
|
+
const items = [];
|
|
190
|
+
while (i < lines.length) {
|
|
191
|
+
const cur = lines[i];
|
|
192
|
+
const mUl = cur.match(/^(\s*)([-*+])\s+(.+)$/);
|
|
193
|
+
const mOl = cur.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
194
|
+
const m = isOrdered ? mOl : mUl;
|
|
195
|
+
if (!m) break;
|
|
196
|
+
let itemText = m[3];
|
|
197
|
+
// Task list checkbox
|
|
198
|
+
const taskMatch = itemText.match(/^\[([ xX])\]\s+(.+)$/);
|
|
199
|
+
if (taskMatch) {
|
|
200
|
+
const checked = taskMatch[1].toLowerCase() === "x";
|
|
201
|
+
itemText = `<input type="checkbox" disabled ${checked ? "checked" : ""} /> ${parseInline(taskMatch[2])}`;
|
|
202
|
+
} else {
|
|
203
|
+
itemText = parseInline(itemText);
|
|
204
|
+
}
|
|
205
|
+
items.push(`<li>${itemText}</li>`);
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
out.push(`<${tag}>${items.join("")}</${tag}>`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Paragraph
|
|
213
|
+
const paraLines = [];
|
|
214
|
+
while (
|
|
215
|
+
i < lines.length &&
|
|
216
|
+
lines[i].trim() &&
|
|
217
|
+
!lines[i].match(/^(#{1,6})\s+/) &&
|
|
218
|
+
!lines[i].match(/^(`{3,}|~{3,})/) &&
|
|
219
|
+
!lines[i].trim().startsWith(">") &&
|
|
220
|
+
!lines[i].match(/^(\s*)([-*+]|\d+\.)\s+/) &&
|
|
221
|
+
!/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i].trim()) &&
|
|
222
|
+
!(lines[i].includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
|
|
223
|
+
) {
|
|
224
|
+
paraLines.push(parseInline(lines[i].trim()));
|
|
225
|
+
i++;
|
|
226
|
+
}
|
|
227
|
+
if (paraLines.length > 0) {
|
|
228
|
+
out.push(`<p>${paraLines.join("<br/>")}</p>`);
|
|
229
|
+
} else {
|
|
230
|
+
i++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return out.join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildHtmlDocument(title, markdownContent) {
|
|
238
|
+
const bodyHtml = markdownToHtmlBody(markdownContent);
|
|
239
|
+
return `<!DOCTYPE html>
|
|
240
|
+
<html lang="zh-TW">
|
|
241
|
+
<head>
|
|
242
|
+
<meta charset="UTF-8" />
|
|
243
|
+
<title>${escapeHtml(title)}</title>
|
|
244
|
+
<style>
|
|
245
|
+
@page {
|
|
246
|
+
size: A4;
|
|
247
|
+
margin: 18mm 16mm;
|
|
248
|
+
}
|
|
249
|
+
* {
|
|
250
|
+
box-sizing: border-box;
|
|
251
|
+
}
|
|
252
|
+
body {
|
|
253
|
+
font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", "PingFang SC", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Malgun Gothic", "Microsoft JhengHei", "Noto Sans CJK TC", "Segoe UI", Roboto, sans-serif;
|
|
254
|
+
font-size: 10.5pt;
|
|
255
|
+
line-height: 1.65;
|
|
256
|
+
color: #1f2937;
|
|
257
|
+
margin: 0;
|
|
258
|
+
padding: 0;
|
|
259
|
+
background: #ffffff;
|
|
260
|
+
}
|
|
261
|
+
h1, h2, h3, h4, h5, h6 {
|
|
262
|
+
color: #111827;
|
|
263
|
+
font-weight: 700;
|
|
264
|
+
line-height: 1.35;
|
|
265
|
+
page-break-after: avoid;
|
|
266
|
+
break-after: avoid;
|
|
267
|
+
}
|
|
268
|
+
h1 {
|
|
269
|
+
font-size: 20pt;
|
|
270
|
+
border-bottom: 2px solid #e5e7eb;
|
|
271
|
+
padding-bottom: 6pt;
|
|
272
|
+
margin-top: 0;
|
|
273
|
+
margin-bottom: 12pt;
|
|
274
|
+
}
|
|
275
|
+
h2 {
|
|
276
|
+
font-size: 14.5pt;
|
|
277
|
+
border-bottom: 1px solid #e5e7eb;
|
|
278
|
+
padding-bottom: 4pt;
|
|
279
|
+
margin-top: 18pt;
|
|
280
|
+
margin-bottom: 8pt;
|
|
281
|
+
}
|
|
282
|
+
h3 {
|
|
283
|
+
font-size: 12pt;
|
|
284
|
+
margin-top: 14pt;
|
|
285
|
+
margin-bottom: 6pt;
|
|
286
|
+
}
|
|
287
|
+
h4, h5, h6 {
|
|
288
|
+
font-size: 10.5pt;
|
|
289
|
+
margin-top: 10pt;
|
|
290
|
+
margin-bottom: 4pt;
|
|
291
|
+
}
|
|
292
|
+
p {
|
|
293
|
+
margin: 0 0 8pt 0;
|
|
294
|
+
orphans: 3;
|
|
295
|
+
widows: 3;
|
|
296
|
+
}
|
|
297
|
+
ul, ol {
|
|
298
|
+
margin: 0 0 10pt 0;
|
|
299
|
+
padding-left: 20pt;
|
|
300
|
+
}
|
|
301
|
+
li {
|
|
302
|
+
margin-bottom: 3pt;
|
|
303
|
+
}
|
|
304
|
+
table {
|
|
305
|
+
width: 100%;
|
|
306
|
+
border-collapse: collapse;
|
|
307
|
+
margin: 10pt 0 14pt 0;
|
|
308
|
+
font-size: 9.5pt;
|
|
309
|
+
page-break-inside: auto;
|
|
310
|
+
}
|
|
311
|
+
tr {
|
|
312
|
+
page-break-inside: avoid;
|
|
313
|
+
break-inside: avoid;
|
|
314
|
+
}
|
|
315
|
+
th, td {
|
|
316
|
+
border: 1px solid #d1d5db;
|
|
317
|
+
padding: 6pt 8pt;
|
|
318
|
+
vertical-align: top;
|
|
319
|
+
word-break: break-word;
|
|
320
|
+
}
|
|
321
|
+
th {
|
|
322
|
+
background-color: #f3f4f6;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
color: #111827;
|
|
325
|
+
}
|
|
326
|
+
tbody tr:nth-child(even) {
|
|
327
|
+
background-color: #f9fafb;
|
|
328
|
+
}
|
|
329
|
+
code {
|
|
330
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
|
331
|
+
font-size: 9pt;
|
|
332
|
+
background-color: #f3f4f6;
|
|
333
|
+
padding: 1.5pt 4pt;
|
|
334
|
+
border-radius: 3pt;
|
|
335
|
+
color: #b91c1c;
|
|
336
|
+
}
|
|
337
|
+
.code-block {
|
|
338
|
+
margin: 8pt 0 12pt 0;
|
|
339
|
+
page-break-inside: avoid;
|
|
340
|
+
break-inside: avoid;
|
|
341
|
+
border: 1px solid #e5e7eb;
|
|
342
|
+
border-radius: 5pt;
|
|
343
|
+
background-color: #f8fafc;
|
|
344
|
+
overflow: hidden;
|
|
345
|
+
}
|
|
346
|
+
.code-lang {
|
|
347
|
+
background-color: #e2e8f0;
|
|
348
|
+
color: #475569;
|
|
349
|
+
font-size: 8pt;
|
|
350
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
351
|
+
padding: 2pt 8pt;
|
|
352
|
+
border-bottom: 1px solid #cbd5e1;
|
|
353
|
+
text-transform: uppercase;
|
|
354
|
+
}
|
|
355
|
+
pre {
|
|
356
|
+
margin: 0;
|
|
357
|
+
padding: 8pt 10pt;
|
|
358
|
+
overflow-x: auto;
|
|
359
|
+
white-space: pre-wrap;
|
|
360
|
+
word-wrap: break-word;
|
|
361
|
+
}
|
|
362
|
+
pre code {
|
|
363
|
+
background: transparent;
|
|
364
|
+
padding: 0;
|
|
365
|
+
color: #1e293b;
|
|
366
|
+
font-size: 8.5pt;
|
|
367
|
+
line-height: 1.5;
|
|
368
|
+
}
|
|
369
|
+
blockquote {
|
|
370
|
+
margin: 8pt 0 12pt 0;
|
|
371
|
+
padding: 6pt 12pt;
|
|
372
|
+
border-left: 3pt solid #9ca3af;
|
|
373
|
+
background-color: #f9fafb;
|
|
374
|
+
color: #4b5563;
|
|
375
|
+
}
|
|
376
|
+
.alert {
|
|
377
|
+
margin: 8pt 0 12pt 0;
|
|
378
|
+
padding: 8pt 12pt;
|
|
379
|
+
border-left: 3.5pt solid #3b82f6;
|
|
380
|
+
background-color: #eff6ff;
|
|
381
|
+
border-radius: 3pt;
|
|
382
|
+
page-break-inside: avoid;
|
|
383
|
+
}
|
|
384
|
+
.alert-title {
|
|
385
|
+
font-weight: 700;
|
|
386
|
+
font-size: 9.5pt;
|
|
387
|
+
margin-bottom: 3pt;
|
|
388
|
+
}
|
|
389
|
+
.alert-warning, .alert-caution {
|
|
390
|
+
border-left-color: #f59e0b;
|
|
391
|
+
background-color: #fffbeb;
|
|
392
|
+
}
|
|
393
|
+
.alert-important {
|
|
394
|
+
border-left-color: #8b5cf6;
|
|
395
|
+
background-color: #f5f3ff;
|
|
396
|
+
}
|
|
397
|
+
hr {
|
|
398
|
+
border: none;
|
|
399
|
+
border-top: 1px solid #e5e7eb;
|
|
400
|
+
margin: 14pt 0;
|
|
401
|
+
}
|
|
402
|
+
.page-break {
|
|
403
|
+
page-break-before: always;
|
|
404
|
+
break-before: page;
|
|
405
|
+
}
|
|
406
|
+
a {
|
|
407
|
+
color: #2563eb;
|
|
408
|
+
text-decoration: none;
|
|
409
|
+
}
|
|
410
|
+
</style>
|
|
411
|
+
</head>
|
|
412
|
+
<body>
|
|
413
|
+
${bodyHtml}
|
|
414
|
+
</body>
|
|
415
|
+
</html>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function findHeadlessBrowser(customBrowserPath) {
|
|
419
|
+
if (customBrowserPath && fs.existsSync(customBrowserPath)) {
|
|
420
|
+
return customBrowserPath;
|
|
421
|
+
}
|
|
422
|
+
if (process.env.SA_SPEC_BROWSER && fs.existsSync(process.env.SA_SPEC_BROWSER)) {
|
|
423
|
+
return process.env.SA_SPEC_BROWSER;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const candidates = [];
|
|
427
|
+
if (process.platform === "darwin") {
|
|
428
|
+
candidates.push(
|
|
429
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
430
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
431
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
432
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
433
|
+
);
|
|
434
|
+
} else if (process.platform === "win32") {
|
|
435
|
+
const programFiles = process.env["PROGRAMFILES"] || "C:\\Program Files";
|
|
436
|
+
const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
|
|
437
|
+
const localAppData = process.env["LOCALAPPDATA"] || "";
|
|
438
|
+
candidates.push(
|
|
439
|
+
path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"),
|
|
440
|
+
path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"),
|
|
441
|
+
path.join(localAppData, "Google\\Chrome\\Application\\chrome.exe"),
|
|
442
|
+
path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
443
|
+
path.join(programFilesX86, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
444
|
+
path.join(programFiles, "BraveSoftware\\Brave-Browser\\Application\\brave.exe")
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
const linuxBins = [
|
|
448
|
+
"google-chrome",
|
|
449
|
+
"google-chrome-stable",
|
|
450
|
+
"chromium",
|
|
451
|
+
"chromium-browser",
|
|
452
|
+
"microsoft-edge",
|
|
453
|
+
"brave-browser"
|
|
454
|
+
];
|
|
455
|
+
for (const bin of linuxBins) {
|
|
456
|
+
const whichRes = spawnSync("which", [bin], { encoding: "utf8" });
|
|
457
|
+
if (whichRes.status === 0 && whichRes.stdout.trim()) {
|
|
458
|
+
return whichRes.stdout.trim();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
for (const candidate of candidates) {
|
|
464
|
+
if (fs.existsSync(candidate)) {
|
|
465
|
+
return candidate;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function renderHtmlToPdf(htmlContent, outputPdfPath, customBrowserPath) {
|
|
472
|
+
const browser = findHeadlessBrowser(customBrowserPath);
|
|
473
|
+
const absPdfPath = path.resolve(outputPdfPath);
|
|
474
|
+
fs.mkdirSync(path.dirname(absPdfPath), { recursive: true });
|
|
475
|
+
|
|
476
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sa-spec-pdf-"));
|
|
477
|
+
const tmpHtmlPath = path.join(tmpDir, "spec.html");
|
|
478
|
+
fs.writeFileSync(tmpHtmlPath, htmlContent, "utf8");
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
if (browser) {
|
|
482
|
+
const fileUrl = pathToFileURL(tmpHtmlPath).href;
|
|
483
|
+
const profileDir = path.join(tmpDir, "profile");
|
|
484
|
+
const commonFlags = [
|
|
485
|
+
"--disable-gpu",
|
|
486
|
+
"--no-sandbox",
|
|
487
|
+
"--disable-crash-reporter",
|
|
488
|
+
"--no-first-run",
|
|
489
|
+
"--no-default-browser-check",
|
|
490
|
+
`--user-data-dir=${profileDir}`,
|
|
491
|
+
"--no-pdf-header-footer",
|
|
492
|
+
`--print-to-pdf=${absPdfPath}`,
|
|
493
|
+
fileUrl
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
// Try modern --headless=new first, fallback to --headless
|
|
497
|
+
let res = spawnSync(browser, ["--headless=new", ...commonFlags], {
|
|
498
|
+
encoding: "utf8",
|
|
499
|
+
timeout: 30000
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
if (res.status !== 0 || !fs.existsSync(absPdfPath)) {
|
|
503
|
+
res = spawnSync(browser, ["--headless", ...commonFlags], {
|
|
504
|
+
encoding: "utf8",
|
|
505
|
+
timeout: 30000
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (fs.existsSync(absPdfPath)) {
|
|
510
|
+
return { ok: true, engine: path.basename(browser), pdfPath: absPdfPath };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Fallback: wkhtmltopdf if available
|
|
515
|
+
const wkWhich = spawnSync("which", ["wkhtmltopdf"], { encoding: "utf8" });
|
|
516
|
+
if (wkWhich.status === 0 && wkWhich.stdout.trim()) {
|
|
517
|
+
const wkBin = wkWhich.stdout.trim();
|
|
518
|
+
const wkRes = spawnSync(wkBin, ["--enable-local-file-access", tmpHtmlPath, absPdfPath], {
|
|
519
|
+
encoding: "utf8",
|
|
520
|
+
timeout: 30000
|
|
521
|
+
});
|
|
522
|
+
if (wkRes.status === 0 && fs.existsSync(absPdfPath)) {
|
|
523
|
+
return { ok: true, engine: "wkhtmltopdf", pdfPath: absPdfPath };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
error: browser
|
|
530
|
+
? "瀏覽器 Headless PDF 轉換失敗(請確認終端機權限或瀏覽器狀態)。"
|
|
531
|
+
: "未偵測到本機 Chrome / Edge / Brave / Chromium 瀏覽器或 wkhtmltopdf。"
|
|
532
|
+
};
|
|
533
|
+
} finally {
|
|
534
|
+
try {
|
|
535
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
536
|
+
} catch (_) {}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function convertMarkdownFileToPdf(mdFilePath, outputPdfPath, customBrowserPath) {
|
|
541
|
+
const absMdPath = path.resolve(mdFilePath);
|
|
542
|
+
if (!fs.existsSync(absMdPath)) {
|
|
543
|
+
return { ok: false, error: `找不到檔案: ${mdFilePath}` };
|
|
544
|
+
}
|
|
545
|
+
const content = fs.readFileSync(absMdPath, "utf8");
|
|
546
|
+
const title = path.basename(absMdPath, path.extname(absMdPath));
|
|
547
|
+
const targetPdf = outputPdfPath
|
|
548
|
+
? path.resolve(outputPdfPath)
|
|
549
|
+
: path.join(path.dirname(absMdPath), `${title}.pdf`);
|
|
550
|
+
const html = buildHtmlDocument(title, content);
|
|
551
|
+
return renderHtmlToPdf(html, targetPdf, customBrowserPath);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function buildMergedRequirementMarkdown(reqDir) {
|
|
555
|
+
const dirName = path.basename(reqDir);
|
|
556
|
+
const sections = [];
|
|
557
|
+
|
|
558
|
+
const mdOrder = [
|
|
559
|
+
{ file: "srs.md", label: "SRS 需求規格書" },
|
|
560
|
+
{ file: "db-schema.md", label: "DB Schema 資料庫規格" },
|
|
561
|
+
{ file: "openapi.yaml", label: "OpenAPI 介面規格", isYaml: true },
|
|
562
|
+
{ file: "handoff.md", label: "Handoff 開發交接摘要" },
|
|
563
|
+
{ file: "history.md", label: "History 版本歷程" }
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
for (const item of mdOrder) {
|
|
567
|
+
const filePath = path.join(reqDir, item.file);
|
|
568
|
+
if (!fs.existsSync(filePath)) continue;
|
|
569
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
570
|
+
if (!raw) continue;
|
|
571
|
+
|
|
572
|
+
if (item.isYaml) {
|
|
573
|
+
sections.push(`# ${item.label} (\`${item.file}\`)\n\n\`\`\`yaml\n${raw}\n\`\`\``);
|
|
574
|
+
} else {
|
|
575
|
+
sections.push(raw);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const umlDir = path.join(reqDir, "uml");
|
|
580
|
+
if (fs.existsSync(umlDir)) {
|
|
581
|
+
const pumlFiles = fs
|
|
582
|
+
.readdirSync(umlDir)
|
|
583
|
+
.filter((f) => f.endsWith(".puml"))
|
|
584
|
+
.sort();
|
|
585
|
+
if (pumlFiles.length > 0) {
|
|
586
|
+
const umlBlocks = pumlFiles.map((f) => {
|
|
587
|
+
const pumlContent = fs.readFileSync(path.join(umlDir, f), "utf8").trim();
|
|
588
|
+
return `## \`uml/${f}\`\n\n\`\`\`plantuml\n${pumlContent}\n\`\`\``;
|
|
589
|
+
});
|
|
590
|
+
sections.push(`# UML 圖表規格 (\`uml/\`)\n\n${umlBlocks.join("\n\n")}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
title: dirName,
|
|
596
|
+
markdown: sections.join("\n\n<!-- pagebreak -->\n\n")
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
module.exports = {
|
|
601
|
+
buildHtmlDocument,
|
|
602
|
+
convertMarkdownFileToPdf,
|
|
603
|
+
buildMergedRequirementMarkdown,
|
|
604
|
+
renderHtmlToPdf
|
|
605
|
+
};
|
package/templates/AGENTS.md
CHANGED
|
@@ -15,14 +15,15 @@
|
|
|
15
15
|
|
|
16
16
|
# 指令流程
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
使用五個指令,並支援簡寫別名(兩者等價):
|
|
19
19
|
|
|
20
20
|
| 指令 | 簡寫 | 用途 | 規則 |
|
|
21
21
|
|---|---|---|---|
|
|
22
22
|
| `spec:init` | `sci` | 初始化 | 載入並遵守 `AGENTS.md`、`skills/system-analysis/SKILL.md` 與必要規則,確認 `project/` 結構是否存在。 |
|
|
23
|
-
| `spec:propose` | `scp` | 提出需求 | 使用者提出需求後,自動依需求名稱建立並切換至專屬 feature 分支(`feat/<name>`,若已在 feature branch
|
|
23
|
+
| `spec:propose` | `scp` | 提出需求 | 使用者提出需求後,自動依需求名稱建立並切換至專屬 feature 分支(`feat/<name>`,若已在 feature branch 則沿用),檢查是否有設定 Git remote,若有則自動將分支推上 remote(`git push -u <remote> feat/<name>`),並建立草稿目錄(內部使用 `{{REQ_ID}}` 佔位),整理需求摘要、目標、初步流程與待確認事項,不歸檔、不修改既有需求。 |
|
|
24
24
|
| `spec:analyze` | `sca` | 分析與起草 | 先檢查新需求是否與 `project/` 內所有既有需求衝突;無衝突才產出 SRS、UML、OpenAPI、DB Schema 草稿;有衝突則只列出衝突與待釐清問題,待使用者釐清後再次執行 `spec:analyze`(或 `sca`)。 |
|
|
25
|
-
| `spec:commit` | `scc` | 確認歸檔 |
|
|
25
|
+
| `spec:commit` | `scc` | 確認歸檔 | 只把使用者已確認的版本歸檔到該需求資料夾;若有 Git remote 則先將目前分支推上 remote 並同步遠端狀態,接著才查詢主分支(`master`/`main`)最新可用序號定號(`REQ-xxx`)、替換佔位符,更新 `project/index.md`、`project/system-spec.md` 與 `history.md`,建立 Git commit(若有 remote 則推送分支),再自動切換回主分支完成合併並推送到 remote。 |
|
|
26
|
+
| `spec:pdf` | `spd` | 匯出 PDF | 呼叫 `sa-spec pdf <target> [--split] [--output <path>]`(或 `--all`),將指定的單一 `.md` 檔轉為同名 `.pdf`,或將指定需求目錄/編號(如 `REQ-001`)整包合併匯出成一份完整規格書 PDF(加 `--split` 則各別轉為獨立 PDF)。 |
|
|
26
27
|
|
|
27
28
|
# 重要限制
|
|
28
29
|
|
|
@@ -42,10 +43,14 @@
|
|
|
42
43
|
- `spec:commit` 只能套用已由 `spec:analyze` 產出的、且使用者已確認的無衝突版本。
|
|
43
44
|
- `handoff.md` 只能根據已確認的 SRS、UML、OpenAPI、DB Schema 與限制整理,不得新增未確認的開發假設。
|
|
44
45
|
- 交給 OpenSpec 或其他 AI 開發工具時,必須指定它先閱讀 `handoff.md`,並以 `handoff.md` 作為開發入口與限制摘要;其他規格文件作為詳細來源。
|
|
46
|
+
- **鐵律(Git Remote 自動推送強制規範)**:只要 Git repository 有設定 remote(例如 `origin`),所有分支建立與提交變更**都必須自動推送(`git push`)到 remote**,無需等待使用者額外指示;若 push 失敗時只回報認證或網路問題,不得回滾已完成的文件歸檔與本機 commit。
|
|
45
47
|
- `spec:commit` 完成文件歸檔後,必須自動建立本機 Git commit;commit message 以需求編號與需求名稱為主,並加上簡短需求說明,格式為 `docs(REQ-001): 需求名稱 - 簡短需求說明`。
|
|
46
|
-
- `spec:commit`
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
- `spec:commit` 歸檔與自動合併閉環流程(含 Remote 同步順序):
|
|
49
|
+
1. **先推分支至 Remote**:執行 `spec:commit`(或 `scc`)時,若檢查到 Git 有設定 remote,必須**先將目前分支推送至 remote**(`git push -u <remote> <branch>`)並同步遠端最新狀態(`git fetch <remote>`)。
|
|
50
|
+
2. **再查主分支最新版號**:接著才向主分支(`main` / `master` 及遠端主分支)查詢最新已使用序號,決定下一個可用序號(`REQ-xxx`)。
|
|
51
|
+
3. **定號歸檔與提交**:將草稿目錄重命名為正式 `project/REQ-xxx-<name>`、替換內部 `{{REQ_ID}}` 佔位符、更新索引與規格後,在 feature 分支建立 commit;若有 remote 則同步推送該 feature 分支至 remote。
|
|
52
|
+
4. **自動合併回主分支並推送**:若目前處於 feature 分支(`feat/<name>`),AI 必須自動切換回主分支(優先偵測 `main` 或 `master`),拉取/檢查最新狀態並自動合併該 feature 分支 (`git checkout master && git merge feat/<name>`),隨後在主分支重新生成 `project/index.md` 索引;若有設定 remote,**必須自動將主分支推送至 remote**(`git push <remote> <mainBranch>`)。
|
|
53
|
+
- 多人協作與 Git-Flow 模式(延遲綁定序號與自動合併):支援多人平行開發。執行 `spec:propose`(或 `scp`)時,若目前處於 master/main 分支,AI 必須自動依需求名稱建立並切換至專屬 feature branch(如 `feat/<name>`),檢查若有設定 Git remote 則立即將新分支推上 remote(`git push -u <remote> feat/<name>`),並在 `project/<name>/` 建立草稿目錄,內部使用 `{{REQ_ID}}` 佔位,無需使用者手動敲 CLI;執行 `spec:analyze` 前應先確保取得 master 最新規格以防漏檢衝突;執行 `spec:commit` 歸檔時先推分支到 remote,再向 master 確認最新可用序號定號,自動將目錄定名為正式 `REQ-xxx`、替換內部佔位符、完成提交、自動合併回 master 並推上 remote,避免多人合併時的序號競爭與索引衝突。
|
|
49
54
|
- 文件未經使用者確認,不得標示為 `已審核`。
|
|
50
55
|
- `uml/` 內的 UML 圖不限單一 UML 圖型;`spec:analyze` 必須依需求選擇最能說明流程、互動、狀態、資料關係或介面佈局的圖表,例如活動圖、循序圖、使用案例圖、狀態圖、類別圖;**若需求涉及前端 UI 或使用者操作畫面,必須在 `uml/wireframe.puml` 產出線框圖(使用 PlantUML Salt 語法:`@startsalt ... @endsalt`)**;純後端需求則在 `handoff.md` 明確標記「無 UI 介面需求」。**嚴禁將多張圖混在同一個檔案中**,必須在需求資料夾下的 `uml/` 目錄拆分成獨立的 `.puml` 檔案(例如 `uml/main.puml`、`uml/sequence.puml`、`uml/wireframe.puml`),且每個檔案只能包含一組 `@start...` 與 `@end...`,以避免 PlantUML 渲染或預覽失敗。
|
|
51
56
|
- 一次最多問 3 個待確認問題,優先詢問會影響範圍、資料、權限、API、DB 或驗收的問題。
|
|
@@ -74,12 +79,12 @@
|
|
|
74
79
|
# 狀態機
|
|
75
80
|
|
|
76
81
|
```text
|
|
77
|
-
init (sci) -> propose (scp) -> analyze (sca)
|
|
82
|
+
init (sci) -> propose (scp, 建分支並推至 remote) -> analyze (sca)
|
|
78
83
|
|
|
79
84
|
analyze 無衝突 -> 產出待確認版本 -> commit (scc)
|
|
80
85
|
analyze 有衝突 -> 釐清問題 -> analyze (sca)
|
|
81
86
|
|
|
82
|
-
commit (scc) ->
|
|
87
|
+
commit (scc) -> 先推分支至 remote -> 查 master 最新版號定號 -> 歸檔 commit -> 合併回主分支並推至 remote
|
|
83
88
|
```
|
|
84
89
|
|
|
85
90
|
# 歸檔結構
|