Files
shared/scripts/gen-plugin-files.mjs
T
jiantw83 fc27067ddd feat(gen-plugin-files): 新增 README 五個共通章節的順序漂移檢查,並把 persona 納入 AGENTS.md 產生範圍
- checkHeadingOrder():驗證五個共通章節的相對順序是否與 DEFAULT_HEADINGS 一致,不一致時報錯並指出違規的 repo 與章節,只報錯不自動搬移。
- AGENTS_APPLIES_TO 加入 persona,改用 agentsExtraBullets 機制產生其 AGENTS.md,不再是唯一排除在產生範圍外的 repo。
- 順帶修正 renderSectionForCompare() 對「檔案最後一節」結尾空行數的錯誤假設,避免內容其實相同卻被誤報「會變動」。
2026-08-12 05:52:21 +00:00

582 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
/**
* gen-plugin-files.mjs — 依 <repo>/plugin.meta.json 產生五份 manifest、AGENTS.md(shared/doc/code)、
* 以及 README.md 五個共通章節(目錄結構/用 CLI 直接執行 skill/新增一個 skill 三節目前為
* 原樣保留的 passthrough,只有「前綴與呼叫方式」與「安裝 / 更新 / 移除」兩節會真正改寫內容)。
*
* 用法:
* node gen-plugin-files.mjs --repo <repo目錄> --dry-run # 只印比對報告,不寫檔(本階段唯一支援的用法)
* node gen-plugin-files.mjs --repo <repo目錄> --write # 實際寫入檔案(下一階段才使用)
*
* 只用 Node.js 內建模組,不依賴任何套件。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
// ---------------------------------------------------------------------------
// 小工具
// ---------------------------------------------------------------------------
function readJSON(p) {
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function readTextOrNull(p) {
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
}
function stripTrailingPeriod(s) {
return s.replace(/。$/, '');
}
/** 在字串第一個「,」或「:」之前插入 insertText。找不到就直接接在字串尾端。 */
function insertBeforeFirstPunct(s, insertText) {
const idx = s.search(/[,:]/);
if (idx === -1) return s + insertText;
return s.slice(0, idx) + insertText + s.slice(idx);
}
function stableStringify(obj) {
return JSON.stringify(obj, null, 2) + '\n';
}
// ---------------------------------------------------------------------------
// Manifest 產生(規則對照見 shared/templates/plugin-manifests.md)
// ---------------------------------------------------------------------------
function buildRootDescription(meta) {
const base = stripTrailingPeriod(meta.descriptionCore);
const rootAssistant = meta.callPrefixAssistants.root;
return `${base};於 ${rootAssistant} 以 ${meta.cliPrefix} 前綴呼叫。`;
}
function buildClaudeDescription(meta) {
const assistantsList = `(${meta.assistants.join(' / ')})`;
const withAssistants = insertBeforeFirstPunct(meta.descriptionCore, assistantsList);
const base = stripTrailingPeriod(withAssistants);
const claudeAssistant = meta.callPrefixAssistants.claudePlugin;
return `${base};於 ${claudeAssistant} 以 ${meta.cliPrefix} 前綴呼叫。`;
}
function buildCodexDescription(meta) {
const note = meta.codexNote || '';
if (!note) return meta.descriptionCore;
if (meta.codexNoteAnchor) {
const idx = meta.descriptionCore.indexOf(meta.codexNoteAnchor);
if (idx === -1) {
throw new Error(
`codexNoteAnchor "${meta.codexNoteAnchor}" 在 descriptionCore 中找不到(repo: ${meta.shortName})`
);
}
const insertAt = idx + meta.codexNoteAnchor.length;
return meta.descriptionCore.slice(0, insertAt) + note + meta.descriptionCore.slice(insertAt);
}
return meta.descriptionCore + note;
}
function buildRootPluginJson(meta) {
return {
name: meta.name,
version: meta.version,
description: buildRootDescription(meta),
skills: meta.skillsPath,
};
}
function buildClaudePluginJson(meta) {
return {
name: meta.name,
version: meta.version,
description: buildClaudeDescription(meta),
skills: meta.skillsPath,
author: meta.author,
homepage: meta.homepage,
repository: meta.repository,
keywords: meta.keywords,
};
}
function buildCodexPluginJson(meta) {
return {
name: meta.name,
version: meta.version,
description: buildCodexDescription(meta),
skills: meta.skillsPath,
};
}
function buildClaudeMarketplaceJson(meta) {
return {
name: meta.shortName,
description: meta.marketplace.repoDescription,
owner: { name: meta.author.name },
plugins: [
{
name: meta.name,
source: './',
description: meta.marketplace.pluginSummary,
},
],
};
}
function buildCodexMarketplaceJson(meta) {
return {
name: meta.shortName,
plugins: [
{
name: meta.name,
source: { source: 'url', url: meta.repository },
},
],
};
}
// ---------------------------------------------------------------------------
// AGENTS.md(四個 repo 皆套用;persona 特有內容走 agentsExtraBullets,見 L1-5)
// ---------------------------------------------------------------------------
const AGENTS_APPLIES_TO = new Set(['shared', 'doc', 'code', 'persona']);
function buildAgentsMd(meta) {
const tmpl = fs.readFileSync(path.join(TEMPLATES_DIR, 'AGENTS.md.tmpl'), 'utf8');
const bullets = meta.agentsExtraBullets || [];
const extraBlock = bullets.length ? bullets.map((b) => `- ${b}`).join('\n') + '\n' : '';
return tmpl
.replaceAll('{{PLUGIN_NAME}}', meta.name)
.replaceAll('{{CLI_PREFIX}}', meta.cliPrefix)
.replace('{{EXTRA_BULLETS}}', extraBlock);
}
// ---------------------------------------------------------------------------
// README.md 五個共通章節
// ---------------------------------------------------------------------------
const DEFAULT_HEADINGS = {
prefixTable: '前綴與呼叫方式',
dirTree: '目錄結構',
install: '安裝 / 更新 / 移除(各助理)',
headless: '用 CLI 直接執行 skill(headless / 一次性)',
addSkill: '新增一個 skill',
};
function getHeadings(meta) {
return { ...DEFAULT_HEADINGS, ...(meta.readmeHeadings || {}) };
}
/**
* 檢查五個共通章節在該份 README 裡的**相對順序**是否與 DEFAULT_HEADINGS 的宣告順序一致
* (L1-4):只驗證順序,不驗證內容——內容正確性屬於 buildReadme() 各章節自己的職責。
* 找不到的章節(例如該 repo 沒有這節)直接跳過,不算順序錯誤。
* 順序不一致時丟出 Error,訊息帶 repo 短名與違規的章節名稱,呼叫端不吞掉這個例外,
* 讓產生器對這個 repo 的處理直接中止(依 L1-4「只報錯、不自動搬移」)。
*/
function checkHeadingOrder(shortName, currentReadmeText, headings) {
const order = Object.keys(DEFAULT_HEADINGS); // prefixTable, dirTree, install, headless, addSkill
const positions = [];
for (const key of order) {
const heading = headings[key];
const sec = findSection(currentReadmeText, heading);
if (sec === null) continue; // 該 repo 沒有這節,不納入順序比較
positions.push({ key, heading, line: sec.startLine });
}
for (let i = 1; i < positions.length; i++) {
if (positions[i].line < positions[i - 1].line) {
throw new Error(
`README 章節順序漂移(repo: ${shortName}):「## ${positions[i].heading}」(第 ${positions[i].line + 1} 行)` +
`出現在「## ${positions[i - 1].heading}」(第 ${positions[i - 1].line + 1} 行)之前,` +
`五個共通章節的相對順序須為:${order.map((k) => headings[k]).join(' → ')}。` +
`本工具只報錯、不自動搬移章節位置,請人工調整後重跑。`
);
}
}
}
/**
* 找出「## <heading>」章節在整份文件中的字元範圍:[sectionStart, sectionEnd)。
* sectionStart 指向標題行開頭;sectionEnd 指向下一個獨立一行 `---` 或下一個 `## ` 標題(不含),或檔尾。
* 回傳 null 表示找不到這個標題(不應該發生在四個既有 repo 上,但保守處理)。
*/
function findSection(text, heading) {
const lines = text.split('\n');
const headingLine = `## ${heading}`;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === headingLine) {
startLine = i;
break;
}
}
if (startLine === -1) return null;
let endLine = lines.length;
for (let i = startLine + 1; i < lines.length; i++) {
const t = lines[i].trim();
if (t === '---' || t.startsWith('## ')) {
endLine = i;
break;
}
}
const prefix = lines.slice(0, startLine).join('\n') + (startLine > 0 ? '\n' : '');
const suffix = lines.slice(endLine).join('\n');
const sectionText = lines.slice(startLine, endLine).join('\n');
return { startLine, endLine, prefix, suffix, sectionText };
}
/**
* 用新的 bodyLines(不含標題、不含結尾空行)取代整節內容,回傳整份新文件文字。
* 新章節格式固定為:`## <heading>` + 空行 + bodyLines + 空行(與既有四份 README 的排版慣例一致)。
*/
function replaceSection(text, heading, bodyLines) {
const sec = findSection(text, heading);
if (!sec) {
throw new Error(`找不到章節「## ${heading}」`);
}
const newSectionLines = [`## ${heading}`, '', ...bodyLines];
const newText = sec.prefix + newSectionLines.join('\n') + '\n\n' + sec.suffix;
return { newText, oldSectionText: sec.sectionText };
}
/**
* 章節文字的「可比較表示」:一般章節後面接 `---`,`sec.sectionText` 會包含結尾那一行空行,
* 所以這裡補上同一個結尾空行才能公平比較。但**檔案最後一節**(後面沒有 `---`/`## `)的
* `sec.sectionText` 直接切到檔尾,結尾空行數等於檔案實際的結尾換行數(可能是 1 行、2 行或
* 更多),不是固定 1 行——若仍然無條件補一行,會跟只有單一結尾換行的檔案產生假性「有變動」
* (已於 L1-6 驗證時發現:`replaceSection()` 實際寫出的內容其實逐字元相同,只有這裡的比較
* 基準算錯)。比較前一律把兩邊的結尾空行正規化掉,只在意內容本身是否改變。
*/
function renderSectionForCompare(heading, bodyLines) {
return [`## ${heading}`, '', ...bodyLines, ''].join('\n');
}
function normalizeForCompare(sectionText) {
return sectionText.replace(/\n+$/, '');
}
// ---- 章節 1/5:前綴與呼叫方式(完全模板化) ----
function buildPrefixTableBodyLines(meta) {
const tmpl = fs.readFileSync(path.join(TEMPLATES_DIR, 'readme-prefix-table.md.tmpl'), 'utf8');
const openCodeMethod = meta.readmeOpenCodeInstallMethod || 'skills 目錄(複製/clone)';
const rendered = tmpl
.replaceAll('{{CLI_PREFIX}}', meta.cliPrefix)
.replace('{{OPENCODE_INSTALL_METHOD}}', openCodeMethod);
// tmpl 檔本身已含 "## 前綴與呼叫方式\n\n" 開頭,這裡只取標題與空行之後的部分當 bodyLines。
const lines = rendered.split('\n');
// 找到第一個非空行之後(跳過標題行與其後的空行)
const headingIdx = lines.findIndex((l) => l.trim().startsWith('## '));
let bodyStart = headingIdx + 1;
while (bodyStart < lines.length && lines[bodyStart].trim() === '') bodyStart++;
let bodyEnd = lines.length;
while (bodyEnd > bodyStart && lines[bodyEnd - 1].trim() === '') bodyEnd--;
return lines.slice(bodyStart, bodyEnd);
}
// ---- 章節 3:安裝 / 更新 / 移除(改為引用 spec-plugin-cli) ----
function buildInstallBodyLines(meta) {
const host = 'gitea.jsc.idv.tw';
const pluginName = meta.name; // jsc-<shortName>
const marketplaceName = meta.shortName;
const token = `${pluginName}@${marketplaceName}`;
const url = meta.repository;
const notes = meta.readmeInstallNotes || [];
const lines = [];
if (notes.length) {
notes.forEach((note, i) => {
if (i > 0) lines.push('>');
lines.push(`> ${note}`);
});
lines.push('>');
}
lines.push(
'> 完整的安裝/更新/移除指令(Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot CLI 五種助理),一律以 [`/jsc-shared:spec-plugin-cli`](https://gitea.jsc.idv.tw/plugins/shared/src/branch/master/skills/spec-plugin-cli/SKILL.md) 為唯一權威版本,套用時代入下列佔位符:',
'>',
'> | 佔位符 | 值 |',
'> | --- | --- |',
`> | \`<host>\` | \`${host}\` |`,
`> | \`<name>\` | \`${meta.shortName}\` |`,
`> | \`<plugin>\` | \`${pluginName}\` |`,
`> | \`<marketplace>\` | \`${marketplaceName}\` |`,
`> | \`<token>\`(= \`<plugin>@<marketplace>\`) | \`${token}\` |`,
`> | \`<url>\` | \`${url}\` |`
);
return lines;
}
// ---- 章節 2/4/5:目錄結構、用 CLI 直接執行 skill、新增一個 skill(passthrough) ----
// 這三節內容是各 repo 特有事實(skills 清單/目錄樹/範例指令),目前原樣保留、只重新包裝標記。
// 詳見 shared/templates/readme-passthrough-sections.md。
function buildPassthroughBodyLines(currentReadmeText, heading) {
const sec = findSection(currentReadmeText, heading);
if (!sec) return null;
const lines = sec.sectionText.split('\n');
// 去掉標題行與其後緊接的空行,其餘原樣回傳
let bodyStart = 1;
while (bodyStart < lines.length && lines[bodyStart].trim() === '') bodyStart++;
let bodyEnd = lines.length;
while (bodyEnd > bodyStart && lines[bodyEnd - 1].trim() === '') bodyEnd--;
return lines.slice(bodyStart, bodyEnd);
}
/**
* 產生整份新 README.md 文字,並回報五個章節各自「是否變動」與「舊/新內容」供 dry-run 顯示。
*/
function buildReadme(meta, currentReadmeText) {
const headings = getHeadings(meta);
checkHeadingOrder(meta.shortName, currentReadmeText, headings);
const changes = [];
let text = currentReadmeText;
// 章節 1:前綴與呼叫方式 — 完全模板化
{
const bodyLines = buildPrefixTableBodyLines(meta);
const { newText, oldSectionText } = replaceSection(text, headings.prefixTable, bodyLines);
const newSectionText = renderSectionForCompare(headings.prefixTable, bodyLines);
changes.push({
key: 'prefixTable',
heading: headings.prefixTable,
changed: normalizeForCompare(oldSectionText) !== normalizeForCompare(newSectionText),
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
// 章節 2:目錄結構 — passthrough(原樣保留)
{
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.dirTree);
if (bodyLines === null) {
changes.push({ key: 'dirTree', heading: headings.dirTree, changed: false, note: '找不到此章節(略過)' });
} else {
const { newText, oldSectionText } = replaceSection(text, headings.dirTree, bodyLines);
const newSectionText = renderSectionForCompare(headings.dirTree, bodyLines);
changes.push({
key: 'dirTree',
heading: headings.dirTree,
changed: normalizeForCompare(oldSectionText) !== normalizeForCompare(newSectionText),
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
}
// 章節 3:安裝 / 更新 / 移除 — 改為引用 spec-plugin-cli
{
const bodyLines = buildInstallBodyLines(meta);
const { newText, oldSectionText } = replaceSection(text, headings.install, bodyLines);
const newSectionText = renderSectionForCompare(headings.install, bodyLines);
changes.push({
key: 'install',
heading: headings.install,
changed: normalizeForCompare(oldSectionText) !== normalizeForCompare(newSectionText),
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
// 章節 4:用 CLI 直接執行 skill — passthrough(原樣保留)
{
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.headless);
if (bodyLines === null) {
changes.push({ key: 'headless', heading: headings.headless, changed: false, note: '找不到此章節(略過)' });
} else {
const { newText, oldSectionText } = replaceSection(text, headings.headless, bodyLines);
const newSectionText = renderSectionForCompare(headings.headless, bodyLines);
changes.push({
key: 'headless',
heading: headings.headless,
changed: normalizeForCompare(oldSectionText) !== normalizeForCompare(newSectionText),
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
}
// 章節 5:新增一個 skill / 新增/修改 skill — passthrough(原樣保留)
{
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.addSkill);
if (bodyLines === null) {
changes.push({ key: 'addSkill', heading: headings.addSkill, changed: false, note: '找不到此章節(略過)' });
} else {
const { newText, oldSectionText } = replaceSection(text, headings.addSkill, bodyLines);
const newSectionText = renderSectionForCompare(headings.addSkill, bodyLines);
changes.push({
key: 'addSkill',
heading: headings.addSkill,
changed: normalizeForCompare(oldSectionText) !== normalizeForCompare(newSectionText),
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
}
return { text, changes };
}
// ---------------------------------------------------------------------------
// 比對與報告
// ---------------------------------------------------------------------------
function fileTargets(repoDir, meta) {
const targets = [
{ label: 'plugin.json(root / Antigravity)', file: path.join(repoDir, 'plugin.json'), kind: 'json', build: () => buildRootPluginJson(meta) },
{ label: '.claude-plugin/plugin.json', file: path.join(repoDir, '.claude-plugin', 'plugin.json'), kind: 'json', build: () => buildClaudePluginJson(meta) },
{ label: '.codex-plugin/plugin.json', file: path.join(repoDir, '.codex-plugin', 'plugin.json'), kind: 'json', build: () => buildCodexPluginJson(meta) },
{ label: '.claude-plugin/marketplace.json', file: path.join(repoDir, '.claude-plugin', 'marketplace.json'), kind: 'json', build: () => buildClaudeMarketplaceJson(meta) },
{ label: '.agents/plugins/marketplace.json', file: path.join(repoDir, '.agents', 'plugins', 'marketplace.json'), kind: 'json', build: () => buildCodexMarketplaceJson(meta) },
];
if (AGENTS_APPLIES_TO.has(meta.shortName)) {
targets.push({ label: 'AGENTS.md', file: path.join(repoDir, 'AGENTS.md'), kind: 'text', build: () => buildAgentsMd(meta) });
}
return targets;
}
function diffLines(oldStr, newStr) {
if (oldStr === newStr) return null;
return { old: oldStr == null ? '(不存在)' : oldStr, new: newStr };
}
function printSeparator(char = '-', len = 78) {
console.log(char.repeat(len));
}
function reportRepo(repoDir, { dryRun }) {
const shortName = path.basename(repoDir);
const metaPath = path.join(repoDir, 'plugin.meta.json');
if (!fs.existsSync(metaPath)) {
console.log(`[SKIP] ${repoDir} 沒有 plugin.meta.json`);
return { repo: shortName, ok: false };
}
const meta = readJSON(metaPath);
printSeparator('=');
console.log(`Repo: ${shortName} (${repoDir})`);
printSeparator('=');
let anyChange = false;
const fileResults = [];
for (const target of fileTargets(repoDir, meta)) {
const current = readTextOrNull(target.file);
const builtRaw = target.build();
const newContent = target.kind === 'json' ? stableStringify(builtRaw) : builtRaw;
const currentNormalized = current; // 保留原始內容比對(含尾端換行差異視為變動,提醒人工留意)
const changed = currentNormalized !== newContent;
if (changed) anyChange = true;
fileResults.push({ label: target.label, file: target.file, changed, current: currentNormalized, next: newContent });
console.log(`\n--- ${target.label} ---`);
console.log(`檔案:${target.file}`);
if (!current) {
console.log('現況:檔案不存在(將會新建)');
}
console.log(changed ? '狀態:會變動' : '狀態:無變動');
if (changed) {
console.log('[舊內容]');
console.log(current == null ? '(不存在)' : current);
console.log('[新內容]');
console.log(newContent);
}
}
// README.md 五個共通章節
const readmePath = path.join(repoDir, 'README.md');
const currentReadme = readTextOrNull(readmePath);
if (currentReadme == null) {
console.log(`\n--- README.md ---\n檔案不存在,略過五個共通章節的產生。`);
} else {
const { text: newReadme, changes } = buildReadme(meta, currentReadme);
console.log(`\n--- README.md(五個共通章節)---`);
console.log(`檔案:${readmePath}`);
for (const c of changes) {
if (c.note) {
console.log(`\n[${c.key}] ## ${c.heading} — ${c.note}`);
continue;
}
console.log(`\n[${c.key}] ## ${c.heading} — ${c.changed ? '會變動' : '無變動'}`);
if (c.changed) {
console.log(' [舊章節內容]');
console.log(indent(c.oldText, ' '));
console.log(' [新章節內容]');
console.log(indent(c.newText, ' '));
}
if (c.changed) anyChange = true;
}
fileResults.push({ label: 'README.md(五節)', file: readmePath, changed: newReadme !== currentReadme, current: currentReadme, next: newReadme });
}
console.log(`\nRepo「${shortName}」摘要:${anyChange ? '有變動待審核' : '完全無變動'}`);
if (!dryRun) {
for (const fr of fileResults) {
if (fr.changed) {
fs.mkdirSync(path.dirname(fr.file), { recursive: true });
fs.writeFileSync(fr.file, fr.next, 'utf8');
console.log(`[WRITE] ${fr.file}`);
}
}
}
return { repo: shortName, anyChange };
}
function indent(text, pad) {
return text
.split('\n')
.map((l) => pad + l)
.join('\n');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { repo: null, dryRun: false, write: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo') args.repo = argv[++i];
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--write') args.write = true;
else {
console.error(`未知參數:${a}`);
process.exit(1);
}
}
return args;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.repo) {
console.error('用法:node gen-plugin-files.mjs --repo <repo目錄> (--dry-run | --write)');
process.exit(1);
}
if (!args.dryRun && !args.write) {
console.error('請明確指定 --dry-run(只印比對報告)或 --write(實際寫入檔案)。');
process.exit(1);
}
const repoDir = path.resolve(args.repo);
if (!fs.existsSync(repoDir)) {
console.error(`repo 目錄不存在:${repoDir}`);
process.exit(1);
}
reportRepo(repoDir, { dryRun: args.dryRun && !args.write });
}
main();