@monoedge/jdu-cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +1123 -336
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import { EventEmitter } from "node:events";
3
3
  import childProcess, { spawn } from "node:child_process";
4
4
  import path, { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
- import fs, { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import fs, { chmodSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import process$1 from "node:process";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
- import { homedir, hostname } from "node:os";
9
+ import { homedir, hostname, tmpdir } from "node:os";
10
10
  import { createServer } from "node:http";
11
11
  import { createInterface } from "node:readline";
12
12
  import { readFile, readdir } from "node:fs/promises";
@@ -3326,6 +3326,11 @@ async function loadRuntimeConfig() {
3326
3326
  }
3327
3327
  //#endregion
3328
3328
  //#region src/comments.ts
3329
+ /** 附件一行:`[图片 ×N] /blob/… /blob/…`,给 agent 的是相对路径,拼上 server 地址就能取 */
3330
+ function attachmentsLine(c) {
3331
+ const list = c.attachments ?? [];
3332
+ return list.length === 0 ? null : `[图片 ×${list.length}] ${list.map((s) => `/blob/${s}`).join(" ")}`;
3333
+ }
3329
3334
  var QUOTE_MAX = 120;
3330
3335
  function when(ms) {
3331
3336
  return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
@@ -3360,7 +3365,7 @@ async function listComments(api, docId, opts) {
3360
3365
  for (const root of roots) {
3361
3366
  const head = [
3362
3367
  root.id,
3363
- root.blockId ?? "-",
3368
+ root.blockId ?? "全文",
3364
3369
  root.resolved ? "resolved" : "open",
3365
3370
  who(root),
3366
3371
  when(root.createdAt)
@@ -3369,10 +3374,14 @@ async function listComments(api, docId, opts) {
3369
3374
  out.push(head.join(" "));
3370
3375
  const quote = root.selection?.quote;
3371
3376
  if (quote) out.push(` > ${quote.length > QUOTE_MAX ? `${quote.slice(0, QUOTE_MAX)}…` : quote}`);
3372
- out.push(indent(root.body, " "));
3377
+ if (root.body) out.push(indent(root.body, " "));
3378
+ const rootPics = attachmentsLine(root);
3379
+ if (rootPics) out.push(` ${rootPics}`);
3373
3380
  for (const reply of replies.get(root.id) ?? []) {
3374
3381
  out.push(` ↳ ${reply.id} ${who(reply)} ${when(reply.createdAt)}`);
3375
- out.push(indent(reply.body, " "));
3382
+ if (reply.body) out.push(indent(reply.body, " "));
3383
+ const pics = attachmentsLine(reply);
3384
+ if (pics) out.push(` ${pics}`);
3376
3385
  }
3377
3386
  out.push("");
3378
3387
  }
@@ -3390,119 +3399,9 @@ async function resolveComment(api, threadId, opts) {
3390
3399
  process.stdout.write(`${opts.undo ? "reopened" : "resolved"} ${String(res?.id ?? threadId)}\n`);
3391
3400
  }
3392
3401
  //#endregion
3393
- //#region src/http.ts
3394
- /** 错误响应体太长会淹没终端,只留头部。 */
3395
- var MAX_DETAIL = 400;
3396
- var ApiClient = class {
3397
- server;
3398
- token;
3399
- /** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
3400
- user;
3401
- /** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
3402
- proxySecret;
3403
- constructor(cfg) {
3404
- this.server = cfg.server;
3405
- this.token = cfg.token;
3406
- this.user = cfg.user;
3407
- this.proxySecret = cfg.proxySecret;
3408
- }
3409
- url(path) {
3410
- return `${this.server}${path}`;
3411
- }
3412
- async getJson(path) {
3413
- return this.json("GET", path, void 0, void 0);
3414
- }
3415
- async postJson(path, body) {
3416
- return this.json("POST", path, JSON.stringify(body), "application/json");
3417
- }
3418
- async putJson(path, body) {
3419
- return this.json("PUT", path, JSON.stringify(body), "application/json");
3420
- }
3421
- async deleteJson(path) {
3422
- return this.json("DELETE", path, void 0, void 0);
3423
- }
3424
- /** 文本路由(`/d/:id.md`):原样返回正文。 */
3425
- async getText(path) {
3426
- return (await this.send("GET", path, void 0, void 0)).text();
3427
- }
3428
- /** widget push 走 multipart:boundary 交给 fetch 生成,别自己设 Content-Type。 */
3429
- async postForm(path, form) {
3430
- const text = await (await this.send("POST", path, form, void 0)).text();
3431
- if (text.trim() === "") return void 0;
3432
- try {
3433
- return JSON.parse(text);
3434
- } catch {
3435
- throw new CliError(`POST ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
3436
- }
3437
- }
3438
- /** blob 上传走原始字节,不做任何包装 —— server 直接对 body 校验 sha256。 */
3439
- async putBytes(path, bytes, contentType) {
3440
- await this.send("PUT", path, bytes, contentType);
3441
- }
3442
- async json(method, path, body, contentType) {
3443
- const text = await (await this.send(method, path, body, contentType)).text();
3444
- if (text.trim() === "") return void 0;
3445
- try {
3446
- return JSON.parse(text);
3447
- } catch {
3448
- throw new CliError(`${method} ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
3449
- }
3450
- }
3451
- async send(method, path, body, contentType) {
3452
- const url = this.url(path);
3453
- const headers = {};
3454
- if (this.token !== void 0) headers.Authorization = `Bearer ${this.token}`;
3455
- if (this.user !== void 0) headers["X-Forwarded-User"] = this.user;
3456
- if (this.proxySecret !== void 0) headers["X-Jiandu-Proxy-Secret"] = this.proxySecret;
3457
- if (contentType !== void 0) headers["Content-Type"] = contentType;
3458
- let res;
3459
- try {
3460
- res = await fetch(url, {
3461
- method,
3462
- headers,
3463
- body,
3464
- redirect: "manual"
3465
- });
3466
- } catch (err) {
3467
- throw new CliError(`请求 ${method} ${url} 失败:${describeNetworkError(err)}`, "确认 server 已启动、地址与端口正确");
3468
- }
3469
- if (!res.ok) throw await httpError(method, url, res);
3470
- return res;
3471
- }
3472
- };
3473
- function clip(text) {
3474
- const t = text.trim();
3475
- return t.length > MAX_DETAIL ? `${t.slice(0, MAX_DETAIL)}…` : t;
3476
- }
3477
- /** fetch 失败时真正的原因藏在 cause 里(ECONNREFUSED / ENOTFOUND / 证书错误…)。 */
3478
- function describeNetworkError(err) {
3479
- const cause = err?.cause;
3480
- if (cause instanceof Error) {
3481
- const code = cause.code;
3482
- return code ? `${code} ${cause.message}` : cause.message;
3483
- }
3484
- return err instanceof Error ? err.message : String(err);
3485
- }
3486
- async function httpError(method, url, res) {
3487
- let detail = "";
3488
- try {
3489
- detail = clip(await res.text());
3490
- } catch {
3491
- detail = "";
3492
- }
3493
- if (detail.startsWith("{")) try {
3494
- const obj = JSON.parse(detail);
3495
- const msg = obj.error ?? obj.message;
3496
- if (typeof msg === "string" && msg !== "") detail = msg;
3497
- } catch {}
3498
- const hint = res.status === 401 || res.status === 403 ? "token 无效或无权访问,执行 jdu login --server <url>;forward-auth 下确认已设 JIANDU_FORWARD_AUTH_USER 与 JIANDU_PROXY_SECRET" : res.status === 302 ? "网关要登录。forward-auth 下先 jdu login;若已登录,网关需接受 Authorization: Bearer" : void 0;
3499
- const suffix = detail === "" ? "" : `:${detail}`;
3500
- return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
3501
- }
3502
- //#endregion
3503
3402
  //#region src/browser-auth.ts
3504
3403
  /**
3505
- * jdu init 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
3404
+ * jdu login 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
3506
3405
  * 人在浏览器里用 passkey 会话点一次「授权」,回跳带回一次性 code,再用 PKCE verifier 向 server 换 token。
3507
3406
  *
3508
3407
  * authorizeUrl 与 server 可能不同源(CLI 连 127.0.0.1:18082,浏览器开 https://md.mason.local)——
@@ -3517,7 +3416,7 @@ async function browserAuthorize(input) {
3517
3416
  server.close();
3518
3417
  fn(value);
3519
3418
  };
3520
- const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu init;不想开浏览器就带 --token")));
3419
+ const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu login;不想开浏览器就带 --token")));
3521
3420
  const handler = callbackHandler(state, finish(resolve));
3522
3421
  const server = createServer((req, res) => {
3523
3422
  if (new URL(req.url ?? "/", "http://localhost").pathname !== "/callback") {
@@ -3550,7 +3449,7 @@ async function browserAuthorize(input) {
3550
3449
  })
3551
3450
  });
3552
3451
  const text = await res.text();
3553
- if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu init 再试一次");
3452
+ if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu login 再试一次");
3554
3453
  let token;
3555
3454
  try {
3556
3455
  token = JSON.parse(text).token;
@@ -3596,7 +3495,11 @@ async function probeApiWithBearer(server, token) {
3596
3495
  redirect: "manual"
3597
3496
  })).ok;
3598
3497
  }
3599
- async function runLogin(opts) {
3498
+ /**
3499
+ * forward-auth / OIDC 分支,外加 --user 直连与 --token 直存。
3500
+ * 不是命令入口——入口是 init.ts 的 runLogin,它探完 healthz 才分派到这里。
3501
+ */
3502
+ async function runOidcLogin(opts) {
3600
3503
  const server = opts.server.trim().replace(/\/+$/, "");
3601
3504
  if (server === "") throw new CliError("--server 不能为空");
3602
3505
  const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
@@ -3621,7 +3524,7 @@ async function runLogin(opts) {
3621
3524
  configPath: saveConfig({ server }),
3622
3525
  source: "anonymous"
3623
3526
  };
3624
- if (provider === "token" || provider === "password") throw new CliError(`这个 server 用 ${provider} 鉴权,CLI 需要 --token`, "jdu init --server <url>(站点支持时打开浏览器授权),或 jdu login --server <url> --token <token>(token 在 server 首启 stdout / data/initial-token.txt)");
3527
+ if (provider === "token" || provider === "password") throw new CliError(`这个 server 用 ${provider} 鉴权,CLI 需要 --token`, "jdu login --server <url> --token <token>(token 在 server 首启 stdout / data/initial-token.txt)");
3625
3528
  const oidc = resolveOidc({
3626
3529
  issuer: opts.issuer,
3627
3530
  clientId: opts.clientId,
@@ -3659,21 +3562,22 @@ async function runLogin(opts) {
3659
3562
  //#endregion
3660
3563
  //#region src/init.ts
3661
3564
  /**
3662
- * jdu init:选定部署目标并写好本地配置,一条命令把「指向哪台简牍」定下来。
3565
+ * jdu login:选定站点、拿到凭据、写好本地配置,一条命令把「登录到哪台简牍」定下来。
3663
3566
  *
3664
- * 目标三选一:
3665
- * (默认) 官方线上服务 https://docs.mszhou.com(OFFICIAL_SERVER)
3567
+ * 目标必须显式给出(没有默认站点,官方商用服务尚未上线):
3666
3568
  * --local 本机自部署(http://127.0.0.1:8080)
3667
3569
  * --server <u> 任意自部署地址
3668
3570
  *
3669
3571
  * 为 agent 联动而设计:全 flag 驱动、无交互提示、--json 输出机器可读结果,
3670
3572
  * 拿到 next 字段就知道下一步该干什么(要不要 token、去哪拿)。
3671
3573
  *
3672
- * 登录方式听 server 的(healthz.cliAuth,#66):声明了 browser 就开浏览器授权换 token;
3673
- * 只有 token 就把 server 给的 tokenHint 拼进 next。CLI 不按「官方 / 自部署」猜。
3574
+ * 登录方式全听 server 的(healthz.cliAuth,#66),用户不必先知道自己的站点用什么鉴权:
3575
+ * browser → 开浏览器授权换 token
3576
+ * oidc → 委托 runOidcLogin 走 SSO(forward-auth)
3577
+ * token → 把 server 给的 tokenHint 拼进 next,告诉去哪拿
3578
+ * anonymous→ 直接就绪
3579
+ * CLI 不按「官方 / 自部署」猜,也不再要求用户在 init / login 之间二选一。
3674
3580
  */
3675
- /** 官方线上地址。password 鉴权:浏览器 passkey,CLI 走 token,token 向站点管理员申领。 */
3676
- var OFFICIAL_SERVER = "https://docs.mszhou.com";
3677
3581
  var LOCAL_SERVER = "http://127.0.0.1:8080";
3678
3582
  function resolveInitTarget(opts) {
3679
3583
  if (opts.local && opts.server) throw new CliError("--local 与 --server 只能二选一");
@@ -3695,12 +3599,9 @@ function resolveInitTarget(opts) {
3695
3599
  target: "local",
3696
3600
  server: LOCAL_SERVER
3697
3601
  };
3698
- return {
3699
- target: "official",
3700
- server: OFFICIAL_SERVER
3701
- };
3602
+ throw new CliError("jdu login 需要指定站点", "自部署用 --server <url>,本机用 --local");
3702
3603
  }
3703
- async function runInit(opts) {
3604
+ async function runLogin(opts) {
3704
3605
  const { target, server } = resolveInitTarget(opts);
3705
3606
  const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
3706
3607
  const provider = healthz.authProvider ?? "token";
@@ -3711,6 +3612,21 @@ async function runInit(opts) {
3711
3612
  authProvider: provider,
3712
3613
  authMethods: methods
3713
3614
  };
3615
+ if (opts.user !== void 0 && opts.user.trim() !== "") {
3616
+ const r = await runOidcLogin({
3617
+ server,
3618
+ user: opts.user,
3619
+ proxySecret: opts.proxySecret,
3620
+ fetchHealthz: async () => healthz
3621
+ });
3622
+ return {
3623
+ ...base,
3624
+ loggedIn: true,
3625
+ configPath: r.configPath,
3626
+ next: null,
3627
+ warning: r.warning
3628
+ };
3629
+ }
3714
3630
  if (opts.token !== void 0 && opts.token !== "") {
3715
3631
  if (!await (opts.probe ?? probeApiWithBearer)(server, opts.token)) throw new CliError("token 校验失败(/api/docs 未放行)", "确认 token 正确、且网关放行 Authorization: Bearer");
3716
3632
  const path = saveConfig({
@@ -3754,8 +3670,25 @@ async function runInit(opts) {
3754
3670
  next: null
3755
3671
  };
3756
3672
  }
3757
- const viaToken = `jdu init --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
3758
- const next = provider === "forward-auth" ? `jdu login --server '${server}'(浏览器 SSO),或 ${viaToken}` : methods.includes("browser") ? `jdu init --server '${server}'(不带 --json / --no-browser,打开浏览器授权),或 ${viaToken}` : viaToken;
3673
+ const viaToken = `jdu login --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
3674
+ if (provider === "forward-auth" && opts.browser) {
3675
+ const r = await runOidcLogin({
3676
+ server,
3677
+ issuer: opts.issuer,
3678
+ clientId: opts.clientId,
3679
+ fetchHealthz: async () => healthz,
3680
+ oidcLogin: opts.oidcLogin,
3681
+ probe: opts.probe
3682
+ });
3683
+ return {
3684
+ ...base,
3685
+ loggedIn: true,
3686
+ configPath: r.configPath,
3687
+ next: null,
3688
+ warning: r.warning
3689
+ };
3690
+ }
3691
+ const next = methods.includes("browser") ? `jdu login --server '${server}'(不带 --json / --no-browser,打开浏览器授权),或 ${viaToken}` : viaToken;
3759
3692
  return {
3760
3693
  ...base,
3761
3694
  loggedIn: false,
@@ -3769,11 +3702,355 @@ function fallbackMethods(provider) {
3769
3702
  if (provider === "forward-auth") return ["oidc", "token"];
3770
3703
  return ["token"];
3771
3704
  }
3705
+ //#endregion
3706
+ //#region src/deploy.ts
3707
+ /**
3708
+ * `jdu deploy`:在用户自己的 Cloudflare 账号里起一个 jiandu,一条命令从零到能登录。
3709
+ *
3710
+ * 为什么是 CLI 而不是 Deploy 按钮:jiandu 是给 agent 用的,agent 会跑命令不会点按钮;
3711
+ * 而且这条路不要求 GitHub 账号、不在用户账号里留一个仓库与构建流水线,`JIANDU_SETUP_SECRET`
3712
+ * 由这里随机生成(用户手填的 secret 强度没法保证)。
3713
+ *
3714
+ * 我们不自己调 Cloudflare API:Worker 上传、Durable Object migration、静态资源上传会话
3715
+ * 是三套协议,wrangler 已经把它们做完了,重写一遍只会多一份要跟着 Cloudflare 演进的代码。
3716
+ * 所以这里只做三件事:把产物取下来、按正确的顺序敲 wrangler、把结果接回 jdu 的登录态。
3717
+ *
3718
+ * jdu deploy --owner mason 首次:部署 → 生成 secret → 自动 jdu login
3719
+ * jdu deploy 升级:npm 取 latest 再部署一次,vars 与 secret 原样保留
3720
+ */
3721
+ /** npm 上的部署产物包:worker.js + assets/ + wrangler.json。 */
3722
+ var PACKAGE = "@monoedge/jiandu-cf";
3723
+ /** wrangler 主版本锁死:它改 flag 我们要跟着改,不能让用户某天突然拿到 v5。 */
3724
+ var WRANGLER = "wrangler@4";
3725
+ /** 默认 runner:stdio 继承给用户看(wrangler 的 OAuth 提示、进度条都靠它)。 */
3726
+ var spawnRunner = (cmd, args, opts) => new Promise((ok, fail) => {
3727
+ const child = spawn(cmd, args, {
3728
+ stdio: [
3729
+ opts.input === void 0 ? "inherit" : "pipe",
3730
+ "inherit",
3731
+ "inherit"
3732
+ ],
3733
+ shell: process.platform === "win32",
3734
+ env: {
3735
+ ...process.env,
3736
+ ...opts.env
3737
+ }
3738
+ });
3739
+ if (opts.input !== void 0) child.stdin?.end(opts.input);
3740
+ child.on("error", (err) => fail(new CliError(`跑不起来 ${cmd}:${err.message}`, "需要 node 与 npm 在 PATH 里")));
3741
+ child.on("exit", (code) => code === 0 ? ok() : fail(new CliError(`${cmd} ${args[0] ?? ""} 失败(退出码 ${code})`)));
3742
+ });
3743
+ /** 24 字节随机,base64url —— 它会出现在 URL 的 query 里(/setup?invite=),别带需要转义的字符。 */
3744
+ function newSecret() {
3745
+ return randomBytes(24).toString("base64url");
3746
+ }
3747
+ /**
3748
+ * 取部署产物:`--dist` 指本地目录,否则临时装一份 npm 包。
3749
+ * 返回 wrangler 配置的绝对路径 —— wrangler 按**配置文件所在目录**解析 main 与 assets.directory,
3750
+ * 所以不需要把产物拷到工作目录,也不需要生成任何脚手架。
3751
+ */
3752
+ async function resolveConfig(opts, run) {
3753
+ if (opts.dist !== void 0) {
3754
+ const config = resolve(opts.dist, "wrangler.json");
3755
+ if (!existsSync(config)) throw new CliError(`${config} 不存在`, "--dist 要指向 server/dist-cf 那样的构建产物目录");
3756
+ return {
3757
+ config,
3758
+ cleanup: () => void 0
3759
+ };
3760
+ }
3761
+ const dir = mkdtempSync(join(tmpdir(), "jdu-deploy-"));
3762
+ try {
3763
+ await run("npm", [
3764
+ "install",
3765
+ "--no-audit",
3766
+ "--no-fund",
3767
+ "--prefix",
3768
+ dir,
3769
+ `${PACKAGE}@latest`
3770
+ ], {});
3771
+ } catch (err) {
3772
+ rmSync(dir, {
3773
+ recursive: true,
3774
+ force: true
3775
+ });
3776
+ throw err;
3777
+ }
3778
+ const config = join(dir, "node_modules", PACKAGE, "dist", "wrangler.json");
3779
+ if (!existsSync(config)) {
3780
+ rmSync(dir, {
3781
+ recursive: true,
3782
+ force: true
3783
+ });
3784
+ throw new CliError(`${PACKAGE} 里没有 dist/wrangler.json`, "包版本太老或装坏了,重试一次;仍失败请报 issue");
3785
+ }
3786
+ return {
3787
+ config,
3788
+ cleanup: () => rmSync(dir, {
3789
+ recursive: true,
3790
+ force: true
3791
+ })
3792
+ };
3793
+ }
3794
+ /** wrangler 的结构化输出:NDJSON,deploy 那条带 targets(部署好的地址)。 */
3795
+ function urlFromOutput(file) {
3796
+ if (!existsSync(file)) return null;
3797
+ for (const line of readFileSync(file, "utf8").split("\n")) {
3798
+ if (line.trim() === "") continue;
3799
+ try {
3800
+ const entry = JSON.parse(line);
3801
+ if (entry.type !== "deploy" || !Array.isArray(entry.targets)) continue;
3802
+ const target = entry.targets.find((t) => typeof t === "string" && t.startsWith("http"));
3803
+ if (target !== void 0) return target;
3804
+ } catch {}
3805
+ }
3806
+ return null;
3807
+ }
3808
+ /** 部署完探一次 healthz:缺 secret 的实例冷启动就抛错,所有请求 500。 */
3809
+ async function healthy(url) {
3810
+ try {
3811
+ return (await fetch(`${url}/healthz`, { headers: { accept: "application/json" } })).ok;
3812
+ } catch {
3813
+ return false;
3814
+ }
3815
+ }
3816
+ async function runDeploy(opts) {
3817
+ const run = opts.run ?? spawnRunner;
3818
+ const name = opts.name ?? "jiandu";
3819
+ const { config, cleanup } = await resolveConfig(opts, run);
3820
+ try {
3821
+ if (opts.dryRun) {
3822
+ await run("npx", [
3823
+ "--yes",
3824
+ WRANGLER,
3825
+ "deploy",
3826
+ "--dry-run",
3827
+ "-c",
3828
+ config,
3829
+ "--name",
3830
+ name
3831
+ ], {});
3832
+ return {
3833
+ name,
3834
+ url: null,
3835
+ setupSecret: null,
3836
+ setupUrl: null,
3837
+ loggedIn: false
3838
+ };
3839
+ }
3840
+ await run("npx", [
3841
+ "--yes",
3842
+ WRANGLER,
3843
+ "login"
3844
+ ], {});
3845
+ const outDir = mkdtempSync(join(tmpdir(), "jdu-wrangler-out-"));
3846
+ const outFile = join(outDir, "out.ndjson");
3847
+ const args = [
3848
+ "--yes",
3849
+ WRANGLER,
3850
+ "deploy",
3851
+ "-c",
3852
+ config,
3853
+ "--name",
3854
+ name,
3855
+ "--keep-vars"
3856
+ ];
3857
+ if (opts.owner !== void 0) args.push("--var", `JIANDU_AUTH_OWNER:${opts.owner}`);
3858
+ await run("npx", args, { env: { WRANGLER_OUTPUT_FILE_PATH: outFile } });
3859
+ const url = urlFromOutput(outFile);
3860
+ rmSync(outDir, {
3861
+ recursive: true,
3862
+ force: true
3863
+ });
3864
+ if (url === null) throw new CliError("部署命令跑完了,但没能从 wrangler 的输出里读到地址", "去 Cloudflare 控制台看这个 Worker 的地址,再手动 npx wrangler secret put JIANDU_SETUP_SECRET");
3865
+ const setupSecret = await healthy(url) ? null : newSecret();
3866
+ if (setupSecret !== null) {
3867
+ await run("npx", [
3868
+ "--yes",
3869
+ WRANGLER,
3870
+ "secret",
3871
+ "put",
3872
+ "JIANDU_SETUP_SECRET",
3873
+ "-c",
3874
+ config,
3875
+ "--name",
3876
+ name
3877
+ ], { input: setupSecret });
3878
+ if (!await healthy(url)) throw new CliError(`${url} 起不来(secret 已设)`, `跑 npx ${WRANGLER} tail --name ${name} 看冷启动日志;常见原因是 JIANDU_AUTH_OWNER 没设或 provider 填了不支持的值`);
3879
+ }
3880
+ let loggedIn = false;
3881
+ if (setupSecret !== null) {
3882
+ await runLogin({
3883
+ server: url,
3884
+ token: setupSecret,
3885
+ browser: false
3886
+ });
3887
+ loggedIn = true;
3888
+ }
3889
+ return {
3890
+ name,
3891
+ url,
3892
+ setupSecret,
3893
+ setupUrl: setupSecret === null ? null : `${url}/setup?invite=${setupSecret}`,
3894
+ loggedIn
3895
+ };
3896
+ } finally {
3897
+ cleanup();
3898
+ }
3899
+ }
3900
+ //#endregion
3901
+ //#region src/http.ts
3902
+ /** 错误响应体太长会淹没终端,只留头部。 */
3903
+ var MAX_DETAIL = 400;
3904
+ var ApiClient = class {
3905
+ server;
3906
+ token;
3907
+ /** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
3908
+ user;
3909
+ /** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
3910
+ proxySecret;
3911
+ constructor(cfg) {
3912
+ this.server = cfg.server;
3913
+ this.token = cfg.token;
3914
+ this.user = cfg.user;
3915
+ this.proxySecret = cfg.proxySecret;
3916
+ }
3917
+ url(path) {
3918
+ return `${this.server}${path}`;
3919
+ }
3920
+ async getJson(path) {
3921
+ return this.json("GET", path, void 0, void 0);
3922
+ }
3923
+ async postJson(path, body) {
3924
+ return this.json("POST", path, JSON.stringify(body), "application/json");
3925
+ }
3926
+ async putJson(path, body) {
3927
+ return this.json("PUT", path, JSON.stringify(body), "application/json");
3928
+ }
3929
+ async deleteJson(path) {
3930
+ return this.json("DELETE", path, void 0, void 0);
3931
+ }
3932
+ /** 文本路由(`/d/:id.md`):原样返回正文。 */
3933
+ async getText(path) {
3934
+ return (await this.send("GET", path, void 0, void 0)).text();
3935
+ }
3936
+ /** widget push 走 multipart:boundary 交给 fetch 生成,别自己设 Content-Type。 */
3937
+ async postForm(path, form) {
3938
+ const text = await (await this.send("POST", path, form, void 0)).text();
3939
+ if (text.trim() === "") return void 0;
3940
+ try {
3941
+ return JSON.parse(text);
3942
+ } catch {
3943
+ throw new CliError(`POST ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
3944
+ }
3945
+ }
3946
+ /** blob 上传走原始字节,不做任何包装 —— server 直接对 body 校验 sha256。 */
3947
+ async putBytes(path, bytes, contentType) {
3948
+ await this.send("PUT", path, bytes, contentType);
3949
+ }
3950
+ async json(method, path, body, contentType) {
3951
+ const text = await (await this.send(method, path, body, contentType)).text();
3952
+ if (text.trim() === "") return void 0;
3953
+ try {
3954
+ return JSON.parse(text);
3955
+ } catch {
3956
+ throw new CliError(`${method} ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
3957
+ }
3958
+ }
3959
+ async send(method, path, body, contentType) {
3960
+ const url = this.url(path);
3961
+ const headers = {};
3962
+ if (this.token !== void 0) headers.Authorization = `Bearer ${this.token}`;
3963
+ if (this.user !== void 0) headers["X-Forwarded-User"] = this.user;
3964
+ if (this.proxySecret !== void 0) headers["X-Jiandu-Proxy-Secret"] = this.proxySecret;
3965
+ if (contentType !== void 0) headers["Content-Type"] = contentType;
3966
+ let res;
3967
+ try {
3968
+ res = await fetch(url, {
3969
+ method,
3970
+ headers,
3971
+ body,
3972
+ redirect: "manual"
3973
+ });
3974
+ } catch (err) {
3975
+ throw new CliError(`请求 ${method} ${url} 失败:${describeNetworkError(err)}`, "确认 server 已启动、地址与端口正确");
3976
+ }
3977
+ if (!res.ok) throw await httpError(method, url, res);
3978
+ return res;
3979
+ }
3980
+ };
3981
+ function clip(text) {
3982
+ const t = text.trim();
3983
+ return t.length > MAX_DETAIL ? `${t.slice(0, MAX_DETAIL)}…` : t;
3984
+ }
3985
+ /** fetch 失败时真正的原因藏在 cause 里(ECONNREFUSED / ENOTFOUND / 证书错误…)。 */
3986
+ function describeNetworkError(err) {
3987
+ const cause = err?.cause;
3988
+ if (cause instanceof Error) {
3989
+ const code = cause.code;
3990
+ return code ? `${code} ${cause.message}` : cause.message;
3991
+ }
3992
+ return err instanceof Error ? err.message : String(err);
3993
+ }
3994
+ async function httpError(method, url, res) {
3995
+ let detail = "";
3996
+ try {
3997
+ detail = clip(await res.text());
3998
+ } catch {
3999
+ detail = "";
4000
+ }
4001
+ if (detail.startsWith("{")) try {
4002
+ const obj = JSON.parse(detail);
4003
+ const msg = obj.error ?? obj.message;
4004
+ if (typeof msg === "string" && msg !== "") detail = msg;
4005
+ } catch {}
4006
+ const hint = res.status === 401 || res.status === 403 ? "token 无效或无权访问,执行 jdu login --server <url>;forward-auth 下确认已设 JIANDU_FORWARD_AUTH_USER 与 JIANDU_PROXY_SECRET" : res.status === 302 ? "网关要登录。forward-auth 下先 jdu login;若已登录,网关需接受 Authorization: Bearer" : void 0;
4007
+ const suffix = detail === "" ? "" : `:${detail}`;
4008
+ return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
4009
+ }
3772
4010
  /** 只取链接/图片的目标部分,标签内容可以任意嵌套,不参与匹配。 */
3773
4011
  var MD_DEST_RE = /\]\(([^)]*)\)/g;
3774
4012
  var HTML_ATTR_RE = /\b(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/gi;
3775
- /** 抽出一份文本里所有可能的引用目标(未过滤外链,原样返回)。 */
3776
- function extractRefs(text) {
4013
+ /** server 的 scanFences 同一条规则(rewrite.ts):缩进 ≤3、3 个以上反引号或波浪号。 */
4014
+ var FENCE_RE = /^([ \t]{0,3})(`{3,}|~{3,})(.*)$/;
4015
+ /**
4016
+ * 去掉 fenced code block 再找引用:代码块里的 `](./a.png)` 是给人看的例子,不是引用。
4017
+ *
4018
+ * 这条规则必须与 server 对齐 —— `rewriteReferences` 就是按 `scanFences` 跳过同样的区间。
4019
+ * 不一致的后果是双向的:CLI 多收就会上传一堆永远不会被改写的文件,还对着代码里的
4020
+ * `steps[i](tx)` 这种写法报「跳过不存在的本地引用」;CLI 少收则会漏传真引用。
4021
+ *
4022
+ * 明知是重复实现:CLI 是零依赖的单文件发布物,为十几行扫描规则给它加一个 workspace 依赖不值。
4023
+ * 规则本身是 CommonMark 定死的,不会漂。
4024
+ */
4025
+ function stripFences(text) {
4026
+ const out = [];
4027
+ let open = null;
4028
+ for (const line of text.split("\n")) {
4029
+ const m = FENCE_RE.exec(line);
4030
+ const marker = m?.[2];
4031
+ if (marker !== void 0) {
4032
+ const char = marker[0];
4033
+ const rest = m?.[3] ?? "";
4034
+ if (open === null) {
4035
+ if (!(char === "`" && rest.includes("`"))) {
4036
+ open = {
4037
+ char,
4038
+ len: marker.length
4039
+ };
4040
+ continue;
4041
+ }
4042
+ } else if (char === open.char && marker.length >= open.len && rest.trim() === "") {
4043
+ open = null;
4044
+ continue;
4045
+ }
4046
+ }
4047
+ if (open === null) out.push(line);
4048
+ }
4049
+ return out.join("\n");
4050
+ }
4051
+ /** 抽出一份文本里所有可能的引用目标(未过滤外链,原样返回)。代码块内的写法不算。 */
4052
+ function extractRefs(raw) {
4053
+ const text = stripFences(raw);
3777
4054
  const out = [];
3778
4055
  for (const m of text.matchAll(MD_DEST_RE)) {
3779
4056
  const dest = destinationOf(m[1] ?? "");
@@ -3888,15 +4165,30 @@ function collectReferences(entryPath, maxDepth = 10) {
3888
4165
  //#region src/push.ts
3889
4166
  /** 可见性三档。link 档已废除:定向分享走读者池(jdu share --to)。 */
3890
4167
  var VISIBILITIES = ["private", "public"];
4168
+ function asLint(v) {
4169
+ if (v === null || typeof v !== "object") return null;
4170
+ const r = v;
4171
+ if (typeof r["code"] !== "string" || typeof r["message"] !== "string") return null;
4172
+ return {
4173
+ code: r["code"],
4174
+ lang: typeof r["lang"] === "string" ? r["lang"] : "",
4175
+ ...typeof r["line"] === "number" ? { line: r["line"] } : {},
4176
+ ...typeof r["blockId"] === "string" ? { blockId: r["blockId"] } : {},
4177
+ message: r["message"]
4178
+ };
4179
+ }
3891
4180
  function log(line) {
3892
4181
  process.stderr.write(`${line}\n`);
3893
4182
  }
3894
- /** 没给 --title 时用首个一级标题,再退化到文件名。 */
4183
+ /** 没给 --title 时用首个一级标题,其次 frontmatter 的 title / name(SKILL.md 形态,#122),再退化到文件名。 */
3895
4184
  function inferTitle(entryPath) {
3896
4185
  try {
3897
4186
  const text = readFileSync(entryPath, "utf8");
3898
4187
  const m = /^[ \t]{0,3}#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(text);
3899
4188
  if (m?.[1]) return m[1].trim();
4189
+ const fm = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text)?.[1];
4190
+ const t = fm ? /^(?:title|name)[ \t]*:[ \t]*(.+?)[ \t]*$/m.exec(fm)?.[1] : void 0;
4191
+ if (t) return /^(["']).*\1$/.test(t) ? t.slice(1, -1) : t;
3900
4192
  } catch {}
3901
4193
  return basename(entryPath, extname(entryPath));
3902
4194
  }
@@ -3937,18 +4229,25 @@ async function pushDoc(api, entryArg, opts) {
3937
4229
  if (opts.id !== void 0 && opts.id !== "") body.id = opts.id;
3938
4230
  if (opts.tag !== void 0) body.tags = opts.tag.flatMap((t) => t.split(",")).map((t) => t.trim()).filter((t) => t.length > 0);
3939
4231
  if (opts.official === true) body.official = true;
4232
+ if (opts.team !== void 0) body.team = opts.team;
3940
4233
  const res = await api.postJson("/api/docs", body);
3941
4234
  const id = typeof res?.id === "string" ? res.id : opts.id;
3942
4235
  const url = typeof res?.url === "string" && res.url !== "" ? res.url : id !== void 0 ? api.url(`/d/${id}`) : void 0;
3943
4236
  const warnings = Array.isArray(res?.warnings) ? res.warnings.map(String) : [];
3944
4237
  for (const w of warnings) log(`warn: ${w}`);
4238
+ const lint = Array.isArray(res?.lint) ? res.lint.map(asLint).filter((l) => l !== null) : [];
4239
+ for (const l of lint) {
4240
+ const where = l.line !== void 0 ? ` 第 ${l.line} 行` : l.blockId ? ` ${l.blockId}` : "";
4241
+ log(`lint: ${l.code} ${l.lang}${where} — ${l.message}`);
4242
+ }
3945
4243
  if (typeof res?.seq === "number") log(`已发布 ${id ?? ""} v${res.seq}`);
3946
4244
  if (url === void 0) throw new CliError("server 未返回文档 id 或 url,无法给出访问地址");
3947
4245
  return {
3948
4246
  id,
3949
4247
  seq: typeof res?.seq === "number" ? res.seq : void 0,
3950
4248
  url,
3951
- warnings
4249
+ warnings,
4250
+ lint
3952
4251
  };
3953
4252
  }
3954
4253
  //#endregion
@@ -3988,6 +4287,73 @@ function printTable(rows, columns) {
3988
4287
  * 模板原样发出去,怎么填是 agent 的事。agent 侧走 MCP 的 list_templates → read_doc → 按骨架写 → push_doc。
3989
4288
  */
3990
4289
  var TEMPLATE_TAG = "template";
4290
+ /**
4291
+ * 模板元数据(#115):写在模板里的一个 HTML 注释,渲染不可见,agent 侧拿它当 MCP prompt 的 name / description:
4292
+ *
4293
+ * <!-- jiandu-template
4294
+ * name: weekly
4295
+ * description: 每周五给团队同步进展。触发:周报、weekly
4296
+ * -->
4297
+ *
4298
+ * 没有这段也能用:name 退回文档 id,description 退回摘要。
4299
+ * 文首 YAML frontmatter 里的同名字段也认(k7 起内核不渲染 frontmatter,SKILL.md 可以原样发;#122),注释优先。
4300
+ *
4301
+ * `arguments:` 声明 prompt 参数(#121):`week(本周编号,如 2026-W36); owner?(负责人)`——分号分隔,`?` 表示可选,
4302
+ * 括号里是说明。prompts/get 时 `jdu mcp` 把正文里的 `{{week}}` 换成客户端给的值;server 仍不做任何替换。
4303
+ */
4304
+ var META_RE = /<!--\s*jiandu-template\s*\r?\n([\s\S]*?)-->[ \t]*\r?\n?/;
4305
+ /** 与 render 的 splitFrontmatter 同一条正则;cli 不为这一处引整个渲染包 */
4306
+ var FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
4307
+ /** prompt name 会拼进 `/mcp__jiandu__<name>` 这类客户端命令,只放行安全字符 */
4308
+ var NAME_RE$1 = /^[A-Za-z0-9_-]{1,64}$/;
4309
+ var ARG_RE = /^([A-Za-z0-9_-]{1,64})(\?)?\s*(?:[((](.*)[))])?$/;
4310
+ function parseArgs(raw) {
4311
+ return raw.split(/[;;]/).map((s) => s.trim()).filter(Boolean).flatMap((item) => {
4312
+ const m = ARG_RE.exec(item);
4313
+ if (!m?.[1]) return [];
4314
+ const desc = m[3]?.trim();
4315
+ return [{
4316
+ name: m[1],
4317
+ required: m[2] === void 0,
4318
+ ...desc ? { description: desc } : {}
4319
+ }];
4320
+ });
4321
+ }
4322
+ /** 一段 `key: value` 行(注释正文或 frontmatter)→ 填进 meta;先到先得,所以调用顺序就是优先级。 */
4323
+ function readFields(block, out) {
4324
+ for (const line of block.split("\n")) {
4325
+ const kv = /^\s*([a-z]+)\s*:\s*(.*?)\s*$/.exec(line);
4326
+ if (!kv?.[2]) continue;
4327
+ const v = /^(["']).*\1$/.test(kv[2]) ? kv[2].slice(1, -1) : kv[2];
4328
+ if (kv[1] === "name" && out.name === void 0 && NAME_RE$1.test(v)) out.name = v;
4329
+ else if (kv[1] === "description" && out.description === void 0) out.description = v;
4330
+ else if (kv[1] === "arguments" && out.arguments.length === 0) out.arguments = parseArgs(v);
4331
+ }
4332
+ }
4333
+ function parseTemplateMeta(md) {
4334
+ const out = {
4335
+ arguments: [],
4336
+ body: md
4337
+ };
4338
+ const fm = FRONTMATTER_RE.exec(md);
4339
+ if (fm) out.body = md.slice(fm[0].length);
4340
+ const m = META_RE.exec(out.body);
4341
+ if (m) {
4342
+ out.body = out.body.replace(m[0], "");
4343
+ readFields(m[1] ?? "", out);
4344
+ }
4345
+ if (fm) readFields(fm[1] ?? "", out);
4346
+ return out;
4347
+ }
4348
+ /** `{{name}}` → 值;没给的可选参数替换成空串。只替换声明过的参数,别的 `{{…}}` 原样留着。 */
4349
+ function fillTemplate(body, args, values) {
4350
+ let out = body;
4351
+ for (const a of args) {
4352
+ const v = typeof values[a.name] === "string" ? values[a.name] : "";
4353
+ out = out.replace(new RegExp(`\\{\\{\\s*${a.name}\\s*\\}\\}`, "g"), v);
4354
+ }
4355
+ return out;
4356
+ }
3991
4357
  var hasTemplateTag = (d) => Array.isArray(d.tags) && d.tags.map(String).includes("template");
3992
4358
  async function listTemplates(api, opts) {
3993
4359
  const [mine, official] = await Promise.all([api.getJson(`/api/docs${opts.all ? "?all=1" : ""}`), api.getJson("/api/official")]);
@@ -4013,6 +4379,10 @@ async function listTemplates(api, opts) {
4013
4379
  key: "title",
4014
4380
  header: "TITLE"
4015
4381
  },
4382
+ {
4383
+ key: "uses",
4384
+ header: "USES"
4385
+ },
4016
4386
  {
4017
4387
  key: "updatedAt",
4018
4388
  header: "UPDATED"
@@ -4034,6 +4404,7 @@ async function pullTemplate(api, id, opts) {
4034
4404
  * (push 的 tags 字段是整体覆盖语义,不先取回就会把别的标签抹掉)。
4035
4405
  */
4036
4406
  async function pushTemplate(api, file, opts) {
4407
+ if (!parseTemplateMeta(readFileSync(file, "utf8")).name) process.stderr.write("warn: 模板没有 <!-- jiandu-template name/description --> 元数据注释(或 frontmatter):agent 侧的 prompt 名会退回文档 id、描述退回摘要\n");
4037
4408
  let tags = [TEMPLATE_TAG];
4038
4409
  if (opts.id) {
4039
4410
  const cur = await api.getJson(`/api/docs/${encodeURIComponent(opts.id)}/tags`).catch(() => ({}));
@@ -4054,8 +4425,12 @@ async function pushTemplate(api, file, opts) {
4054
4425
  * 当可读可写的知识库用(#9 A 段)。凭据复用 ~/.config/jiandu/config.json,server 端零改动。
4055
4426
  *
4056
4427
  * ponytail: 不引 @modelcontextprotocol/sdk——它带 express / hono / zod 一整套,而这里只需要
4057
- * initialize / tools/list / tools/call 三个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4428
+ * initialize / tools/* / prompts/* 几个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4058
4429
  * stdout 是协议信道:本文件之外任何写 stdout 的代码都不能在 mcp 模式下被调到(pushDoc 已改为返回值)。
4430
+ *
4431
+ * 模板即 prompt(#115):打了 template 标签的文档同时暴露为 MCP prompts(Claude Code 里是 /mcp__jiandu__<name>),
4432
+ * name / description 来自模板里的 `<!-- jiandu-template -->` 注释。语法三层(#116):常驻 instructions 放最容易错的几条,
4433
+ * get_syntax 按需拿这台实例的完整规则(带 widget= 就是某个组件的完整 schema),push_doc 响应的 lint[] 事后指出会降级的 fence。
4059
4434
  */
4060
4435
  var SUPPORTED_PROTOCOLS = [
4061
4436
  "2025-06-18",
@@ -4064,13 +4439,41 @@ var SUPPORTED_PROTOCOLS = [
4064
4439
  ];
4065
4440
  var SERVER_INFO = {
4066
4441
  name: "jiandu",
4067
- version: "0.2.0"
4442
+ version: "0.6.0"
4068
4443
  };
4069
- var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库。先用 search_docs / list_docs 找到文档 id,read_doc 拿 markdown 原文;改稿后用 push_doc 发布新版本(带 id 才是更新,不带是新建)。文档里的 widget fence(```widget_component:Name)保留原样。写新文档前先 list_templates 看有没有对应骨架(周报 / 技术方案…),有就 read_doc 取模板按节填,占位注释自己替换掉。";
4444
+ /**
4445
+ * 常驻开场白(#116 L0):只放**跨工具**的知识——各个工具自己做什么由它们的 description 讲,
4446
+ * 在这里复述一遍等于每轮对话都多付一份 token。留下的是顺序、写作规范,和 fence 那几条最容易错的。
4447
+ */
4448
+ var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库,这些工具都是它 HTTP API 的薄封装。典型顺序:list_docs 找到 id → read_doc 拿原文 → 改稿 → push_doc 发新版本;写新文档前先 list_templates 看有没有现成骨架。写法:标准 markdown + GFM;一级标题就是文档标题,第一段会截成摘要。可交互组件写 ```widget:Name fence、正文是一个 JSON 对象入参:Name 区分大小写、必须是这台实例已注册的,写错不报错、只会静默变成普通代码块;JSON 字符串里的 markdown 要转义(换行 \\n、引号 \\\")。mermaid / diff / vega-lite 直接写 ```mermaid 这类 fence,正文是 DSL 不是 JSON。用扩展语法前先 get_syntax;push_doc 响应的 lint[] 非空就是有 fence 会降级,按提示改稿重推。已有的 widget fence 保留原样。";
4449
+ /** prompts/get 给模型的开场:模板正文前的一段固定说明,不在模板里另起一套「指令字段」。 */
4450
+ function promptText(t) {
4451
+ return `下面是文档模板「${t.title}」的骨架。按节填写,删掉所有 <!-- --> 占位注释,第一段写一句话摘要;写完用 push_doc 发布(默认 private,进团队传 team),并把链接给用户。模板里的说明文字不要带进正文。
4452
+
4453
+ ---
4454
+
4455
+ ` + t.body;
4456
+ }
4070
4457
  function str(v, name) {
4071
4458
  if (typeof v !== "string" || v.trim() === "") throw new CliError(`${name} 必填且为非空字符串`);
4072
4459
  return v.trim();
4073
4460
  }
4461
+ var SCOPES$1 = [
4462
+ "mine",
4463
+ "official",
4464
+ "shared",
4465
+ "all"
4466
+ ];
4467
+ function asScope(v) {
4468
+ if (typeof v === "string" && SCOPES$1.includes(v)) return v;
4469
+ throw new CliError(`scope 只能是 ${SCOPES$1.join(" / ")}:${String(v)}`);
4470
+ }
4471
+ function strList(v) {
4472
+ return Array.isArray(v) ? v.map(String).filter((s) => s.trim() !== "") : [];
4473
+ }
4474
+ function isRecord(v) {
4475
+ return v !== null && typeof v === "object" && !Array.isArray(v);
4476
+ }
4074
4477
  function asDoc(raw, scope, server) {
4075
4478
  if (raw === null || typeof raw !== "object") return null;
4076
4479
  const d = raw;
@@ -4082,8 +4485,10 @@ function asDoc(raw, scope, server) {
4082
4485
  visibility: typeof d["visibility"] === "string" ? d["visibility"] : scope === "official" ? "official" : "",
4083
4486
  tags: Array.isArray(d["tags"]) ? d["tags"].map(String) : [],
4084
4487
  scope,
4488
+ ...typeof d["team"] === "string" || d["team"] === null ? { team: d["team"] } : {},
4085
4489
  ...typeof d["archived"] === "boolean" ? { archived: d["archived"] } : {},
4086
4490
  ...typeof d["updatedAt"] === "number" ? { updatedAt: d["updatedAt"] } : {},
4491
+ ...typeof d["uses"] === "number" ? { uses: d["uses"] } : {},
4087
4492
  url: typeof d["url"] === "string" ? d["url"] : `${server}/d/${d["id"]}`
4088
4493
  };
4089
4494
  }
@@ -4102,152 +4507,345 @@ function buildTools(api) {
4102
4507
  const seen = new Set(mine.map((d) => d.id));
4103
4508
  return [...mine, ...[...official, ...shared].filter((d) => !seen.has(d.id) && seen.add(d.id))];
4104
4509
  };
4105
- return [
4106
- {
4107
- name: "list_docs",
4108
- description: "列出文档。scope:mine(我发布的,默认)/ official(官方知识库)/ shared(分享给我的)/ all。返回 id、标题、摘要、可见性、标签、URL。",
4109
- inputSchema: {
4110
- type: "object",
4111
- properties: {
4112
- scope: {
4113
- type: "string",
4114
- enum: [
4115
- "mine",
4116
- "official",
4117
- "shared",
4118
- "all"
4119
- ],
4120
- default: "mine"
4121
- },
4122
- includeArchived: {
4123
- type: "boolean",
4124
- default: false,
4125
- description: "仅 mine 生效:包含已归档"
4510
+ const loadTemplates = async () => {
4511
+ const [mine, shared, official] = await Promise.all([
4512
+ listOf("mine", false),
4513
+ listOf("shared", false),
4514
+ listOf("official", false)
4515
+ ]);
4516
+ const seen = /* @__PURE__ */ new Set();
4517
+ const docs = [
4518
+ ...mine,
4519
+ ...shared,
4520
+ ...official
4521
+ ].filter((d) => d.tags.includes("template") && !seen.has(d.id) && seen.add(d.id));
4522
+ const infos = await Promise.all(docs.map(async (d) => {
4523
+ const meta = parseTemplateMeta(await api.getText(`/d/${encodeURIComponent(d.id)}.md`));
4524
+ return {
4525
+ ...d,
4526
+ name: meta.name ?? d.id,
4527
+ description: meta.description ?? (d.excerpt || d.title),
4528
+ arguments: meta.arguments,
4529
+ body: meta.body
4530
+ };
4531
+ }));
4532
+ const names = /* @__PURE__ */ new Set();
4533
+ return infos.filter((t) => !names.has(t.name) && names.add(t.name));
4534
+ };
4535
+ return {
4536
+ tools: [
4537
+ {
4538
+ name: "list_docs",
4539
+ description: "列 / 搜文档,返回 id、标题、摘要、可见性、标签、URL。给 query 就按关键词过滤(标题 / 摘要 / 标签,空格分词、全部命中),不给就是纯列表。scope:mine(我发布的)/ official(官方知识库)/ shared(分享给我的 + 我所属团队的)/ all;不传时——搜(有 query)默认 all、列(无 query)默认 mine。没有全文检索,正文搜不到——要读正文用 read_doc。",
4540
+ inputSchema: {
4541
+ type: "object",
4542
+ properties: {
4543
+ query: {
4544
+ type: "string",
4545
+ description: "关键词,空格分词、全部命中;不给就是纯列表"
4546
+ },
4547
+ scope: {
4548
+ type: "string",
4549
+ enum: [...SCOPES$1]
4550
+ },
4551
+ limit: {
4552
+ type: "integer",
4553
+ minimum: 1,
4554
+ maximum: 200,
4555
+ description: "最多几条;不给时搜默认 20 条、纯列表不截断"
4556
+ },
4557
+ includeArchived: {
4558
+ type: "boolean",
4559
+ default: false,
4560
+ description: "仅 mine 生效:包含已归档"
4561
+ }
4562
+ }
4563
+ },
4564
+ run: async (args) => {
4565
+ const terms = (typeof args["query"] === "string" ? args["query"].trim() : "").toLowerCase().split(/\s+/).filter(Boolean);
4566
+ const scope = args["scope"] === void 0 ? terms.length === 0 ? "mine" : "all" : asScope(args["scope"]);
4567
+ const includeArchived = args["includeArchived"] === true;
4568
+ const docs = scope === "all" ? await listAll(includeArchived) : await listOf(scope, includeArchived);
4569
+ if (terms.length === 0) {
4570
+ const cap = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(200, args["limit"])) : docs.length;
4571
+ return JSON.stringify(docs.slice(0, cap), null, 2);
4126
4572
  }
4573
+ const hits = docs.filter((d) => {
4574
+ const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
4575
+ return terms.every((t) => hay.includes(t));
4576
+ });
4577
+ const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(200, args["limit"])) : 20;
4578
+ return JSON.stringify(hits.slice(0, limit), null, 2);
4127
4579
  }
4128
4580
  },
4129
- run: async (args) => {
4130
- const scope = typeof args["scope"] === "string" ? args["scope"] : "mine";
4131
- const includeArchived = args["includeArchived"] === true;
4132
- const docs = scope === "all" ? await listAll(includeArchived) : scope === "mine" || scope === "official" || scope === "shared" ? await listOf(scope, includeArchived) : (() => {
4133
- throw new CliError(`scope 只能是 mine / official / shared / all:${scope}`);
4134
- })();
4135
- return JSON.stringify(docs, null, 2);
4136
- }
4137
- },
4138
- {
4139
- name: "search_docs",
4140
- description: "按关键词搜文档(标题 / 摘要 / 标签,空格分词、全部命中)。范围是我能看到的全部文档。没有全文检索——要读正文用 read_doc。",
4141
- inputSchema: {
4142
- type: "object",
4143
- required: ["query"],
4144
- properties: {
4145
- query: { type: "string" },
4146
- limit: {
4147
- type: "integer",
4148
- minimum: 1,
4149
- maximum: 100,
4150
- default: 20
4581
+ {
4582
+ name: "read_doc",
4583
+ description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
4584
+ inputSchema: {
4585
+ type: "object",
4586
+ required: ["id"],
4587
+ properties: {
4588
+ id: { type: "string" },
4589
+ version: {
4590
+ type: "integer",
4591
+ minimum: 1,
4592
+ description: "版本号 seq,见 list_versions"
4593
+ }
4151
4594
  }
4595
+ },
4596
+ run: async (args) => {
4597
+ const id = encodeURIComponent(str(args["id"], "id"));
4598
+ const version = args["version"];
4599
+ if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
4600
+ return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
4152
4601
  }
4153
4602
  },
4154
- run: async (args) => {
4155
- const terms = str(args["query"], "query").toLowerCase().split(/\s+/).filter(Boolean);
4156
- const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(100, args["limit"])) : 20;
4157
- const hits = (await listAll(false)).filter((d) => {
4158
- const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
4159
- return terms.every((t) => hay.includes(t));
4160
- });
4161
- return JSON.stringify(hits.slice(0, limit), null, 2);
4162
- }
4163
- },
4164
- {
4165
- name: "read_doc",
4166
- description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
4167
- inputSchema: {
4168
- type: "object",
4169
- required: ["id"],
4170
- properties: {
4171
- id: { type: "string" },
4172
- version: {
4173
- type: "integer",
4174
- minimum: 1,
4175
- description: "版本号 seq,见 list_versions"
4603
+ {
4604
+ name: "list_templates",
4605
+ description: "列出文档模板(打了 template 标签的文档:我的 + 我所属团队的 + 官方)。每条带 name(同时是 MCP prompt 名)和 description(什么场景用)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
4606
+ inputSchema: {
4607
+ type: "object",
4608
+ properties: {}
4609
+ },
4610
+ run: async () => {
4611
+ const out = (await loadTemplates()).map(({ body: _body, ...rest }) => rest);
4612
+ return JSON.stringify(out, null, 2);
4613
+ }
4614
+ },
4615
+ {
4616
+ name: "get_syntax",
4617
+ description: "取这台实例的写作语法(markdown):内核语法、widget fence 与多态 CodeBlock(mermaid / diff / vega-lite…)的规则、已注册 widget 的清单与字段摘要。写含 fence / 组件 / 图表的文档前先调一次(不带参数)。要某几个组件的完整入参就带 widget=名字(多个用逗号分隔),拿到 dataSchema(JSON Schema)、sampleData 和可直接粘进文档的 fence 示例;标了 contentMediaType: text/markdown 的字符串字段可以写 markdown。",
4618
+ inputSchema: {
4619
+ type: "object",
4620
+ properties: { widget: {
4621
+ type: "string",
4622
+ description: "组件名,区分大小写;多个用逗号分隔。不给就返回整份语法说明"
4623
+ } }
4624
+ },
4625
+ run: async (args) => {
4626
+ const want = typeof args["widget"] === "string" ? args["widget"].split(",").map((s) => s.trim()).filter(Boolean) : [];
4627
+ if (want.length === 0) return api.getText("/api/syntax");
4628
+ const res = await api.getJson("/api/widgets");
4629
+ const out = [];
4630
+ const active = [];
4631
+ for (const raw of Array.isArray(res?.widgets) ? res.widgets : []) {
4632
+ if (raw === null || typeof raw !== "object") continue;
4633
+ const w = raw;
4634
+ if (w["status"] !== "active" || typeof w["name"] !== "string") continue;
4635
+ active.push(w["name"]);
4636
+ if (!want.includes(w["name"])) continue;
4637
+ out.push({
4638
+ name: w["name"],
4639
+ scope: typeof w["scope"] === "string" ? w["scope"] : "",
4640
+ version: typeof w["version"] === "string" ? w["version"] : "",
4641
+ description: typeof w["description"] === "string" ? w["description"] : null,
4642
+ dataSchema: w["dataSchema"] ?? null,
4643
+ sampleData: w["sampleData"] ?? null,
4644
+ fence: `\`\`\`widget:${w["name"]}\n${JSON.stringify(w["sampleData"] ?? {}, null, 2)}\n\`\`\``
4645
+ });
4176
4646
  }
4647
+ const missing = want.filter((n) => !out.some((w) => w.name === n));
4648
+ if (missing.length > 0) throw new CliError(`这台实例没有注册:${missing.join(" / ")}。可用的是:${active.join(" / ")}`);
4649
+ return JSON.stringify(out, null, 2);
4177
4650
  }
4178
4651
  },
4179
- run: async (args) => {
4180
- const id = encodeURIComponent(str(args["id"], "id"));
4181
- const version = args["version"];
4182
- if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
4183
- return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
4184
- }
4185
- },
4186
- {
4187
- name: "list_templates",
4188
- description: "列出文档模板(打了 template 标签的文档:官方模板 + 我的)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
4189
- inputSchema: {
4190
- type: "object",
4191
- properties: {}
4652
+ {
4653
+ name: "list_versions",
4654
+ description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
4655
+ inputSchema: {
4656
+ type: "object",
4657
+ required: ["id"],
4658
+ properties: { id: { type: "string" } }
4659
+ },
4660
+ run: async (args) => {
4661
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
4662
+ return JSON.stringify(res, null, 2);
4663
+ }
4192
4664
  },
4193
- run: async () => {
4194
- const docs = (await listAll(false)).filter((d) => d.tags.includes(TEMPLATE_TAG));
4195
- return JSON.stringify(docs, null, 2);
4196
- }
4197
- },
4198
- {
4199
- name: "list_versions",
4200
- description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
4201
- inputSchema: {
4202
- type: "object",
4203
- required: ["id"],
4204
- properties: { id: { type: "string" } }
4665
+ {
4666
+ name: "push_doc",
4667
+ description: "发布 markdown。正文二选一:path(本机入口 .md 的绝对路径,会递归收集图片 / 嵌套 md 等本地引用一起上传)或 content(markdown 字串,没有本地引用可收,标题取一级标题、否则传 title)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签 / 团队不传则保持原值);不带 id 新建,默认 private。响应里的 lint[] 列出会静默降级成普通代码块的 fence(unknown-widget / invalid-json / invalid-data,带行号或 blockId)——发布已成功,但非空就该按提示改稿再 push_doc 一次。",
4668
+ inputSchema: {
4669
+ type: "object",
4670
+ properties: {
4671
+ path: {
4672
+ type: "string",
4673
+ description: "入口 .md 的绝对路径;与 content 二选一"
4674
+ },
4675
+ content: {
4676
+ type: "string",
4677
+ description: "markdown 正文;与 path 二选一"
4678
+ },
4679
+ id: {
4680
+ type: "string",
4681
+ description: "要更新的文档 id"
4682
+ },
4683
+ title: { type: "string" },
4684
+ visibility: {
4685
+ type: "string",
4686
+ enum: [...VISIBILITIES]
4687
+ },
4688
+ tags: {
4689
+ type: "array",
4690
+ items: { type: "string" },
4691
+ description: "整体覆盖标签;不传则不动"
4692
+ },
4693
+ team: {
4694
+ type: "string",
4695
+ description: "进团队的团队 id(需是成员);空串移出团队;不传则不动"
4696
+ }
4697
+ }
4698
+ },
4699
+ run: async (args) => {
4700
+ const content = typeof args["content"] === "string" ? args["content"] : void 0;
4701
+ const hasPath = typeof args["path"] === "string" && args["path"].trim() !== "";
4702
+ if (content === void 0 === !hasPath) throw new CliError("path 与 content 必须且只能传一个");
4703
+ const tmp = content !== void 0 ? mkdtempSync(join(tmpdir(), "jdu-mcp-")) : null;
4704
+ const path = tmp ? join(tmp, "doc.md") : str(args["path"], "path");
4705
+ if (tmp && content !== void 0) writeFileSync(path, content, "utf8");
4706
+ const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
4707
+ try {
4708
+ const res = await pushDoc(api, path, {
4709
+ id: typeof args["id"] === "string" ? args["id"] : void 0,
4710
+ title: typeof args["title"] === "string" ? args["title"] : void 0,
4711
+ visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
4712
+ tag: tags,
4713
+ team: typeof args["team"] === "string" ? args["team"] : void 0
4714
+ });
4715
+ return JSON.stringify(res, null, 2);
4716
+ } finally {
4717
+ if (tmp) rmSync(tmp, {
4718
+ recursive: true,
4719
+ force: true
4720
+ });
4721
+ }
4722
+ }
4205
4723
  },
4206
- run: async (args) => {
4207
- const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
4208
- return JSON.stringify(res, null, 2);
4209
- }
4210
- },
4211
- {
4212
- name: "push_doc",
4213
- description: "发布本机的 markdown 文件(递归收集图片 / 嵌套 md 等本地引用并上传)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签不传则保持原值);不带 id 新建,默认 private。path 用绝对路径。",
4214
- inputSchema: {
4215
- type: "object",
4216
- required: ["path"],
4217
- properties: {
4218
- path: {
4219
- type: "string",
4220
- description: "入口 .md 的绝对路径"
4221
- },
4222
- id: {
4223
- type: "string",
4224
- description: "要更新的文档 id"
4225
- },
4226
- title: { type: "string" },
4227
- visibility: {
4228
- type: "string",
4229
- enum: [...VISIBILITIES]
4230
- },
4231
- tags: {
4232
- type: "array",
4233
- items: { type: "string" },
4234
- description: "整体覆盖标签;不传则不动"
4724
+ {
4725
+ name: "share_doc",
4726
+ description: "改文档的访问范围,不用重推正文:visibility(private / public)、team(团队 id;空串移出团队)、addReaders / removeReaders(定向读者的身份串,如邮箱 / 用户名)。私有文档谁能读 = 作者 ∪ 指定读者 ∪ 所选团队成员。",
4727
+ inputSchema: {
4728
+ type: "object",
4729
+ required: ["id"],
4730
+ properties: {
4731
+ id: { type: "string" },
4732
+ visibility: {
4733
+ type: "string",
4734
+ enum: [...VISIBILITIES]
4735
+ },
4736
+ team: {
4737
+ type: "string",
4738
+ description: "团队 id;空串移出团队"
4739
+ },
4740
+ addReaders: {
4741
+ type: "array",
4742
+ items: { type: "string" }
4743
+ },
4744
+ removeReaders: {
4745
+ type: "array",
4746
+ items: { type: "string" }
4747
+ }
4235
4748
  }
4749
+ },
4750
+ run: async (args) => {
4751
+ const id = encodeURIComponent(str(args["id"], "id"));
4752
+ const access = {};
4753
+ if (typeof args["visibility"] === "string") access["visibility"] = args["visibility"];
4754
+ if (typeof args["team"] === "string") access["team"] = args["team"];
4755
+ const add = strList(args["addReaders"]);
4756
+ const remove = strList(args["removeReaders"]);
4757
+ if (Object.keys(access).length === 0 && add.length === 0 && remove.length === 0) throw new CliError("share_doc 需要 visibility / team / addReaders / removeReaders 之一");
4758
+ const out = { id: args["id"] };
4759
+ if (Object.keys(access).length > 0) Object.assign(out, await api.putJson(`/api/docs/${id}/visibility`, access));
4760
+ if (add.length > 0 || remove.length > 0) Object.assign(out, await api.putJson(`/api/docs/${id}/readers`, {
4761
+ add,
4762
+ remove
4763
+ }));
4764
+ return JSON.stringify(out, null, 2);
4236
4765
  }
4237
4766
  },
4238
- run: async (args) => {
4239
- const path = str(args["path"], "path");
4240
- const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
4241
- const res = await pushDoc(api, path, {
4242
- id: typeof args["id"] === "string" ? args["id"] : void 0,
4243
- title: typeof args["title"] === "string" ? args["title"] : void 0,
4244
- visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
4245
- tag: tags
4246
- });
4247
- return JSON.stringify(res, null, 2);
4767
+ {
4768
+ name: "list_comments",
4769
+ description: "列出文档的评论线程(读者在阅读页划词 / 块把手 / 文末评论区留下的)。默认只列未处理的;每条带 blockId(对应正文哪一块,null = 评论整篇文档)、selection.quote 引文、正文、attachments(图片附件的 sha256,取图 GET /blob/<sha256>)、作者、replies。处理流程:按 blockId 找到段落改稿 → push_doc 新版本 → reply_comment 回一句并 resolved=true 关掉。",
4770
+ inputSchema: {
4771
+ type: "object",
4772
+ required: ["id"],
4773
+ properties: {
4774
+ id: { type: "string" },
4775
+ includeResolved: {
4776
+ type: "boolean",
4777
+ default: false
4778
+ }
4779
+ }
4780
+ },
4781
+ run: async (args) => {
4782
+ const id = encodeURIComponent(str(args["id"], "id"));
4783
+ const res = await api.getJson(`/api/docs/${id}/comments`);
4784
+ const all = (Array.isArray(res?.comments) ? res.comments : []).filter(isRecord);
4785
+ const includeResolved = args["includeResolved"] === true;
4786
+ const threads = all.filter((c) => c["parentId"] === null && (includeResolved || c["resolved"] !== true)).map((root) => ({
4787
+ ...root,
4788
+ replies: all.filter((c) => c["parentId"] === root["id"])
4789
+ }));
4790
+ return JSON.stringify(threads, null, 2);
4791
+ }
4792
+ },
4793
+ {
4794
+ name: "reply_comment",
4795
+ description: "回复评论线程并/或改它的处理状态。body 回一句(以 agent 身份发出,authorType=ai,读者页里会标出来);resolved=true 标记已处理、false 重新打开(仅文档 owner)。改完稿发了新版本后,通常一次调用把两件事一起做掉。",
4796
+ inputSchema: {
4797
+ type: "object",
4798
+ required: ["threadId"],
4799
+ properties: {
4800
+ threadId: { type: "string" },
4801
+ body: {
4802
+ type: "string",
4803
+ description: "回复正文;只想改状态就不传"
4804
+ },
4805
+ resolved: {
4806
+ type: "boolean",
4807
+ description: "true 关掉线程、false 重新打开;不传就只回复不改状态"
4808
+ }
4809
+ }
4810
+ },
4811
+ run: async (args) => {
4812
+ const tid = encodeURIComponent(str(args["threadId"], "threadId"));
4813
+ const body = typeof args["body"] === "string" && args["body"].trim() !== "" ? args["body"].trim() : null;
4814
+ const resolved = typeof args["resolved"] === "boolean" ? args["resolved"] : null;
4815
+ if (body === null && resolved === null) throw new CliError("reply_comment 需要 body(回复)或 resolved(改状态)之一");
4816
+ const out = { threadId: args["threadId"] };
4817
+ if (body !== null) out["reply"] = await api.postJson(`/api/comments/${tid}/reply`, {
4818
+ body,
4819
+ authorType: "ai"
4820
+ });
4821
+ if (resolved !== null) out["resolve"] = await api.postJson(`/api/comments/${tid}/resolve`, { resolved });
4822
+ return JSON.stringify(out, null, 2);
4823
+ }
4824
+ },
4825
+ {
4826
+ name: "archive_doc",
4827
+ description: "归档文档:从列表撤下、读者打开 404(owner 自己仍可读),版本与评论都保留;undo=true 取消归档。这就是「撤回发布」——没有比它更重的删除给 agent 用。",
4828
+ inputSchema: {
4829
+ type: "object",
4830
+ required: ["id"],
4831
+ properties: {
4832
+ id: { type: "string" },
4833
+ undo: {
4834
+ type: "boolean",
4835
+ default: false,
4836
+ description: "取消归档"
4837
+ }
4838
+ }
4839
+ },
4840
+ run: async (args) => {
4841
+ const id = encodeURIComponent(str(args["id"], "id"));
4842
+ const res = await api.postJson(`/api/docs/${id}/archive`, { archived: args["undo"] !== true });
4843
+ return JSON.stringify(res, null, 2);
4844
+ }
4248
4845
  }
4249
- }
4250
- ];
4846
+ ],
4847
+ loadTemplates
4848
+ };
4251
4849
  }
4252
4850
  function write(msg) {
4253
4851
  process.stdout.write(`${JSON.stringify(msg)}\n`);
@@ -4263,7 +4861,7 @@ function rpcError(id, code, message) {
4263
4861
  });
4264
4862
  }
4265
4863
  async function runMcp(api) {
4266
- const tools = buildTools(api);
4864
+ const { tools, loadTemplates } = buildTools(api);
4267
4865
  const byName = new Map(tools.map((t) => [t.name, t]));
4268
4866
  const handle = async (req) => {
4269
4867
  const { id, method } = req;
@@ -4276,18 +4874,81 @@ async function runMcp(api) {
4276
4874
  switch (method) {
4277
4875
  case "initialize": {
4278
4876
  const asked = typeof params["protocolVersion"] === "string" ? params["protocolVersion"] : "";
4877
+ const protocolVersion = SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0];
4878
+ let instructions = INSTRUCTIONS;
4879
+ try {
4880
+ const templates = await loadTemplates();
4881
+ if (templates.length > 0) instructions += `可用模板(同名 prompt 可直接用):${templates.map((t) => `${t.name}——${t.description}`).join(";")}。`;
4882
+ } catch {}
4279
4883
  write({
4280
4884
  jsonrpc: "2.0",
4281
4885
  id,
4282
4886
  result: {
4283
- protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
4284
- capabilities: { tools: {} },
4887
+ protocolVersion,
4888
+ capabilities: {
4889
+ tools: {},
4890
+ prompts: {}
4891
+ },
4285
4892
  serverInfo: SERVER_INFO,
4286
- instructions: INSTRUCTIONS
4893
+ instructions
4287
4894
  }
4288
4895
  });
4289
4896
  return;
4290
4897
  }
4898
+ case "prompts/list":
4899
+ try {
4900
+ write({
4901
+ jsonrpc: "2.0",
4902
+ id,
4903
+ result: { prompts: (await loadTemplates()).map((t) => ({
4904
+ name: t.name,
4905
+ title: t.title,
4906
+ description: t.description,
4907
+ ...t.arguments.length > 0 ? { arguments: t.arguments } : {}
4908
+ })) }
4909
+ });
4910
+ } catch (err) {
4911
+ rpcError(id, -32603, `模板列表取不到:${err instanceof CliError ? err.message : String(err)}`);
4912
+ }
4913
+ return;
4914
+ case "prompts/get": {
4915
+ const name = typeof params["name"] === "string" ? params["name"] : "";
4916
+ try {
4917
+ const t = (await loadTemplates()).find((x) => x.name === name);
4918
+ if (!t) {
4919
+ rpcError(id, -32602, `未知 prompt:${name}`);
4920
+ return;
4921
+ }
4922
+ const given = isRecord(params["arguments"]) ? params["arguments"] : {};
4923
+ const missing = t.arguments.filter((a) => a.required && (typeof given[a.name] !== "string" || given[a.name] === ""));
4924
+ if (missing.length > 0) {
4925
+ rpcError(id, -32602, `缺少必填参数:${missing.map((a) => a.name).join(", ")}`);
4926
+ return;
4927
+ }
4928
+ api.postJson(`/api/docs/${encodeURIComponent(t.id)}/used`, {}).catch(() => void 0);
4929
+ const body = t.arguments.length > 0 ? fillTemplate(t.body, t.arguments, given) : t.body;
4930
+ write({
4931
+ jsonrpc: "2.0",
4932
+ id,
4933
+ result: {
4934
+ description: t.description,
4935
+ messages: [{
4936
+ role: "user",
4937
+ content: {
4938
+ type: "text",
4939
+ text: promptText({
4940
+ ...t,
4941
+ body
4942
+ })
4943
+ }
4944
+ }]
4945
+ }
4946
+ });
4947
+ } catch (err) {
4948
+ rpcError(id, -32603, `模板取不到:${err instanceof CliError ? err.message : String(err)}`);
4949
+ }
4950
+ return;
4951
+ }
4291
4952
  case "ping":
4292
4953
  write({
4293
4954
  jsonrpc: "2.0",
@@ -4392,6 +5053,7 @@ async function readWidgetJson(dir) {
4392
5053
  name,
4393
5054
  scope,
4394
5055
  version,
5056
+ ...typeof obj.description === "string" && obj.description.trim() !== "" ? { description: obj.description.trim() } : {},
4395
5057
  dataSchema: obj.dataSchema ?? null,
4396
5058
  sampleData: obj.sampleData ?? null
4397
5059
  };
@@ -4486,6 +5148,9 @@ async function pushWidget(api, dirArg) {
4486
5148
  process.stdout.write(`${meta.scope}/${meta.name}@${meta.version}${src}${where}\n`);
4487
5149
  }
4488
5150
  //#endregion
5151
+ //#region ../docs/design-system/tokens.css?raw
5152
+ var tokens_default = "/* ============================================================\n 简牍 Jiandu · Design Tokens (v3 · 2026-09)\n 这是设计系统唯一的取值来源。规则、对比度依据、组件规格见同目录 README.md。\n\n - 双主题 = 同一套语义 token 换值;结构、间距、字号、圆角逐像素一致。\n - 主题真源是 <html class=\"dark\">(跨宿主契约:widget、dsh 都读它);\n 暗色块同时匹配 [data-t=\"dark\"],供设计画布与局部反色容器使用。\n 组件代码里不要写 .dark / [data-t] / prefers-color-scheme 分支,只走 token。\n - accent 家族用 oklch 定义(同 L 同 C 只变 hue),注释里是 oklch,值是出码 hex。\n - 每个文字色都在它实际会落到的最深一档表面(--s-4)上过 WCAG AA 4.5:1。\n 改任何一个值之前先重跑对比度(见 README「校验」)。\n ============================================================ */\n\n/* ---------- 与主题无关的量纲 ---------- */\n:root {\n --f-sans: \"Instrument Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n \"PingFang SC\", \"HarmonyOS Sans SC\", \"Noto Sans CJK SC\", \"Microsoft YaHei\", sans-serif;\n --f-serif: \"Instrument Serif\", \"Iowan Old Style\", Palatino, Georgia, serif;\n --f-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, monospace;\n\n /* 字号阶 · 1.20 主比例,UI 段加密到约 1.07 以保住 13/14/15 三档 */\n --t-micro: 11px; --t-cap: 12px; --t-sm: 13px; --t-ui: 14px;\n --t-body: 15px; --t-lg: 16px; --t-h5: 18px; --t-h4: 20px;\n --t-h3: 24px; --t-h2: 30px; --t-h1: 38px;\n --t-d3: 48px; --t-d2: 60px; --t-d1: 76px;\n\n --lh-tight: 1.05; --lh-head: 1.25; --lh-ui: 1.4; --lh-body: 1.65; --lh-loose: 1.8;\n\n /* 间距阶 · 4 基准,2/6 只用于图标与描边补偿 */\n --s1: 2px; --s2: 4px; --s3: 6px; --s4: 8px; --s5: 12px; --s6: 16px;\n --s7: 20px; --s8: 24px; --s9: 32px; --s10: 40px; --s11: 48px;\n --s12: 64px; --s13: 80px; --s14: 96px; --s15: 128px;\n\n /* 圆角 · 四档,同心圆角规则:外层 = 内层 + padding */\n --r-xs: 4px; --r-sm: 6px; --r-md: 10px; --r-lg: 14px; --r-full: 999px;\n\n --topbar-h: 56px;\n --sidebar-w: 264px;\n --w-content: 1160px;\n --w-reader: 1600px;\n --w-prose: clamp(46rem, 60vw, 60rem);\n --w-marketing: 1200px;\n\n --dur: 140ms;\n --ease: cubic-bezier(.32, .72, 0, 1);\n\n /* 实心按钮顶部那道高光。两套主题同值(它压在 accent / danger 填充上,不压在页面底色上)。 */\n --gloss: inset 0 1px 0 rgba(255, 255, 255, .14);\n /* 对话框背后的遮罩。两套主题同值:亮色下也要压暗,不然弹层浮不起来。 */\n --scrim: rgba(9, 10, 12, .44);\n}\n\n/* ---------- 亮色 ---------- */\n:root, [data-t=\"light\"] {\n color-scheme: light;\n --bg: #FBFBFC;\n --s-1: #FFFFFF;\n --s-2: #F5F6F8;\n --s-3: #EDEFF2;\n --s-4: #E3E6EA;\n --line: #E4E7EB;\n --line-strong: #D6DAE0;\n --line-loud: #C3C9D1;\n --hairline: transparent;\n\n --fg: #101317;\n --fg-muted: #565E68;\n --fg-subtle: #606871;\n\n --accent: #057558;\n --accent-hover: #065E47;\n --accent-quiet: #E3F7EE;\n --accent-ring: #B5DFCE;\n --on-accent: #FFFFFF;\n --on-danger: #FFFFFF;\n\n --amber: #825B0C;\n --amber-quiet: #FAF0E0;\n --danger: #C02B2F;\n --danger-hover: #A21921;\n --danger-quiet: #FFEBE9;\n --danger-ring: #F4C8C4;\n --success: #1B763A;\n --success-quiet: #E6F6E9;\n --warn: #885716;\n --warn-quiet: #FAF0E0;\n\n /* 输入框的内凹。向内凹是「可输入」的信号(按钮向外抬),组件里只引这个 token,\n 不写 .dark 分支 —— 暗色那份在下面的暗色块里换值。 */\n --inset-field: inset 0 1px 2px rgba(16,19,23,.04);\n /* 弹层表面:亮色用卡片色 + 控件描边;暗色整体抬一档(见暗色块) */\n --pop-bg: #FFFFFF;\n --pop-line: #D6DAE0;\n\n /* 头像色相散列。**唯一绕开「竹青只表示可交互」的地方**:它区分人,不表达状态。\n 放进 token 而不是留在组件里,是为了让组件代码保持零 hex、零 .dark 分支。 */\n --av-1-bg: #E3F7EE; --av-1-fg: #045A43;\n --av-2-bg: #E5EFFC; --av-2-fg: #1B4C8F;\n --av-3-bg: #FAF0E0; --av-3-fg: #6D4B08;\n --av-4-bg: #F3E9FB; --av-4-fg: #5A2B8C;\n\n --sh-1: 0 1px 2px rgba(16,19,23,.06), 0 1px 1px rgba(16,19,23,.04);\n --sh-2: 0 4px 12px -2px rgba(16,19,23,.08), 0 2px 4px -1px rgba(16,19,23,.05);\n --sh-3: 0 16px 32px -8px rgba(16,19,23,.14), 0 4px 8px -2px rgba(16,19,23,.06);\n --press: inset 0 2px 4px rgba(16,19,23,.10);\n\n --code-key: #A2145E; --code-str: #0A5B45; --code-num: #1F4FA8;\n --code-com: #7A828C; --code-fn: #6B3FBF; --code-tag: #0F6B36;\n\n /* 图表系列色(Chart widget)。六档固定顺序按槽位分配、不循环,顺序本身就是色盲安全机制:\n 相邻两档在 protan / deutan 模拟下 ΔE ≥ 8.5,全部对表面 ≥ 3:1(dataviz 校验器过的,改值先重跑)。\n 刻意不用 --accent:竹青只表示可交互,数据不是控件。第一档蓝、第二档琥珀(与 --amber 同族但抬亮到标记档)。 */\n --chart-1: #3B6FD4; --chart-2: #A6720C; --chart-3: #1A8F7A;\n --chart-4: #7C55D9; --chart-5: #D2447F; --chart-6: #5E8C2E;\n}\n\n/* ---------- 暗色 ---------- */\n.dark, [data-t=\"dark\"] {\n color-scheme: dark;\n --bg: #090A0C;\n --s-1: #121519;\n --s-2: #1A1E23;\n --s-3: #23282E;\n --s-4: #2D333A;\n --line: #242830;\n --line-strong: #2E333B;\n --line-loud: #3A4048;\n /* 暗色里阴影几乎不可见,层次靠这道顶部高光边 */\n --hairline: rgba(255,255,255,.055);\n\n --fg: #F5F6F8;\n --fg-muted: #B4BBC4;\n --fg-subtle: #939BA5;\n\n --accent: #48C49C;\n --accent-hover: #6DD9B3;\n --accent-quiet: #143B2E;\n --accent-ring: #256550;\n --on-accent: #05130F;\n --on-danger: #200A08;\n\n --amber: #D6A044;\n --amber-quiet: #412F13;\n --danger: #FE7871;\n --danger-hover: #FF8B83;\n --danger-quiet: #492826;\n --danger-ring: #7D4743;\n --success: #68CA80;\n --success-quiet: #1F3A25;\n --warn: #F7AC55;\n --warn-quiet: #412F13;\n\n /* 暗色里 .04 的内凹完全看不见,要加深到 .35 才成立 */\n --inset-field: inset 0 1px 2px rgba(0,0,0,.35);\n --pop-bg: #1A1E23;\n --pop-line: #3A4048;\n\n --av-1-bg: #143B2E; --av-1-fg: #7FDCC0;\n --av-2-bg: #15294A; --av-2-fg: #9CC3F5;\n --av-3-bg: #412F13; --av-3-fg: #E0B96F;\n --av-4-bg: #2E2043; --av-4-fg: #C4A6FF;\n\n --sh-1: 0 1px 2px rgba(0,0,0,.5);\n --sh-2: 0 4px 14px -2px rgba(0,0,0,.6), 0 1px 3px rgba(0,0,0,.4);\n --sh-3: 0 20px 40px -10px rgba(0,0,0,.7), 0 4px 10px rgba(0,0,0,.45);\n --press: inset 0 2px 4px rgba(0,0,0,.34);\n\n --code-key: #FF9CB4; --code-str: #7FDCC0; --code-num: #8FBCFF;\n --code-com: #7C858F; --code-fn: #C4A6FF; --code-tag: #7EE0A0;\n\n /* 同六个色相为暗表面重新取档(OKLCH L 0.48–0.67),不是亮色的直接反转 */\n --chart-1: #5A8BE0; --chart-2: #B8842A; --chart-3: #2FA48C;\n --chart-4: #9878E8; --chart-5: #D45A8E; --chart-6: #7DA34A;\n}\n\n/* ============================================================\n 旧 token 名的别名(过渡层,下个大版本删)\n\n 现在只剩正文排版(viewer/src/reader.css,自 base.css 原样迁入)还在读它们。\n official widget 已全部改走 theme.css 的语义 utility(bg-card / text-muted-foreground …),\n `jdu widget create` 的模板同步换成短名;这里把旧名指向 v3 的新值,reader.css 一行不改就跟着换色板。\n\n 新代码只用短名。改动这一段前先 grep 一遍 `--jiandu-`,确认没人还在读它。\n ============================================================ */\n:root, [data-t=\"light\"], .dark, [data-t=\"dark\"] {\n --jiandu-font-sans: var(--f-sans);\n --jiandu-font-mono: var(--f-mono);\n\n --jiandu-bg: var(--bg);\n --jiandu-bg-subtle: var(--s-2);\n --jiandu-bg-code: var(--s-2);\n --jiandu-bg-inline-code: var(--s-3);\n --jiandu-fg: var(--fg);\n --jiandu-fg-muted: var(--fg-muted);\n --jiandu-fg-subtle: var(--fg-subtle);\n --jiandu-border: var(--line-strong);\n --jiandu-border-muted: var(--line);\n\n --jiandu-link: var(--accent);\n --jiandu-link-hover: var(--accent-hover);\n --jiandu-accent: var(--accent);\n --jiandu-accent-subtle: var(--accent-quiet);\n --jiandu-info: var(--accent);\n --jiandu-info-subtle: var(--accent-quiet);\n --jiandu-success: var(--success);\n --jiandu-success-subtle: var(--success-quiet);\n --jiandu-warning: var(--warn);\n --jiandu-warning-subtle: var(--warn-quiet);\n --jiandu-danger: var(--danger);\n --jiandu-danger-subtle: var(--danger-quiet);\n --jiandu-selection: var(--accent-quiet);\n\n --jiandu-skeleton-base: var(--s-2);\n --jiandu-skeleton-shine: var(--s-3);\n --jiandu-radius: var(--r-md);\n --jiandu-radius-sm: var(--r-sm);\n /* 阅读页正文随视口流体扩展,中文正文保持约 44–58 字/行的可读范围。 */\n --jiandu-doc-width: var(--w-prose);\n\n /* 代码高亮:v2 分了 12 档,v3 的 --code-* 只有 6 档,按语义就近合并 */\n --jiandu-hl-keyword: var(--code-key);\n --jiandu-hl-entity: var(--code-fn);\n --jiandu-hl-string: var(--code-str);\n --jiandu-hl-comment: var(--code-com);\n --jiandu-hl-constant: var(--code-num);\n --jiandu-hl-section: var(--code-num);\n --jiandu-hl-variable: var(--amber);\n --jiandu-hl-tag: var(--code-tag);\n --jiandu-hl-addition-fg: var(--success);\n --jiandu-hl-addition-bg: var(--success-quiet);\n --jiandu-hl-deletion-fg: var(--danger);\n --jiandu-hl-deletion-bg: var(--danger-quiet);\n}\n";
5153
+ //#endregion
4489
5154
  //#region src/widget-dev.ts
4490
5155
  var ARTIFACTS = [
4491
5156
  "index.js",
@@ -4528,19 +5193,28 @@ function readMeta(dir, dist) {
4528
5193
  version: ""
4529
5194
  };
4530
5195
  }
4531
- /** 线上 base.css 的地址:抓首页 HTML 里的 `/_v/base.<hash>.css`。拿不到返回 null,页面用兜底样式。 */
5196
+ /**
5197
+ * 线上正文样式表的地址:读 `/_v/manifest.json`(viewer 的产物清单,本来就在那条静态路由下)。
5198
+ * 早先是抓首页 HTML 里的 `/_v/base.<hash>.css`,但站点页换成 React 直出后首页只链 app.css,
5199
+ * 而 widget 预览要的是**正文**那套排版与 token。清单里按名字取,页面怎么改都不影响。
5200
+ * 拿不到返回 null,预览页用内置兜底样式。
5201
+ */
4532
5202
  async function siteBaseCss(server) {
4533
5203
  if (!server) return null;
4534
5204
  try {
4535
5205
  const ctrl = new AbortController();
4536
5206
  const t = setTimeout(() => ctrl.abort(), 2e3);
4537
- const html = await (await fetch(`${server}/`, {
5207
+ const res = await fetch(`${server}/_v/manifest.json`, {
4538
5208
  signal: ctrl.signal,
4539
5209
  redirect: "follow"
4540
- })).text();
5210
+ });
4541
5211
  clearTimeout(t);
4542
- const m = /\/_v\/base\.[a-f0-9]+\.css/.exec(html);
4543
- return m ? `${server}${m[0]}` : null;
5212
+ if (!res.ok) return null;
5213
+ const manifest = await res.json();
5214
+ if (typeof manifest !== "object" || manifest === null) return null;
5215
+ const m = manifest;
5216
+ const file = m["reader.css"] ?? m["app.css"];
5217
+ return typeof file === "string" ? `${server}/_v/${file}` : null;
4544
5218
  } catch {
4545
5219
  return null;
4546
5220
  }
@@ -4553,23 +5227,25 @@ function page(meta, baseCss, hasCss, mtime) {
4553
5227
  <meta charset="utf-8">
4554
5228
  <meta name="viewport" content="width=device-width, initial-scale=1">
4555
5229
  <title>${esc(meta.name)} · jdu widget dev</title>
5230
+ <style>
5231
+ /* 设计系统 tokens(构建期内联的 docs/design-system/tokens.css):widget 只读 token、不带兜底色,
5232
+ 预览页必须自己给一份;连上 server 时线上正文样式表再叠在后面 */
5233
+ ${tokens_default}
5234
+ </style>
4556
5235
  ${baseCss ? `<link rel="stylesheet" href="${esc(baseCss)}">` : ""}
4557
5236
  ${hasCss ? `<link rel="stylesheet" href="/index.css?t=${mtime}">` : ""}
4558
5237
  <style>
4559
- /* 兜底 token:连不上 jiandu server 时也有亮 / 暗两套 */
4560
- :root { --jiandu-bg: #fff; --jiandu-bg-subtle: #f6f8fa; --jiandu-fg: #1f2328; --jiandu-fg-muted: #59636e; --jiandu-border: #d0d7de; --jiandu-font-sans: system-ui, sans-serif; --jiandu-font-mono: ui-monospace, monospace; }
4561
- .dark { --jiandu-bg: #0d1117; --jiandu-bg-subtle: #161b22; --jiandu-fg: #e6edf3; --jiandu-fg-muted: #9198a1; --jiandu-border: #3d444d; }
4562
- html { background: var(--jiandu-bg); color: var(--jiandu-fg); font-family: var(--jiandu-font-sans); }
5238
+ html { background: var(--bg); color: var(--fg); font-family: var(--f-sans); }
4563
5239
  body { margin: 0; }
4564
- .dev-bar { display: flex; gap: 1rem; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--jiandu-border); font-size: 13px; color: var(--jiandu-fg-muted); }
4565
- .dev-bar b { color: var(--jiandu-fg); }
4566
- .dev-bar button { margin-left: auto; padding: 0.25rem 0.6rem; border: 1px solid var(--jiandu-border); border-radius: 6px; background: var(--jiandu-bg-subtle); color: inherit; cursor: pointer; }
5240
+ .dev-bar { display: flex; gap: 1rem; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--line); font-size: var(--t-sm); color: var(--fg-muted); }
5241
+ .dev-bar b { color: var(--fg); }
5242
+ .dev-bar button { margin-left: auto; height: 26px; padding: 0 var(--s4); border: 1px solid var(--line-strong); border-radius: var(--r-sm); background: var(--s-1); color: var(--fg); font: 550 var(--t-sm) var(--f-sans); cursor: pointer; }
4567
5243
  .dev-main { max-width: 760px; margin: 0 auto; padding: 1.5rem 1rem; }
4568
- .dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--jiandu-border); border-radius: 8px; }
4569
- details { margin-top: 1.5rem; font-size: 13px; color: var(--jiandu-fg-muted); }
4570
- textarea { width: 100%; min-height: 8rem; margin-top: 0.5rem; padding: 0.5rem; box-sizing: border-box; border: 1px solid var(--jiandu-border); border-radius: 6px; background: var(--jiandu-bg-subtle); color: var(--jiandu-fg); font: 12px/1.5 var(--jiandu-font-mono); }
4571
- .dev-err { color: #d1242f; white-space: pre-wrap; font-family: var(--jiandu-font-mono); font-size: 12px; }
4572
- .dev-degraded { padding: 0.75rem; border-radius: 6px; background: var(--jiandu-bg-subtle); font-family: var(--jiandu-font-mono); font-size: 12px; white-space: pre-wrap; }
5244
+ .dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--line-strong); border-radius: var(--r-md); }
5245
+ details { margin-top: 1.5rem; font-size: var(--t-sm); color: var(--fg-muted); }
5246
+ textarea { width: 100%; min-height: 8rem; margin-top: 0.5rem; padding: 0.5rem; box-sizing: border-box; border: 1px solid var(--line-strong); border-radius: var(--r-sm); background: var(--s-1); color: var(--fg); font: 12px/1.5 var(--f-mono); }
5247
+ .dev-err { color: var(--danger); white-space: pre-wrap; font-family: var(--f-mono); font-size: 12px; }
5248
+ .dev-degraded { padding: 0.75rem; border-radius: var(--r-sm); background: var(--s-2); font-family: var(--f-mono); font-size: 12px; white-space: pre-wrap; }
4573
5249
  </style>
4574
5250
  </head>
4575
5251
  <body>
@@ -4590,11 +5266,11 @@ $('dark').addEventListener('click', () => { document.documentElement.classList.t
4590
5266
 
4591
5267
  // 与 viewer loader 同一条链路:import → new → mount(el, ctx);抛错就按线上的降级形态显示原始 fence
4592
5268
  let meta = {}, inst = null, W = null, data = {};
4593
- const ctx = (d) => ({ data: d, loading: false, blockId: 'b0-dev00000', source: JSON.stringify(d, null, 2), language: 'widget_component:' + (meta.name ?? '') });
5269
+ const ctx = (d) => ({ data: d, loading: false, blockId: 'b0-dev00000', source: JSON.stringify(d, null, 2), language: 'widget:' + (meta.name ?? '') });
4594
5270
  function degrade(reason) {
4595
5271
  const pre = document.createElement('pre');
4596
5272
  pre.className = 'dev-degraded';
4597
- pre.textContent = '[降级为代码块] ' + reason + '\\n\\n\`\`\`widget_component:' + (meta.name ?? '') + '\\n' + JSON.stringify(data, null, 2) + '\\n\`\`\`';
5273
+ pre.textContent = '[降级为代码块] ' + reason + '\\n\\n\`\`\`widget:' + (meta.name ?? '') + '\\n' + JSON.stringify(data, null, 2) + '\\n\`\`\`';
4598
5274
  island.replaceChildren(pre);
4599
5275
  status.textContent = '挂载失败';
4600
5276
  }
@@ -4732,7 +5408,7 @@ export interface WidgetContext<D = unknown> {
4732
5408
  blockId: string;
4733
5409
  /** 原始 fence 正文 */
4734
5410
  source: string;
4735
- /** 原始代码块语言标识,如 \`widget_component:StarRating\` */
5411
+ /** 原始代码块语言标识,如 \`widget:StarRating\` */
4736
5412
  language: string;
4737
5413
  /**
4738
5414
  * 嵌套 markdown:dataSchema 里标了 \`contentMediaType: "text/markdown"\` 的字符串字段,宿主渲成已 sanitize 的 HTML
@@ -4761,7 +5437,7 @@ export interface WidgetClass<D = unknown> {
4761
5437
  var TYPES_TS = (c) => `// fence 里的 data 长什么样,这里是唯一事实:\`npm run build\` 会把 Data 生成为 widget.json 的 dataSchema
4762
5438
  // (字段上的 JSDoc → description,@default → default)。文档里的 data 不合法时平台会把 fence 降级成代码块。
4763
5439
  //
4764
- // \`\`\`widget_component:${c.name}
5440
+ // \`\`\`widget:${c.name}
4765
5441
  // { "title": "你好,简牍", "count": 3 }
4766
5442
  // \`\`\`
4767
5443
  //
@@ -4777,26 +5453,36 @@ export interface Data {
4777
5453
  count?: number;
4778
5454
  }
4779
5455
  `;
4780
- var STYLE_CSS = (c) => `/* 颜色一律走 --jiandu-* token(带兜底值),亮 / 暗主题跟宿主一起变;暗色用 .dark 选择器,不要 prefers-color-scheme */
5456
+ var STYLE_CSS = (c) => `/* 颜色、字号、圆角一律走宿主的设计 token(docs/design-system/tokens.css),亮 / 暗主题跟宿主一起变;
5457
+ 不写 hex,不写 .dark 分支,不用 prefers-color-scheme。常用的:
5458
+ 表面 --bg --s-1 --s-2 --s-3 --s-4 · 描边 --line --line-strong --line-loud · 文字 --fg --fg-muted --fg-subtle
5459
+ 强调 --accent --accent-quiet --accent-ring --on-accent · 语义 --success --warn --danger(各配 -quiet)
5460
+ 字号 --t-sm(13) --t-ui(14) --t-body(15) · 间距 --s4(8) --s5(12) --s6(16) · 圆角 --r-sm(6) --r-md(10) */
4781
5461
  .w-${c.kebab} {
4782
5462
  display: inline-flex;
4783
5463
  align-items: center;
4784
- gap: 0.5rem;
4785
- padding: 0.5rem 0.75rem;
4786
- border: 1px solid var(--jiandu-border, #d0d7de);
4787
- border-radius: 8px;
4788
- background: var(--jiandu-bg-subtle, #f6f8fa);
4789
- color: var(--jiandu-fg, #1f2328);
4790
- font: 14px/1.4 var(--jiandu-font-sans, system-ui, sans-serif);
5464
+ gap: var(--s4);
5465
+ padding: var(--s4) var(--s5);
5466
+ border: 1px solid var(--line);
5467
+ border-radius: var(--r-md);
5468
+ background: var(--s-2);
5469
+ color: var(--fg);
5470
+ font: var(--t-ui) / 1.4 var(--f-sans);
4791
5471
  }
4792
5472
  .w-${c.kebab} button {
4793
- padding: 0 0.5rem;
4794
- border: 1px solid var(--jiandu-border, #d0d7de);
4795
- border-radius: 6px;
4796
- background: var(--jiandu-bg, #fff);
5473
+ height: 26px;
5474
+ padding: 0 var(--s4);
5475
+ border: 1px solid var(--line-strong);
5476
+ border-radius: var(--r-sm);
5477
+ background: var(--s-1);
4797
5478
  color: inherit;
5479
+ font: 550 var(--t-sm) var(--f-sans);
4798
5480
  cursor: pointer;
4799
5481
  }
5482
+ .w-${c.kebab} button:hover {
5483
+ background: var(--s-2);
5484
+ border-color: var(--line-loud);
5485
+ }
4800
5486
  `;
4801
5487
  var VANILLA_INDEX = (c) => `import type { WidgetContext } from './_base/contract.js';
4802
5488
  import type { Data } from './types.js';
@@ -5109,6 +5795,7 @@ var WIDGET_JSON = (c) => `${JSON.stringify({
5109
5795
  name: c.name,
5110
5796
  scope: "personal",
5111
5797
  version: "1.0.0",
5798
+ description: "一句话说清什么场景用这个组件——agent 靠它决定选不选",
5112
5799
  dataSchema: {
5113
5800
  type: "object",
5114
5801
  required: ["title"],
@@ -5143,7 +5830,7 @@ npm run push # = build + jdu widget push dist
5143
5830
  然后在任何一篇文档里:
5144
5831
 
5145
5832
  \`\`\`\`markdown
5146
- \`\`\`widget_component:${c.name}
5833
+ \`\`\`widget:${c.name}
5147
5834
  { "title": "你好,简牍", "count": 3 }
5148
5835
  \`\`\`
5149
5836
  \`\`\`\`
@@ -5154,7 +5841,7 @@ npm run push # = build + jdu widget push dist
5154
5841
  |---|---|
5155
5842
  | \`src/types.ts\` | \`Data\` 接口 = fence 里 data 的唯一事实。JSDoc → description,\`@default\` → default;\`npm run build\` 生成 widget.json 的 dataSchema |
5156
5843
  | ${c.template === "vanilla" ? "`src/index.ts`" : c.template === "react" ? "`src/App.tsx`" : "`src/App.ts`"} | 渲染逻辑${c.template === "vanilla" ? "(mount / update / destroy 三个生命周期)" : "(组件收 `data` prop;契约适配在 `src/_base/`,一般不用动)"} |
5157
- | \`src/style.css\` | 样式。颜色走 \`--jiandu-*\` token 带兜底值,暗色用 \`.dark\` 选择器 |
5844
+ | \`src/style.css\` | 样式。颜色 / 字号 / 圆角走宿主的设计 token(\`--bg\` \`--fg\` \`--line\` \`--accent\` \`--t-ui\` \`--r-md\` …),不写 hex 与 \`.dark\` 分支;主题真源是 \`<html class="dark">\`,token 会自己换值 |
5158
5845
  | \`widget.json\` | name / scope / version / sampleData;dataSchema 由 build 生成,不用手写 |
5159
5846
  | \`scripts/build.mjs\` | 全内联构建配置${c.runtime ? ";`RUNTIME_PATHS` 是平台版本档 runtime 的 URL" : ""} |
5160
5847
 
@@ -5198,7 +5885,7 @@ async function fetchRuntime(api) {
5198
5885
  try {
5199
5886
  client = await api();
5200
5887
  } catch (err) {
5201
- throw new CliError(`shared 档要从 server 取 runtime 地址,但 CLI 未配置:${err instanceof Error ? err.message : String(err)}`, "先 jdu init / login,或改用 --runtime custom(自带 react 全内联,不需要 server)");
5888
+ throw new CliError(`shared 档要从 server 取 runtime 地址,但 CLI 未配置:${err instanceof Error ? err.message : String(err)}`, "先 jdu login,或改用 --runtime custom(自带 react 全内联,不需要 server)");
5202
5889
  }
5203
5890
  const res = await client.getJson("/api/runtimes");
5204
5891
  const name = typeof res?.default === "string" ? res.default : null;
@@ -5262,10 +5949,30 @@ async function client() {
5262
5949
  function collectTag(value, previous) {
5263
5950
  return [...previous ?? [], value];
5264
5951
  }
5952
+ var VERSION = "0.5.0";
5265
5953
  var program = new Command();
5266
- program.name("jdu").description("jiandu 命令行").version("0.3.0").showHelpAfterError();
5267
- program.command("init").description("选定部署目标并初始化本地配置(默认官方线上;自部署 / 本机需显式指定)").option("--local", "目标是本机自部署(http://127.0.0.1:8080)").option("--server <url>", "目标是指定的自部署地址").option("--token <token>", "一并完成 token 登录(server 首启 stdout / data/initial-token.txt)").option("--no-browser", "站点支持浏览器授权时也不打开浏览器,只给 token 指引").option("--json", "机器可读输出(agent 联动用;不会打开浏览器)", false).action(async (opts) => {
5268
- const result = await runInit({
5954
+ program.name("jdu").description("jiandu 命令行").version(VERSION).showHelpAfterError();
5955
+ program.command("deploy").description("把一台简牍部署到你自己的 Cloudflare 账号(Worker + Durable Object,Free 计划够用)").option("--owner <name>", "管理员用户名(JIANDU_AUTH_OWNER)。首次部署必填;升级时不给就保持账号上现有的值").option("--name <name>", "Worker 名字,决定 workers.dev 的子域名前缀", "jiandu").option("--dist <dir>", "用本地构建的产物代替 npm 包(开发用,指向 server/dist-cf)").option("--dry-run", "只校验配置与产物,不上传、不碰你的账号", false).option("--json", "机器可读输出(agent 联动用)", false).action(async (opts) => {
5956
+ const result = await runDeploy(opts);
5957
+ if (opts.json) {
5958
+ process.stdout.write(`${JSON.stringify(result)}\n`);
5959
+ return;
5960
+ }
5961
+ if (opts.dryRun) {
5962
+ process.stdout.write("校验通过:产物与 wrangler 配置都没问题,没有上传任何东西\n");
5963
+ return;
5964
+ }
5965
+ process.stdout.write(`已部署 ${result.url}\n`);
5966
+ if (result.setupSecret === null) {
5967
+ process.stdout.write("实例本来就在跑,secret 与配置原样保留(这是一次升级)\n");
5968
+ return;
5969
+ }
5970
+ process.stdout.write(`\n第一把钥匙(只在这里出现这一次,记下来):\n ${result.setupSecret}\n`);
5971
+ process.stdout.write(`\n打开这个链接绑 passkey:\n ${result.setupUrl}\n`);
5972
+ if (result.loggedIn) process.stdout.write("\njdu 已经连上它了,可以直接 jdu push\n");
5973
+ });
5974
+ program.command("login").description("登录到一台简牍:定站点、拿凭据、写本地配置(必须指定 --server 或 --local)").option("--local", "目标是本机自部署(http://127.0.0.1:8080)").option("--server <url>", "目标简牍站点地址").option("--token <token>", "直接用 token 登录(无浏览器的机器;token 在 server 首启 stdout / data/initial-token.txt)").option("--user <owner>", "forward-auth:以指定 owner 直连,跳过 SSO(请求注入 X-Forwarded-User 头)").option("--proxy-secret <secret>", "forward-auth:网关共享密钥(X-Jiandu-Proxy-Secret 头)").option("--issuer <url>", "OIDC issuer(缺省读 healthz.oidc 或 JIANDU_OIDC_ISSUER)").option("--client-id <id>", "OIDC client id(缺省读 healthz.oidc 或 JIANDU_OIDC_CLIENT_ID)").option("--no-browser", "站点支持浏览器授权时也不打开浏览器,只给 token 指引").option("--json", "机器可读输出(agent 联动用;不会打开浏览器)", false).action(async (opts) => {
5975
+ const result = await runLogin({
5269
5976
  ...opts,
5270
5977
  browser: opts.browser && !opts.json
5271
5978
  });
@@ -5276,24 +5983,9 @@ program.command("init").description("选定部署目标并初始化本地配置
5276
5983
  process.stdout.write(`目标 ${result.target} · ${result.server}(auth: ${result.authProvider})\n`);
5277
5984
  process.stdout.write(`配置已写入 ${result.configPath}\n`);
5278
5985
  process.stdout.write(result.loggedIn ? "凭据就绪,可以直接 jdu push。\n" : `下一步:${result.next ?? ""}\n`);
5279
- });
5280
- program.command("login").description("登录到 jiandu server(forward-auth 默认打开浏览器走 SSO;--user 则直连注入身份头)").requiredOption("--server <url>", "jiandu server 地址").option("--token <token>", "直接保存访问 token(token provider / 跳过浏览器)").option("--user <owner>", "forward-auth:以指定 owner 直连(请求注入 X-Forwarded-User 头,跳过 SSO)").option("--proxy-secret <secret>", "forward-auth:网关共享密钥(请求注入 X-Jiandu-Proxy-Secret 头)").option("--issuer <url>", "OIDC issuer(缺省读 healthz.oidc 或 JIANDU_OIDC_ISSUER)").option("--client-id <id>", "OIDC client id(缺省读 healthz.oidc 或 JIANDU_OIDC_CLIENT_ID)").action(async (opts) => {
5281
- const result = await runLogin({
5282
- server: opts.server,
5283
- token: opts.token,
5284
- user: opts.user,
5285
- proxySecret: opts.proxySecret,
5286
- issuer: opts.issuer,
5287
- clientId: opts.clientId
5288
- });
5289
- process.stdout.write(`${result.configPath}\n`);
5290
- if (result.source === "anonymous") process.stderr.write("这个 server 是匿名模式,无需登录。\n");
5291
- if (result.source === "forward-auth") process.stderr.write("已保存 forward-auth 直连身份(X-Forwarded-User)。\n");
5292
- if (result.source === "oidc-cache") process.stderr.write("已复用本机 SSO credential。\n");
5293
- if (result.source === "oidc-browser") process.stderr.write("SSO 登录成功。\n");
5294
5986
  if (result.warning) process.stderr.write(`jdu: ${result.warning}\n`);
5295
5987
  });
5296
- program.command("push").argument("<entry.md>", "入口 markdown 文件").description("递归收集本地引用、增量上传并发布一个新版本").option("--title <title>", "文档标题,默认取首个一级标题").option("--visibility <v>", `${VISIBILITIES.join(" | ")}(新文档默认 private;更新已有文档时不传则保持原值)`).option("--id <docId>", "复用已有文档 id(发布新版本)").option("--tag <name>", "打标签,可重复;不传则保持原有标签", collectTag, void 0).option("--official", "上架到官方知识库(需管理员;同时公开)").action(async (entry, opts) => {
5988
+ program.command("push").argument("<entry.md>", "入口 markdown 文件").description("递归收集本地引用、增量上传并发布一个新版本").option("--title <title>", "文档标题,默认取首个一级标题").option("--visibility <v>", `${VISIBILITIES.join(" | ")}(新文档默认 private;更新已有文档时不传则保持原值)`).option("--id <docId>", "复用已有文档 id(发布新版本)").option("--tag <name>", "打标签,可重复;不传则保持原有标签", collectTag, void 0).option("--official", "上架到官方知识库(需管理员;同时公开)").option("--team <teamId>", "进团队(需是该团队成员;传空串移出团队;不传则保持原值)").action(async (entry, opts) => {
5297
5989
  const res = await pushDoc(await client(), entry, opts);
5298
5990
  process.stdout.write(`${res.url}\n`);
5299
5991
  });
@@ -5420,10 +6112,10 @@ template.command("push").argument("<file.md>", "模板文件").description("发
5420
6112
  const res = await pushTemplate(await client(), file, opts);
5421
6113
  process.stdout.write(`${res.url}\n`);
5422
6114
  });
5423
- program.command("mcp").description("以 MCP server(stdio)暴露 list_docs / search_docs / read_doc / list_versions / push_doc,给本机 agent 用").action(async () => {
6115
+ program.command("mcp").description("以 MCP server(stdio)暴露文档 / 评论 / widget / 语法工具,模板同时暴露为 prompts,给本机 agent 用").action(async () => {
5424
6116
  await runMcp(await client());
5425
6117
  });
5426
- program.command("share").argument("<docId>").description("改可见性,或管理读者池(定向分享,可撤销)").option("--visibility <v>", VISIBILITIES.join(" | ")).option("--to <reader>", "加入读者池(身份串,如邮箱 / forward-auth 的 owner),可重复", collectTag, void 0).option("--revoke <reader>", "从读者池移除,立刻失效,可重复", collectTag, void 0).option("--readers", "列出当前读者池", false).action(async (docId, opts) => {
6118
+ program.command("share").argument("<docId>").description("改可见性 / 进出团队,或管理读者池(定向分享,可撤销)").option("--visibility <v>", VISIBILITIES.join(" | ")).option("--team <teamId>", "进团队(需是该团队成员)").option("--no-team", "移出团队").option("--to <reader>", "加入读者池(身份串,如邮箱 / forward-auth 的 owner),可重复", collectTag, void 0).option("--revoke <reader>", "从读者池移除,立刻失效,可重复", collectTag, void 0).option("--readers", "列出当前读者池", false).action(async (docId, opts) => {
5427
6119
  const api = await client();
5428
6120
  const url = `/api/docs/${encodeURIComponent(docId)}`;
5429
6121
  if (opts.readers) {
@@ -5441,9 +6133,12 @@ program.command("share").argument("<docId>").description("改可见性,或管
5441
6133
  process.stdout.write(`${readers.length > 0 ? readers.join("\n") : "(读者池为空)"}\n`);
5442
6134
  return;
5443
6135
  }
5444
- if (opts.visibility === void 0) throw new CliError("share 需要 --visibility / --to / --revoke / --readers 之一");
5445
- const res = await api.putJson(`${url}/visibility`, { visibility: opts.visibility });
5446
- process.stdout.write(`可见性 ${String(res.visibility)}\n`);
6136
+ const payload = {};
6137
+ if (opts.visibility !== void 0) payload.visibility = opts.visibility;
6138
+ if (opts.team !== void 0) payload.team = opts.team === false ? "" : opts.team;
6139
+ if (Object.keys(payload).length === 0) throw new CliError("share 需要 --visibility / --team / --no-team / --to / --revoke / --readers 之一");
6140
+ const res = await api.putJson(`${url}/visibility`, payload);
6141
+ process.stdout.write(`可见性 ${String(res.visibility)}${res.team ? `,团队 ${String(res.team)}` : ""}\n`);
5447
6142
  });
5448
6143
  var tokenCmd = program.command("token").description("CLI token(首启那枚与浏览器授权签发的)");
5449
6144
  tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请求用的那枚").action(async () => {
@@ -5470,10 +6165,102 @@ tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请
5470
6165
  }
5471
6166
  ]);
5472
6167
  });
6168
+ tokenCmd.command("create").option("--label <label>", "备注(jdu token list 里辨认用),如 agent / ci", "cli").description("再签一枚 token 给别的机器 / headless agent 用;stdout 只有 token,方便 $(jdu token create --label agent)").action(async (opts) => {
6169
+ const res = await (await client()).postJson("/api/tokens", { label: opts.label });
6170
+ if (typeof res.token !== "string") throw new CliError("server 没有返回 token");
6171
+ process.stderr.write(`id=${String(res.id)} label=${String(res.label)}(明文只显示这一次)\n`);
6172
+ process.stdout.write(`${res.token}\n`);
6173
+ });
5473
6174
  tokenCmd.command("rm").argument("<id>", "jdu token list 里的 ID").description("吊销一枚 token(正在使用的那枚不能删)").action(async (id) => {
5474
6175
  await (await client()).deleteJson(`/api/tokens/${encodeURIComponent(id)}`);
5475
6176
  process.stdout.write(`revoked ${id}\n`);
5476
6177
  });
6178
+ var teamCmd = program.command("team").description("团队:共享阅读与评论的空间(实例管理员建团队,团队管理员按用户名加人;文档进团队用 push/share --team)");
6179
+ teamCmd.command("list").description("我所属的团队(实例管理员:全部团队)").action(async () => {
6180
+ printTable(asRows(await (await client()).getJson("/api/teams"), "teams"), [
6181
+ {
6182
+ key: "id",
6183
+ header: "ID"
6184
+ },
6185
+ {
6186
+ key: "name",
6187
+ header: "NAME"
6188
+ },
6189
+ {
6190
+ key: "role",
6191
+ header: "ROLE"
6192
+ },
6193
+ {
6194
+ key: "members",
6195
+ header: "MEMBERS"
6196
+ },
6197
+ {
6198
+ key: "docs",
6199
+ header: "DOCS"
6200
+ }
6201
+ ]);
6202
+ });
6203
+ teamCmd.command("create").argument("<name>", "团队名").description("新建团队(实例管理员);你自动成为团队管理员,stdout 是团队 id").action(async (name) => {
6204
+ const res = await (await client()).postJson("/api/teams", { name });
6205
+ process.stdout.write(`${res.id}\n`);
6206
+ process.stderr.write(`团队「${res.name}」已创建:jdu team add ${res.id} <用户名> 加人\n`);
6207
+ });
6208
+ teamCmd.command("add").argument("<team>", "团队 id").argument("<user>", "成员用户名").option("--admin", "设为团队管理员(可加人 / 踢人 / 改名)").description("把成员加进团队;对已在团队的人 = 改角色").action(async (team, user, opts) => {
6209
+ await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members`, {
6210
+ owner: user,
6211
+ role: opts.admin ? "admin" : "member"
6212
+ });
6213
+ process.stdout.write(`added ${user} → ${team}${opts.admin ? " (admin)" : ""}\n`);
6214
+ });
6215
+ teamCmd.command("rm").argument("<team>", "团队 id").argument("<user>", "成员用户名").description("把成员移出团队(团队管理员)").action(async (team, user) => {
6216
+ await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members/remove`, { owner: user });
6217
+ process.stdout.write(`removed ${user} ← ${team}\n`);
6218
+ });
6219
+ teamCmd.command("leave").argument("<team>", "团队 id").description("自己退出团队").action(async (team) => {
6220
+ await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members/remove`, {});
6221
+ process.stdout.write(`left ${team}\n`);
6222
+ });
6223
+ var memberCmd = program.command("member").description("成员(password 鉴权,管理员专用):准入只有邀请,被邀请者用链接自己绑 passkey");
6224
+ memberCmd.command("list").description("成员名单:pending = 已邀请未绑定,bound = 已绑定 passkey").action(async () => {
6225
+ printTable(asRows(await (await client()).getJson("/api/members"), "members"), [
6226
+ {
6227
+ key: "owner",
6228
+ header: "OWNER"
6229
+ },
6230
+ {
6231
+ key: "status",
6232
+ header: "STATUS"
6233
+ },
6234
+ {
6235
+ key: "invitedAt",
6236
+ header: "INVITED"
6237
+ },
6238
+ {
6239
+ key: "boundAt",
6240
+ header: "BOUND"
6241
+ },
6242
+ {
6243
+ key: "tokens",
6244
+ header: "TOKENS"
6245
+ },
6246
+ {
6247
+ key: "docs",
6248
+ header: "DOCS"
6249
+ }
6250
+ ]);
6251
+ });
6252
+ memberCmd.command("invite").argument("<name>", "用户名:小写字母 / 数字开头,可含 . _ -,≤30 位").option("--reset", "已绑定的成员重置 passkey(清掉凭据,所有设备与会话即刻失效)").description("签一张 7 天有效的一次性邀请链接,自己交给对方;对方打开后绑定 passkey 即成为成员").action(async (name, opts) => {
6253
+ const res = await (await client()).postJson("/api/members/invite", {
6254
+ owner: name,
6255
+ reset: opts.reset === true
6256
+ });
6257
+ process.stdout.write(`${res.url}\n`);
6258
+ process.stderr.write(`邀请 ${res.owner},${new Date(res.expiresAt).toLocaleString()} 前有效;链接只显示这一次\n`);
6259
+ });
6260
+ memberCmd.command("rm").argument("<name>", "用户名").description("移除成员:删 passkey 与其全部 token;文档 / 评论 / 标签不动,同名重邀即接回").action(async (name) => {
6261
+ await (await client()).postJson("/api/members/remove", { owner: name });
6262
+ process.stdout.write(`removed ${name}\n`);
6263
+ });
5477
6264
  var officialCmd = program.command("official").description("官方知识库上架");
5478
6265
  officialCmd.command("list").description("列出已上架的官方文档").action(async () => {
5479
6266
  printTable(asRows(await (await client()).getJson("/api/official"), "docs"), [{