@@ -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 ( ` \n Repo「 ${ 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 ( ) ;