Files
shared/scripts/gen-plugin-files.mjs
T
jiantw83andClaude Sonnet 5 1030f9d403 feat(shared): 新增14個共用spec、models/todo工具與樣板產生器,收斂跨repo重複規範
依 todo.md 執行的規範治理專案:新增 spec-preflight 等 14 個共用規範(含
conventional-commit/pull-request/git-push/issue-read/todo-list/ask-user/
subagent/no-scratch-files/skill-invocation/script-path/action-scaffold/
node-src-layout/plugin-cli/model),擴充 spec-git-safety 與 spec-gitea(token
優先序、機密遮蔽、Wiki 頁名轉義規則);新增可執行 skill `models`(模型能力
查詢與標籤)與 `todo`(依指定模型產生/附加 todo.md);新增 plugin.meta.json
單一事實來源與 gen-plugin-files.mjs 樣板產生器,統一四個 repo 的 manifest/
README/AGENTS.md 並移除寫死的本機使用者路徑;新增 shared/scripts/lib 的
log/機密遮蔽三語言參考實作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 06:02:30 +00:00

545 lines
20 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(只給 shared / doc / code;persona 不套用)
// ---------------------------------------------------------------------------
const AGENTS_APPLIES_TO = new Set(['shared', 'doc', 'code']);
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 || {}) };
}
/**
* 找出「## <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)永遠包含結尾那一行空行(緊接在 `---` 之前),
* 所以這裡也補上同一個結尾空行,才能跟 oldSectionText 公平比較,避免只因為少了那一行空行就誤報「有變動」。
*/
function renderSectionForCompare(heading, bodyLines) {
return [`## ${heading}`, '', ...bodyLines, ''].join('\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);
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: oldSectionText !== 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: oldSectionText !== 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: oldSectionText !== 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: oldSectionText !== 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: oldSectionText !== 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();