#!/usr/bin/env node 'use strict'; /** * gen-plugin-files.mjs — 依 /plugin.meta.json 產生五份 manifest、AGENTS.md(shared/doc/code)、 * 以及 README.md 五個共通章節(目錄結構/用 CLI 直接執行 skill/新增一個 skill 三節目前為 * 原樣保留的 passthrough,只有「前綴與呼叫方式」與「安裝 / 更新 / 移除」兩節會真正改寫內容)。 * * 用法: * node gen-plugin-files.mjs --repo --dry-run # 只印比對報告,不寫檔(本階段唯一支援的用法) * node gen-plugin-files.mjs --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(' → ')}。` + `本工具只報錯、不自動搬移章節位置,請人工調整後重跑。` ); } } } /** * 找出「## 」章節在整份文件中的字元範圍:[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(不含標題、不含結尾空行)取代整節內容,回傳整份新文件文字。 * 新章節格式固定為:`## ` + 空行 + 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- 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}\` |`, `> | \`\` | \`${meta.shortName}\` |`, `> | \`\` | \`${pluginName}\` |`, `> | \`\` | \`${marketplaceName}\` |`, `> | \`\`(= \`@\`) | \`${token}\` |`, `> | \`\` | \`${url}\` |` ); lines.push( '', '### 一行指令範例', '', '| 工具 | 安裝 | 更新 | 解除安裝 |', '| --- | --- | --- | --- |', `| Claude Code | \`claude plugin marketplace add ${url} && claude plugin install ${token}\` | \`claude plugin marketplace update ${marketplaceName} && claude plugin update ${token}\` | \`claude plugin uninstall ${token} && claude plugin marketplace remove ${marketplaceName}\` |`, `| Codex | \`codex plugin marketplace add ${url} && codex plugin add ${token}\` | \`codex plugin marketplace upgrade ${marketplaceName}\` | \`codex plugin remove ${token} && codex plugin marketplace remove ${marketplaceName}\` |`, `| Antigravity | \`git clone ${url} $HOME/plugins/${meta.shortName} && agy plugin install $HOME/plugins/${meta.shortName}\` | \`git -C $HOME/plugins/${meta.shortName} pull && agy plugin uninstall ${pluginName} && agy plugin install $HOME/plugins/${meta.shortName}\` | \`agy plugin uninstall ${pluginName}\` |`, `| OpenCode | \`git clone ${url} $HOME/plugins/${meta.shortName} && mkdir -p ~/.config/opencode/skills && cp -r $HOME/plugins/${meta.shortName}/skills/* ~/.config/opencode/skills/\` | \`git -C $HOME/plugins/${meta.shortName} pull && cp -r $HOME/plugins/${meta.shortName}/skills/* ~/.config/opencode/skills/\` | \`for s in $HOME/plugins/${meta.shortName}/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"; done\` |`, `| GitHub Copilot CLI | \`copilot plugin marketplace add ${url} && copilot plugin install ${token}\` | \`copilot plugin marketplace update ${marketplaceName} && copilot plugin update ${token}\` | \`copilot plugin uninstall ${token} && copilot plugin marketplace remove ${marketplaceName}\` |` ); 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 (--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();