feat: jsc-wiki 新增 book-search、book-import,領域技能可直接使用

- 新增 /jsc-wiki:book-search:上網蒐集主題並依頁面格式整理成草稿
- 新增 /jsc-wiki:book-import:判斷領域與章節,主題存在則修改、否則新增,
  重建目錄與上下頁導覽,lint 後經確認再推送到 wiki
- 寫入工具以 TypeScript 實作(scripts/book.ts,入口 book.sh)
- 領域技能補上 allowed-tools,並改用 ${CLAUDE_PLUGIN_ROOT},修正需手動核准而無法載入的問題
- 頁面格式新增兩條規則:簡化技術中文(STE100)、繁體中文無亂碼且盡量用圖表
- 版本升至 0.0.2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-10-14 05:22:09 +00:00
co-authored by Claude Sonnet 5.5
parent 2c853c0e16
commit 65bc192fae
17 changed files with 501 additions and 33 deletions
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# JSC 知識庫寫入工具的入口。實作在 book.ts(TypeScript)。
# Node 內建支援 TypeScript(22.18 以上且編入 amaro)時直接執行,否則用 npx 啟動固定版本的 tsx。
set -u
command -v node >/dev/null || { echo "找不到 node,請安裝 Node 20 以上。" >&2; exit 127; }
ts="$(dirname "$(readlink -f "$0")")/book.ts"
if [ "$(node -p 'process.config.variables.node_use_amaro' 2>/dev/null)" = true ] \
&& node -e 'process.exit(+process.versions.node.split(".")[0] >= 23 || /^22\.(1[89]|[2-9][0-9])\./.test(process.versions.node) ? 0 : 1)'; then
exec node --disable-warning=ExperimentalWarning "$ts" "$@"
fi
command -v npx >/dev/null || { echo "找不到 npx,無法啟動 tsx。" >&2; exit 127; }
exec npx --yes tsx@4.20.5 "$ts" "$@"
+328
View File
@@ -0,0 +1,328 @@
// JSC 知識庫(wiki)寫入工具:讓 wiki 維持「一本書」的結構。
// 用 scripts/book.sh 執行(需要 Node 22.6 以上,直接執行 TypeScript)。
//
// sync 取得/更新可寫入的工作副本,印出路徑
// chapters 列出章節(章號、標題、說明、篇數)
// find <關鍵字>... 找已存在的主題(標題與內文)
// add <章號> --title T --source S --file F 新增頁面(自動編號)
// add-chapter <標題> <說明> 新增一章
// 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 = "<!-- 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<string, string>[] = [];
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<string, [[string, string], [string, string]]>();
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)}`);
}
function cmdAddChapter(title: string, desc: string): void {
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();
console.log(`已新增:${name.slice(0, -3)}\n提醒:領域指令 /jsc-wiki:<領域> 需另外新增對應的技能(見 README)。`);
}
// ---------------------------------------------------------------- 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,請改成 <密碼>、<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() && !/^(#|>|\||<!--|---|- \[|\[|!)/.test(l));
for (const l of prose) {
const l2 = l.replace(/`[^`]*`/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
const noUrl = l2.replace(/https?:\/\/\S+/g, "");
if (l2.includes(";") || noUrl.includes(";")) warns.push(`STE:不使用分號:${l.trim().slice(0, 30)}…`);
for (const s of l2.split(/[。!?]/)) {
if (s.trim().length > 40) warns.push(`STE:句子超過 40 字(${s.trim().length}):${s.trim().slice(0, 24)}…`);
}
for (const w of MARKETING) if (l2.includes(w)) warns.push(`STE:避免行銷用語「${w}」:${l.trim().slice(0, 24)}…`);
if (/[一-鿿][,;:]/.test(noUrl)) warns.push(`中文後面使用了半形標點:${l.trim().slice(0, 24)}…`);
}
for (const p of body.join("\n").split("\n\n")) {
if ((p.match(/。/g) ?? []).length > 6 && !/^(-|\||1\.|\*)/.test(p.trimStart())) warns.push("STE:一個段落超過 6 句");
}
if (!text.includes("```mermaid") && !/^\|[\s:|-]+\|$/m.test(text)) warns.push("圖表:建議加入 mermaid 圖或表格(若內容確實不適合可忽略)");
return { errs, warns };
}
function cmdLint(given: string[]): void {
sync();
let files = given;
if (!files.length) {
files = run(["git", "status", "--porcelain"], { cwd: WORK })
.split("\n")
.filter(Boolean)
.map((l) => inWork(l.slice(3).trim().replace(/^"|"$/g, "")))
.filter((f) => /^\d+\.\d+-/.test(basename(f)) && existsSync(f));
}
let bad = 0;
for (const f of files) {
const { errs, warns } = lintFile(f);
console.log(`== ${basename(f)}`);
errs.forEach((e) => console.log(" ✗", e));
[...new Set(warns)].forEach((w) => console.log(" △", w));
if (!errs.length && !warns.length) console.log(" ✓ 通過");
if (errs.length) bad++;
}
process.exit(bad ? 1 : 0);
}
function cmdDiff(): void {
sync();
console.log(run(["git", "status", "--short"], { cwd: WORK }) || "沒有變更。");
console.log(run(["git", "diff", "HEAD", "--stat"], { cwd: WORK }));
}
function cmdPush(message: string): void {
sync();
if (!run(["git", "status", "--porcelain"], { cwd: WORK }).trim()) return void console.log("沒有變更。");
makeIndex();
const ident = run(["git", "config", "user.name"], { cwd: WORK, check: false }).trim()
? []
: ["-c", "user.name=jsc-wiki", "-c", "user.email=jsc-wiki@noreply.localhost"];
run(["git", "add", "-A"], { cwd: WORK });
run(["git", ...ident, "commit", "-q", "-m", message], { cwd: WORK });
const t = token();
if (!t) die(`已 commit,但找不到 token(tea login 或 JSC_WIKI_TOKEN)。請手動推送:git -C ${WORK} push`);
run(["git", "-c", `http.extraHeader=Authorization: token ${t}`, "push", "-q", "origin", "HEAD"], { cwd: WORK });
console.log("已推送到 wiki。");
}
// ---------------------------------------------------------------- main
process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); throw e; });
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
case "sync": console.log(sync(false)); break;
case "chapters": cmdChapters(); break;
case "find": rest.length ? cmdFind(rest) : die("用法:find <關鍵字>..."); break;
case "add": cmdAdd(rest); break;
case "add-chapter": rest.length === 2 ? cmdAddChapter(rest[0], rest[1]) : die("用法:add-chapter <標題> <說明>"); break;
case "index": sync(); makeIndex(); console.log("已重建章節頁、Home 目錄與上下頁導覽。"); break;
case "lint": cmdLint(rest); break;
case "diff": cmdDiff(); break;
case "push": rest[0] ? cmdPush(rest[0]) : die("用法:push <訊息>"); break;
default: die("用法:book.sh sync|chapters|find|add|add-chapter|index|lint|diff|push(說明見 book.ts 開頭)");
}