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>
This commit is contained in:
2026-08-11 06:02:30 +00:00
co-authored by Claude Sonnet 5
parent d49ae1085d
commit 1030f9d403
38 changed files with 2329 additions and 139 deletions
+544
View File
@@ -0,0 +1,544 @@
#!/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();