@monoedge/jdu-cli 0.4.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 (2) hide show
  1. package/dist/cli.js +967 -289
  2. 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,116 +3399,6 @@ 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
3404
  * jdu login 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
@@ -3803,11 +3702,355 @@ function fallbackMethods(provider) {
3803
3702
  if (provider === "forward-auth") return ["oidc", "token"];
3804
3703
  return ["token"];
3805
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
+ }
3806
4010
  /** 只取链接/图片的目标部分,标签内容可以任意嵌套,不参与匹配。 */
3807
4011
  var MD_DEST_RE = /\]\(([^)]*)\)/g;
3808
4012
  var HTML_ATTR_RE = /\b(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/gi;
3809
- /** 抽出一份文本里所有可能的引用目标(未过滤外链,原样返回)。 */
3810
- 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);
3811
4054
  const out = [];
3812
4055
  for (const m of text.matchAll(MD_DEST_RE)) {
3813
4056
  const dest = destinationOf(m[1] ?? "");
@@ -3922,15 +4165,30 @@ function collectReferences(entryPath, maxDepth = 10) {
3922
4165
  //#region src/push.ts
3923
4166
  /** 可见性三档。link 档已废除:定向分享走读者池(jdu share --to)。 */
3924
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
+ }
3925
4180
  function log(line) {
3926
4181
  process.stderr.write(`${line}\n`);
3927
4182
  }
3928
- /** 没给 --title 时用首个一级标题,再退化到文件名。 */
4183
+ /** 没给 --title 时用首个一级标题,其次 frontmatter 的 title / name(SKILL.md 形态,#122),再退化到文件名。 */
3929
4184
  function inferTitle(entryPath) {
3930
4185
  try {
3931
4186
  const text = readFileSync(entryPath, "utf8");
3932
4187
  const m = /^[ \t]{0,3}#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(text);
3933
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;
3934
4192
  } catch {}
3935
4193
  return basename(entryPath, extname(entryPath));
3936
4194
  }
@@ -3977,13 +4235,19 @@ async function pushDoc(api, entryArg, opts) {
3977
4235
  const url = typeof res?.url === "string" && res.url !== "" ? res.url : id !== void 0 ? api.url(`/d/${id}`) : void 0;
3978
4236
  const warnings = Array.isArray(res?.warnings) ? res.warnings.map(String) : [];
3979
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
+ }
3980
4243
  if (typeof res?.seq === "number") log(`已发布 ${id ?? ""} v${res.seq}`);
3981
4244
  if (url === void 0) throw new CliError("server 未返回文档 id 或 url,无法给出访问地址");
3982
4245
  return {
3983
4246
  id,
3984
4247
  seq: typeof res?.seq === "number" ? res.seq : void 0,
3985
4248
  url,
3986
- warnings
4249
+ warnings,
4250
+ lint
3987
4251
  };
3988
4252
  }
3989
4253
  //#endregion
@@ -4023,6 +4287,73 @@ function printTable(rows, columns) {
4023
4287
  * 模板原样发出去,怎么填是 agent 的事。agent 侧走 MCP 的 list_templates → read_doc → 按骨架写 → push_doc。
4024
4288
  */
4025
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
+ }
4026
4357
  var hasTemplateTag = (d) => Array.isArray(d.tags) && d.tags.map(String).includes("template");
4027
4358
  async function listTemplates(api, opts) {
4028
4359
  const [mine, official] = await Promise.all([api.getJson(`/api/docs${opts.all ? "?all=1" : ""}`), api.getJson("/api/official")]);
@@ -4048,6 +4379,10 @@ async function listTemplates(api, opts) {
4048
4379
  key: "title",
4049
4380
  header: "TITLE"
4050
4381
  },
4382
+ {
4383
+ key: "uses",
4384
+ header: "USES"
4385
+ },
4051
4386
  {
4052
4387
  key: "updatedAt",
4053
4388
  header: "UPDATED"
@@ -4069,6 +4404,7 @@ async function pullTemplate(api, id, opts) {
4069
4404
  * (push 的 tags 字段是整体覆盖语义,不先取回就会把别的标签抹掉)。
4070
4405
  */
4071
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");
4072
4408
  let tags = [TEMPLATE_TAG];
4073
4409
  if (opts.id) {
4074
4410
  const cur = await api.getJson(`/api/docs/${encodeURIComponent(opts.id)}/tags`).catch(() => ({}));
@@ -4089,8 +4425,12 @@ async function pushTemplate(api, file, opts) {
4089
4425
  * 当可读可写的知识库用(#9 A 段)。凭据复用 ~/.config/jiandu/config.json,server 端零改动。
4090
4426
  *
4091
4427
  * ponytail: 不引 @modelcontextprotocol/sdk——它带 express / hono / zod 一整套,而这里只需要
4092
- * initialize / tools/list / tools/call 三个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4428
+ * initialize / tools/* / prompts/* 几个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4093
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。
4094
4434
  */
4095
4435
  var SUPPORTED_PROTOCOLS = [
4096
4436
  "2025-06-18",
@@ -4099,13 +4439,41 @@ var SUPPORTED_PROTOCOLS = [
4099
4439
  ];
4100
4440
  var SERVER_INFO = {
4101
4441
  name: "jiandu",
4102
- version: "0.2.0"
4442
+ version: "0.6.0"
4103
4443
  };
4104
- var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库。先用 search_docs / list_docs 找到文档 id,read_doc 拿 markdown 原文;改稿后用 push_doc 发布新版本(带 id 才是更新,不带是新建)。文档里的 widget fence(```widget: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
+ }
4105
4457
  function str(v, name) {
4106
4458
  if (typeof v !== "string" || v.trim() === "") throw new CliError(`${name} 必填且为非空字符串`);
4107
4459
  return v.trim();
4108
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
+ }
4109
4477
  function asDoc(raw, scope, server) {
4110
4478
  if (raw === null || typeof raw !== "object") return null;
4111
4479
  const d = raw;
@@ -4117,8 +4485,10 @@ function asDoc(raw, scope, server) {
4117
4485
  visibility: typeof d["visibility"] === "string" ? d["visibility"] : scope === "official" ? "official" : "",
4118
4486
  tags: Array.isArray(d["tags"]) ? d["tags"].map(String) : [],
4119
4487
  scope,
4488
+ ...typeof d["team"] === "string" || d["team"] === null ? { team: d["team"] } : {},
4120
4489
  ...typeof d["archived"] === "boolean" ? { archived: d["archived"] } : {},
4121
4490
  ...typeof d["updatedAt"] === "number" ? { updatedAt: d["updatedAt"] } : {},
4491
+ ...typeof d["uses"] === "number" ? { uses: d["uses"] } : {},
4122
4492
  url: typeof d["url"] === "string" ? d["url"] : `${server}/d/${d["id"]}`
4123
4493
  };
4124
4494
  }
@@ -4137,152 +4507,345 @@ function buildTools(api) {
4137
4507
  const seen = new Set(mine.map((d) => d.id));
4138
4508
  return [...mine, ...[...official, ...shared].filter((d) => !seen.has(d.id) && seen.add(d.id))];
4139
4509
  };
4140
- return [
4141
- {
4142
- name: "list_docs",
4143
- description: "列出文档。scope:mine(我发布的,默认)/ official(官方知识库)/ shared(分享给我的)/ all。返回 id、标题、摘要、可见性、标签、URL。",
4144
- inputSchema: {
4145
- type: "object",
4146
- properties: {
4147
- scope: {
4148
- type: "string",
4149
- enum: [
4150
- "mine",
4151
- "official",
4152
- "shared",
4153
- "all"
4154
- ],
4155
- default: "mine"
4156
- },
4157
- includeArchived: {
4158
- type: "boolean",
4159
- default: false,
4160
- 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);
4161
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);
4162
4579
  }
4163
4580
  },
4164
- run: async (args) => {
4165
- const scope = typeof args["scope"] === "string" ? args["scope"] : "mine";
4166
- const includeArchived = args["includeArchived"] === true;
4167
- const docs = scope === "all" ? await listAll(includeArchived) : scope === "mine" || scope === "official" || scope === "shared" ? await listOf(scope, includeArchived) : (() => {
4168
- throw new CliError(`scope 只能是 mine / official / shared / all:${scope}`);
4169
- })();
4170
- return JSON.stringify(docs, null, 2);
4171
- }
4172
- },
4173
- {
4174
- name: "search_docs",
4175
- description: "按关键词搜文档(标题 / 摘要 / 标签,空格分词、全部命中)。范围是我能看到的全部文档。没有全文检索——要读正文用 read_doc。",
4176
- inputSchema: {
4177
- type: "object",
4178
- required: ["query"],
4179
- properties: {
4180
- query: { type: "string" },
4181
- limit: {
4182
- type: "integer",
4183
- minimum: 1,
4184
- maximum: 100,
4185
- 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
+ }
4186
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`);
4187
4601
  }
4188
4602
  },
4189
- run: async (args) => {
4190
- const terms = str(args["query"], "query").toLowerCase().split(/\s+/).filter(Boolean);
4191
- const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(100, args["limit"])) : 20;
4192
- const hits = (await listAll(false)).filter((d) => {
4193
- const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
4194
- return terms.every((t) => hay.includes(t));
4195
- });
4196
- return JSON.stringify(hits.slice(0, limit), null, 2);
4197
- }
4198
- },
4199
- {
4200
- name: "read_doc",
4201
- description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
4202
- inputSchema: {
4203
- type: "object",
4204
- required: ["id"],
4205
- properties: {
4206
- id: { type: "string" },
4207
- version: {
4208
- type: "integer",
4209
- minimum: 1,
4210
- 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
+ });
4211
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);
4212
4650
  }
4213
4651
  },
4214
- run: async (args) => {
4215
- const id = encodeURIComponent(str(args["id"], "id"));
4216
- const version = args["version"];
4217
- if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
4218
- return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
4219
- }
4220
- },
4221
- {
4222
- name: "list_templates",
4223
- description: "列出文档模板(打了 template 标签的文档:官方模板 + 我的)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
4224
- inputSchema: {
4225
- type: "object",
4226
- 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
+ }
4227
4664
  },
4228
- run: async () => {
4229
- const docs = (await listAll(false)).filter((d) => d.tags.includes(TEMPLATE_TAG));
4230
- return JSON.stringify(docs, null, 2);
4231
- }
4232
- },
4233
- {
4234
- name: "list_versions",
4235
- description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
4236
- inputSchema: {
4237
- type: "object",
4238
- required: ["id"],
4239
- 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
+ }
4240
4723
  },
4241
- run: async (args) => {
4242
- const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
4243
- return JSON.stringify(res, null, 2);
4244
- }
4245
- },
4246
- {
4247
- name: "push_doc",
4248
- description: "发布本机的 markdown 文件(递归收集图片 / 嵌套 md 等本地引用并上传)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签不传则保持原值);不带 id 新建,默认 private。path 用绝对路径。",
4249
- inputSchema: {
4250
- type: "object",
4251
- required: ["path"],
4252
- properties: {
4253
- path: {
4254
- type: "string",
4255
- description: "入口 .md 的绝对路径"
4256
- },
4257
- id: {
4258
- type: "string",
4259
- description: "要更新的文档 id"
4260
- },
4261
- title: { type: "string" },
4262
- visibility: {
4263
- type: "string",
4264
- enum: [...VISIBILITIES]
4265
- },
4266
- tags: {
4267
- type: "array",
4268
- items: { type: "string" },
4269
- 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
+ }
4270
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);
4271
4765
  }
4272
4766
  },
4273
- run: async (args) => {
4274
- const path = str(args["path"], "path");
4275
- const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
4276
- const res = await pushDoc(api, path, {
4277
- id: typeof args["id"] === "string" ? args["id"] : void 0,
4278
- title: typeof args["title"] === "string" ? args["title"] : void 0,
4279
- visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
4280
- tag: tags
4281
- });
4282
- 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
+ }
4283
4845
  }
4284
- }
4285
- ];
4846
+ ],
4847
+ loadTemplates
4848
+ };
4286
4849
  }
4287
4850
  function write(msg) {
4288
4851
  process.stdout.write(`${JSON.stringify(msg)}\n`);
@@ -4298,7 +4861,7 @@ function rpcError(id, code, message) {
4298
4861
  });
4299
4862
  }
4300
4863
  async function runMcp(api) {
4301
- const tools = buildTools(api);
4864
+ const { tools, loadTemplates } = buildTools(api);
4302
4865
  const byName = new Map(tools.map((t) => [t.name, t]));
4303
4866
  const handle = async (req) => {
4304
4867
  const { id, method } = req;
@@ -4311,18 +4874,81 @@ async function runMcp(api) {
4311
4874
  switch (method) {
4312
4875
  case "initialize": {
4313
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 {}
4314
4883
  write({
4315
4884
  jsonrpc: "2.0",
4316
4885
  id,
4317
4886
  result: {
4318
- protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
4319
- capabilities: { tools: {} },
4887
+ protocolVersion,
4888
+ capabilities: {
4889
+ tools: {},
4890
+ prompts: {}
4891
+ },
4320
4892
  serverInfo: SERVER_INFO,
4321
- instructions: INSTRUCTIONS
4893
+ instructions
4322
4894
  }
4323
4895
  });
4324
4896
  return;
4325
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
+ }
4326
4952
  case "ping":
4327
4953
  write({
4328
4954
  jsonrpc: "2.0",
@@ -4427,6 +5053,7 @@ async function readWidgetJson(dir) {
4427
5053
  name,
4428
5054
  scope,
4429
5055
  version,
5056
+ ...typeof obj.description === "string" && obj.description.trim() !== "" ? { description: obj.description.trim() } : {},
4430
5057
  dataSchema: obj.dataSchema ?? null,
4431
5058
  sampleData: obj.sampleData ?? null
4432
5059
  };
@@ -4521,6 +5148,9 @@ async function pushWidget(api, dirArg) {
4521
5148
  process.stdout.write(`${meta.scope}/${meta.name}@${meta.version}${src}${where}\n`);
4522
5149
  }
4523
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
4524
5154
  //#region src/widget-dev.ts
4525
5155
  var ARTIFACTS = [
4526
5156
  "index.js",
@@ -4563,19 +5193,28 @@ function readMeta(dir, dist) {
4563
5193
  version: ""
4564
5194
  };
4565
5195
  }
4566
- /** 线上 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
+ */
4567
5202
  async function siteBaseCss(server) {
4568
5203
  if (!server) return null;
4569
5204
  try {
4570
5205
  const ctrl = new AbortController();
4571
5206
  const t = setTimeout(() => ctrl.abort(), 2e3);
4572
- const html = await (await fetch(`${server}/`, {
5207
+ const res = await fetch(`${server}/_v/manifest.json`, {
4573
5208
  signal: ctrl.signal,
4574
5209
  redirect: "follow"
4575
- })).text();
5210
+ });
4576
5211
  clearTimeout(t);
4577
- const m = /\/_v\/base\.[a-f0-9]+\.css/.exec(html);
4578
- 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;
4579
5218
  } catch {
4580
5219
  return null;
4581
5220
  }
@@ -4588,23 +5227,25 @@ function page(meta, baseCss, hasCss, mtime) {
4588
5227
  <meta charset="utf-8">
4589
5228
  <meta name="viewport" content="width=device-width, initial-scale=1">
4590
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>
4591
5235
  ${baseCss ? `<link rel="stylesheet" href="${esc(baseCss)}">` : ""}
4592
5236
  ${hasCss ? `<link rel="stylesheet" href="/index.css?t=${mtime}">` : ""}
4593
5237
  <style>
4594
- /* 兜底 token:连不上 jiandu server 时也有亮 / 暗两套 */
4595
- :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; }
4596
- .dark { --jiandu-bg: #0d1117; --jiandu-bg-subtle: #161b22; --jiandu-fg: #e6edf3; --jiandu-fg-muted: #9198a1; --jiandu-border: #3d444d; }
4597
- 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); }
4598
5239
  body { margin: 0; }
4599
- .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); }
4600
- .dev-bar b { color: var(--jiandu-fg); }
4601
- .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; }
4602
5243
  .dev-main { max-width: 760px; margin: 0 auto; padding: 1.5rem 1rem; }
4603
- .dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--jiandu-border); border-radius: 8px; }
4604
- details { margin-top: 1.5rem; font-size: 13px; color: var(--jiandu-fg-muted); }
4605
- 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); }
4606
- .dev-err { color: #d1242f; white-space: pre-wrap; font-family: var(--jiandu-font-mono); font-size: 12px; }
4607
- .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; }
4608
5249
  </style>
4609
5250
  </head>
4610
5251
  <body>
@@ -4812,26 +5453,36 @@ export interface Data {
4812
5453
  count?: number;
4813
5454
  }
4814
5455
  `;
4815
- 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) */
4816
5461
  .w-${c.kebab} {
4817
5462
  display: inline-flex;
4818
5463
  align-items: center;
4819
- gap: 0.5rem;
4820
- padding: 0.5rem 0.75rem;
4821
- border: 1px solid var(--jiandu-border, #d0d7de);
4822
- border-radius: 8px;
4823
- background: var(--jiandu-bg-subtle, #f6f8fa);
4824
- color: var(--jiandu-fg, #1f2328);
4825
- 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);
4826
5471
  }
4827
5472
  .w-${c.kebab} button {
4828
- padding: 0 0.5rem;
4829
- border: 1px solid var(--jiandu-border, #d0d7de);
4830
- border-radius: 6px;
4831
- 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);
4832
5478
  color: inherit;
5479
+ font: 550 var(--t-sm) var(--f-sans);
4833
5480
  cursor: pointer;
4834
5481
  }
5482
+ .w-${c.kebab} button:hover {
5483
+ background: var(--s-2);
5484
+ border-color: var(--line-loud);
5485
+ }
4835
5486
  `;
4836
5487
  var VANILLA_INDEX = (c) => `import type { WidgetContext } from './_base/contract.js';
4837
5488
  import type { Data } from './types.js';
@@ -5144,6 +5795,7 @@ var WIDGET_JSON = (c) => `${JSON.stringify({
5144
5795
  name: c.name,
5145
5796
  scope: "personal",
5146
5797
  version: "1.0.0",
5798
+ description: "一句话说清什么场景用这个组件——agent 靠它决定选不选",
5147
5799
  dataSchema: {
5148
5800
  type: "object",
5149
5801
  required: ["title"],
@@ -5189,7 +5841,7 @@ npm run push # = build + jdu widget push dist
5189
5841
  |---|---|
5190
5842
  | \`src/types.ts\` | \`Data\` 接口 = fence 里 data 的唯一事实。JSDoc → description,\`@default\` → default;\`npm run build\` 生成 widget.json 的 dataSchema |
5191
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/`,一般不用动)"} |
5192
- | \`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 会自己换值 |
5193
5845
  | \`widget.json\` | name / scope / version / sampleData;dataSchema 由 build 生成,不用手写 |
5194
5846
  | \`scripts/build.mjs\` | 全内联构建配置${c.runtime ? ";`RUNTIME_PATHS` 是平台版本档 runtime 的 URL" : ""} |
5195
5847
 
@@ -5297,8 +5949,28 @@ async function client() {
5297
5949
  function collectTag(value, previous) {
5298
5950
  return [...previous ?? [], value];
5299
5951
  }
5952
+ var VERSION = "0.5.0";
5300
5953
  var program = new Command();
5301
- program.name("jdu").description("jiandu 命令行").version("0.4.0").showHelpAfterError();
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
+ });
5302
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) => {
5303
5975
  const result = await runLogin({
5304
5976
  ...opts,
@@ -5440,7 +6112,7 @@ template.command("push").argument("<file.md>", "模板文件").description("发
5440
6112
  const res = await pushTemplate(await client(), file, opts);
5441
6113
  process.stdout.write(`${res.url}\n`);
5442
6114
  });
5443
- 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 () => {
5444
6116
  await runMcp(await client());
5445
6117
  });
5446
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) => {
@@ -5493,6 +6165,12 @@ tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请
5493
6165
  }
5494
6166
  ]);
5495
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
+ });
5496
6174
  tokenCmd.command("rm").argument("<id>", "jdu token list 里的 ID").description("吊销一枚 token(正在使用的那枚不能删)").action(async (id) => {
5497
6175
  await (await client()).deleteJson(`/api/tokens/${encodeURIComponent(id)}`);
5498
6176
  process.stdout.write(`revoked ${id}\n`);