// JSC 知識庫(wiki)寫入工具:讓 wiki 維持「一本書」的結構。 // 用 scripts/book.sh 執行(需要 Node 22.6 以上,直接執行 TypeScript)。 // // sync 取得/更新可寫入的工作副本,印出路徑 // chapters 列出章節(章號、標題、說明、篇數) // find <關鍵字>... 找已存在的主題(標題與內文) // add <章號> --title T --source S --file F 新增頁面(自動編號) // add-chapter <標題> <說明> --key <名稱> [--skills-dir <目錄>] 新增一章並產生領域技能 // index 重建章節頁、Home 目錄與每頁的上下頁導覽 // lint [檔案...] 檢查頁面格式(不給檔案則檢查有變更的頁面) // diff 顯示尚未推送的變更 // push <訊息> commit 並推送(用 tea 預設 login 的 token) import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; const REPO = process.env.JSC_WIKI_URL ?? "https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.wiki.git"; const WORK = process.env.JSC_WIKI_WORK || join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "jsc-wiki-work"); const NAV = ""; const die = (msg: string): never => { console.error(msg); process.exit(1); }; function run(args: string[], opts: { cwd?: string; check?: boolean } = {}): string { const [cmd, ...rest] = args; const argv = cmd === "git" ? ["-c", "core.quotepath=false", ...rest] : rest; const r = spawnSync(cmd, argv, { cwd: opts.cwd, encoding: "utf8" }); if ((opts.check ?? true) && r.status !== 0) die(`指令失敗:${args.join(" ")}\n${(r.stderr ?? "").trim()}`); return r.stdout ?? ""; } const read = (p: string): string => readFileSync(p, "utf8"); const write = (p: string, s: string): void => writeFileSync(p, s, "utf8"); const inWork = (f: string): string => join(WORK, f); function token(): string | undefined { if (process.env.JSC_WIKI_TOKEN) return process.env.JSC_WIKI_TOKEN; const cfg = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "tea", "config.yml"); if (!existsSync(cfg)) return undefined; const host = new URL(REPO).host; const logins: Record[] = []; for (const line of read(cfg).split("\n")) { const s = line.trim(); if (s.startsWith("- name:")) logins.push({}); else if (logins.length && s.includes(":")) { const i = s.indexOf(":"); const k = s.slice(0, i); if (k === "url" || k === "token" || k === "default") logins[logins.length - 1][k] = s.slice(i + 1).trim().replace(/^["']|["']$/g, ""); } } const mine = logins.filter((l) => (l.url ?? "").includes(host)); return (mine.find((l) => l.default === "true") ?? mine[0])?.token; } function sync(quiet = true): string { if (existsSync(join(WORK, ".git"))) { if (run(["git", "status", "--porcelain"], { cwd: WORK }).trim()) { if (!quiet) console.error("工作副本有未推送的變更,略過更新。"); } else run(["git", "pull", "-q", "--ff-only"], { cwd: WORK, check: false }); } else { mkdirSync(dirname(WORK), { recursive: true }); run(["git", "clone", "-q", REPO, WORK]); } return WORK; } const slug = (t: string): string => t.replace(/[\\/?#%*"<>|]/g, "").trim().replace(/\s+/g, "-"); const link = (t: string): string => encodeURIComponent(slug(t)); type Pos = [number, number]; function chapterFiles(): [number, string][] { const out: [number, string][] = []; for (const f of readdirSync(WORK)) { const m = f.match(/^第(\d+)章-/); if (m) out.push([Number(m[1]), f]); } return out.sort((a, b) => a[0] - b[0]); } function pageFiles(n?: number): [Pos, string][] { const out: [Pos, string][] = []; for (const f of readdirSync(WORK)) { const m = f.match(/^(\d+)\.(\d+)-.+\.md$/); if (m && (n === undefined || Number(m[1]) === n)) out.push([[Number(m[1]), Number(m[2])], f]); } return out.sort((a, b) => a[0][0] - b[0][0] || a[0][1] - b[0][1]); } function chapterInfo(f: string): [string, string] { const lines = read(inWork(f)).split("\n"); const title = lines[0].replace(/^#\s*/, "").trim(); const desc = lines.slice(1).map((l) => l.trim()).find((l) => l && !/^[-\[<#]/.test(l)) ?? ""; return [title, desc]; } const pageTitle = (f: string): string => read(inWork(f)).split("\n", 1)[0].replace(/^#\s*/, "").trim(); const pageSource = (f: string): string => read(inWork(f)).match(/來源:([^\n]+)/)?.[1].trim() ?? ""; const stripNav = (s: string): string => (s.includes("\n" + NAV) ? s.split("\n" + NAV)[0].trimEnd() + "\n" : s.trimEnd() + "\n"); // ---------------------------------------------------------------- index function makeIndex(): void { const chs = chapterFiles(); const info = new Map(chs.map(([n, f]) => [n, chapterInfo(f)] as const)); const ordered: [string, string][] = []; // 全書順序:[頁名, 顯示標題] for (const [n, f] of chs) { ordered.push([f.slice(0, -3), info.get(n)![0]]); for (const [[a, b], pf] of pageFiles(n)) ordered.push([pf.slice(0, -3), `${a}.${b} ${pageTitle(pf)}`]); } const navOf = new Map(); ordered.forEach(([s], i) => { navOf.set(s, [ordered[i - 1] ?? ["Home", "目錄"], ordered[i + 1] ?? ["Home", "目錄"]]); }); const navBlock = (s: string, pf?: string): string => { const [prev, next] = navOf.get(s)!; let chap = ""; if (pf) { const cf = chs.find(([k]) => k === Number(pf.split(".")[0]))![1]; chap = ` · [本章目錄](${link(cf.slice(0, -3))})`; } return `\n${NAV}\n---\n\n[← ${prev[1]}](${link(prev[0])})${chap} · [全書目錄](Home) · [${next[1]} →](${link(next[0])})\n`; }; for (const [n, f] of chs) { const [t, d] = info.get(n)!; const rows = pageFiles(n).map(([[a, b], pf]) => { const src = pageSource(pf).replace(" 知識庫", ""); return `- [${a}.${b} ${pageTitle(pf)}](${link(pf.slice(0, -3))})` + (src ? `(${src})` : ""); }); write(inWork(f), `# ${t}\n\n${d}\n\n${rows.join("\n")}\n` + navBlock(f.slice(0, -3))); } for (const [, pf] of pageFiles()) write(inWork(pf), stripNav(read(inWork(pf))) + navBlock(pf.slice(0, -3), pf)); const toc = chs .map(([n, f], i) => `${i + 1}. [${info.get(n)![0]}](${link(f.slice(0, -3))}):${info.get(n)![1]}(${pageFiles(n).length} 篇)`) .join("\n"); let home = read(inWork("Home.md")); home = home.replace(/(## 目錄\n\n)[\s\S]*?(\n\n## )/, (_, a, b) => a + toc + b); home = home.replace(/依領域分為\s*\S{1,3}\s*章/, `依領域分為 ${chs.length} 章`); write(inWork("Home.md"), home); } // ---------------------------------------------------------------- commands function cmdChapters(): void { sync(); for (const [n, f] of chapterFiles()) { const [t, d] = chapterInfo(f); console.log(`${n}\t${t}\t${d}\t${pageFiles(n).length} 篇`); for (const [[a, b], pf] of pageFiles(n)) console.log(`\t${a}.${b} ${pageTitle(pf)}`); } } function cmdFind(kws: string[]): void { sync(); const keys = kws.map((k) => k.toLowerCase()); const hits = pageFiles() .map(([, pf]) => { const body = read(inWork(pf)).toLowerCase(); const title = pageTitle(pf).toLowerCase(); const score = keys.reduce((s, k) => s + (title.includes(k) ? 3 : 0) + (body.includes(k) ? 1 : 0), 0); return { score, pf }; }) .filter((h) => h.score) .sort((a, b) => b.score - a.score) .slice(0, 15); if (!hits.length) console.log("沒有相符的頁面。"); for (const h of hits) console.log(`${h.score}\t${h.pf.slice(0, -3)}\t${pageTitle(h.pf)}`); } function cmdAdd(args: string[]): void { const opt = (name: string): string => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? args[i + 1] : die(`缺少 ${name}`); }; const chapter = Number(args[0]); const title = opt("--title"); const source = opt("--source"); const file = opt("--file"); sync(); const ch = chapterFiles().find(([n]) => n === chapter); if (!ch) die(`沒有第 ${chapter} 章。可用章號:${chapterFiles().map(([n]) => n).join(", ")}`); const ctitle = chapterInfo(ch![1])[0].replace(/^第\d+章\s*/, ""); const body = read(file).replace(/^\s*#\s[^\n]*\n/, "").replace(/^\s*>\s*第[^\n]*\n/, ""); const next = Math.max(0, ...pageFiles(chapter).map(([[, b]]) => b)) + 1; const name = `${chapter}.${next}-${slug(title)}.md`; write(inWork(name), `# ${title}\n\n> 第${chapter}章 ${ctitle} · 來源:${source}\n\n${body.trim()}\n`); makeIndex(); console.log(`已新增:${name.slice(0, -3)}`); } const RESERVED = new Set(["wiki", "book-search", "book-import"]); function domainSkill(key: string, n: number, title: string, desc: string): string { const brief = desc.replace(/"/g, "").replace(/。$/, ""); const t = title.replace(/"/g, ""); return `--- name: ${key} allowed-tools: Bash(\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh *) description: "查詢${t}的知識(${brief})。使用者執行 /jsc-wiki:${key} 或詢問與「${t}」相關的操作步驟時使用。" --- # JSC 知識庫・第${n}章 ${t} 目前章節內容(來自 wiki,唯讀): !\`\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh chapter ${n}\` ## 流程 0. 上方若顯示錯誤或沒有內容,先自行執行 \`\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh chapter N\`(N 為本章號)。 1. 使用者沒給主題(\`$ARGUMENTS\` 為空):只列出上面的篇目並詢問要看哪一篇,不要全文貼出。 2. 有給主題:用 \`\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh page <編號>\`(例如 \`${n}.1\`)讀取最相關的一到兩篇,依內容回答;找不到再用 \`wiki.sh search <關鍵字>\` 跨章搜尋。 3. 只依 wiki 內容回答並註明頁面編號;wiki 沒寫的就說沒有,不要憑記憶補指令。 4. 內容中 \`<密碼>\`、\`\` 等是占位符,不要猜測實際值;執行會改動系統的指令前先跟使用者確認。 回覆使用繁體中文(程式碼、指令除外)。 `; } /** 新增一章,並產生對應的領域技能(skills//SKILL.md),同步更新 README 表格與 wiki 技能的指令清單。 */ function cmdAddChapter(args: string[]): void { const flag = (name: string): string | undefined => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; const key = flag("--key") ?? die("缺少 --key <領域指令名稱>(英文小寫,例如 security)"); const skillsDir = flag("--skills-dir") ?? join(dirname(process.argv[1]), "..", "skills"); const pos = args.filter((a, i) => !a.startsWith("--") && !args[i - 1]?.startsWith("--")); if (pos.length !== 2) die("用法:add-chapter <標題> <說明> --key <名稱> [--skills-dir <目錄>]"); const [title, desc] = pos; if (!/^[a-z][a-z0-9-]*$/.test(key)) die(`--key 只能用英文小寫、數字與連字號:${key}`); if (RESERVED.has(key)) die(`--key 不可使用保留名稱:${key}`); const skillFile = join(skillsDir, key, "SKILL.md"); if (existsSync(skillFile)) die(`已有同名技能:${skillFile}`); sync(); const n = Math.max(0, ...chapterFiles().map(([k]) => k)) + 1; const name = `第${n}章-${slug(title)}.md`; write(inWork(name), `# 第${n}章 ${title}\n\n${desc}\n`); makeIndex(); mkdirSync(dirname(skillFile), { recursive: true }); write(skillFile, domainSkill(key, n, title, desc)); const touched = [skillFile]; const readme = join(skillsDir, "..", "README.md"); if (existsSync(readme)) { const lines = read(readme).split("\n"); const last = lines.map((l) => l.startsWith("| `/jsc-wiki:")).lastIndexOf(true); if (last >= 0) { lines.splice(last + 1, 0, `| \`/jsc-wiki:${key}\` | ${n} ${title} | ${desc.replace(/。$/, "")} |`); write(readme, lines.join("\n")); touched.push(readme); } } const wikiSkill = join(skillsDir, "wiki", "SKILL.md"); if (existsSync(wikiSkill)) { const lines = read(wikiSkill).split("\n"); const i = lines.findIndex((l) => l.includes("各領域指令:")); if (i >= 0) { lines[i] = lines[i].replace(/。$/, `、\`:${key}\`。`); write(wikiSkill, lines.join("\n")); touched.push(wikiSkill); } } // 在 marketplace repo 內:同步市集 README、市集 manifest 與插件 manifest 的指令清單,並升一個修訂版本 const pluginDir = join(skillsDir, ".."); const repoRoot = join(pluginDir, "..", ".."); const edit = (file: string, fn: (s: string) => string): void => { if (!existsSync(file)) return; const before = read(file); const after = fn(before); if (after !== before) { write(file, after); touched.push(file); } }; const bump = (v: string): string => v.replace(/(\d+\.\d+\.)(\d+)/, (_, a, b) => a + (Number(b) + 1)); let version = ""; edit(join(pluginDir, ".claude-plugin", "plugin.json"), (j) => { j = j.replace(/(:[a-z0-9-]+)(;\/jsc-wiki:book-search)/, `$1、:${key}$2`); return j.replace(/("version": ")([^"]+)"/, (_, a, v) => `${a}${(version = bump(v))}"`); }); edit(join(repoRoot, ".claude-plugin", "marketplace.json"), (j) => j.replace(/(system(?:、[a-z0-9-]+)*)( 指令)/, `$1、${key}$2`)); edit(join(repoRoot, "README.md"), (r) => { r = r.replace(/(### \[jsc-wiki\]\(plugins\/jsc-wiki\) `)[^`]+`/, (_, a) => `${a}${version || "未知"}\``); return r.replace(/(### \[jsc-wiki\][\s\S]*?`:[a-z0-9-]+`)( 等指令)/, `$1、\`:${key}\`$2`); }); console.log(`已新增:${name.slice(0, -3)}(wiki 工作副本,尚未推送)`); console.log(`已產生領域技能 /jsc-wiki:${key},變更的檔案:\n${touched.map((f) => " " + f).join("\n")}`); console.log("提醒:技能檔在插件目錄,需 commit 進 jsc-marketplace repo 才會保留;重新載入插件後指令才會出現。"); } // ---------------------------------------------------------------- lint const MOJIBAKE = /�|[ÃÂãäåæçèéï][\u0080-¿]|\?{3,}/; const MARKETING = ["強大", "無縫", "輕鬆", "極致", "完美", "革命性", "頂尖", "卓越", "高效能", "一鍵搞定"]; const SECRET = /(password|passwd|token|secret|api[_-]?key)\s*[=:]\s*['"]?(?!<)[A-Za-z0-9/+_\-*%&]{8,}/i; /** 去掉程式碼區塊;四個以上的反引號可包住三個。回傳文字行與區塊是否成對。 */ function stripCode(text: string): { lines: string[]; balanced: boolean } { const lines: string[] = []; let mark = 0; for (const l of text.split("\n")) { const m = l.match(/^(`{3,})/); if (m) { if (!mark) mark = m[1].length; else if (m[1].length >= mark && l.trim() === m[1]) mark = 0; continue; } if (!mark) lines.push(l); } return { lines, balanced: !mark }; } function lintFile(path: string): { errs: string[]; warns: string[] } { const errs: string[] = []; const warns: string[] = []; let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path)); } catch { return { errs: ["不是有效的 UTF-8"], warns }; } if (text.startsWith("")) errs.push("檔案開頭有 BOM,請移除"); if (text.includes("\r")) errs.push("含有 CR 換行,請改用 LF"); if (MOJIBAKE.test(text)) errs.push("疑似亂碼(出現 U+FFFD、ä¸ 之類的字元或連續問號)"); if (SECRET.test(text)) errs.push("疑似含有密碼或 token,請改成 <密碼>、 占位符"); const { lines: body, balanced } = stripCode(text); if (!balanced) errs.push("程式碼區塊 ``` 沒有成對"); const lines = text.split("\n"); if (!lines[0].startsWith("# ")) errs.push("第一行必須是 `# 標題`"); if (!lines.slice(0, 6).some((l) => l.startsWith("> 第") && l.includes("來源:"))) errs.push("標題後需要來源列:`> 第N章 領域 · 來源:…`"); const heads = body.filter((l) => /^#{1,6} /.test(l)); if (!heads.includes("## 簡介")) errs.push("缺少 `## 簡介`"); if (!["## 步驟", "## 參考連結", "## 提示詞"].some((h) => heads.includes(h))) errs.push("缺少 `## 步驟`"); heads.filter((h) => h.startsWith("### ")).forEach((h, i) => { if (!h.startsWith(`### ${i + 1}. `) && /\d/.test(h[4] ?? "")) warns.push(`步驟編號不連續:${h}`); }); const prose = body.filter((l) => l.trim() && !/^(#|>|\||