@timeryu/dsh-larklink 0.0.0-stage → 0.3.1

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.
package/lib/index.mjs ADDED
@@ -0,0 +1,1773 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { delimiter, join } from "node:path";
5
+ import { defineTool } from "@deepseek-ai/dsh-tools";
6
+ import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { createHash } from "node:crypto";
8
+ // \0rolldown/runtime.js
9
+ var __defProp = Object.defineProperty;
10
+ var __exportAll = (all, no_symbols) => {
11
+ let target = {};
12
+ for (var name in all) __defProp(target, name, {
13
+ get: all[name],
14
+ enumerable: true
15
+ });
16
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
17
+ return target;
18
+ };
19
+ // ../cli/src/larkcli.ts
20
+ /**
21
+ * lark-cli 子进程封装。所有 spawn 只走这里——命令一律 argv 数组,绝不安字符串拼接
22
+ * (lark-shared 安全红线)。runner 以接口注入,测试用 mock 替换。
23
+ */
24
+ /** 解析 lark-cli 可执行文件:env 覆盖 > PATH 上的原生 exe > npm 全局已知位置。Windows 只接受 .exe(shim 脚本无法被 spawn(shell:false) 执行)。 */
25
+ function resolveLarkCli() {
26
+ const envOverride = process.env["LARKLINK_LARK_CLI"];
27
+ if (envOverride && envOverride.trim() !== "") return envOverride;
28
+ const isWin = process.platform === "win32";
29
+ const candidates = [];
30
+ const pathDirs = (process.env["PATH"] ?? "").split(delimiter).filter((d) => d.trim() !== "");
31
+ for (const dir of pathDirs) if (isWin) candidates.push(join(dir, "lark-cli.exe"));
32
+ else candidates.push(join(dir, "lark-cli"));
33
+ const home = process.env["USERPROFILE"] ?? process.env["HOME"];
34
+ if (home) candidates.push(join(home, ".npm-global", "node_modules", "@larksuite", "cli", "bin", "lark-cli.exe"));
35
+ for (const c of candidates) try {
36
+ if (existsSync(c)) return c;
37
+ } catch {}
38
+ throw new Error(`找不到可执行的 lark-cli${isWin ? "(Windows 需要 lark-cli.exe 原生二进制)" : ""}:请安装 lark-cli(npm i -g @larksuite/cli)或设置 LARKLINK_LARK_CLI 指向其原生可执行路径`);
39
+ }
40
+ /** 默认 runner:spawn 原生 exe,超时击杀并标记 timedOut(Q7:超时=结果未知)。 */
41
+ const defaultLarkRunner = (args, opts = {}) => {
42
+ const bin = resolveLarkCli();
43
+ const timeoutMs = opts.timeoutMs ?? 12e4;
44
+ return new Promise((resolve, reject) => {
45
+ const started = Date.now();
46
+ const child = spawn(bin, args, {
47
+ shell: false,
48
+ windowsHide: true,
49
+ cwd: opts.cwd,
50
+ stdio: [
51
+ "pipe",
52
+ "pipe",
53
+ "pipe"
54
+ ]
55
+ });
56
+ let stdout = "";
57
+ let stderr = "";
58
+ let timedOut = false;
59
+ const timer = setTimeout(() => {
60
+ timedOut = true;
61
+ child.kill();
62
+ }, timeoutMs);
63
+ child.stdout.on("data", (d) => stdout += d.toString("utf8"));
64
+ child.stderr.on("data", (d) => stderr += d.toString("utf8"));
65
+ if (opts.stdin !== void 0) {
66
+ child.stdin.write(opts.stdin);
67
+ child.stdin.end();
68
+ } else child.stdin.end();
69
+ child.on("error", (e) => {
70
+ clearTimeout(timer);
71
+ reject(e);
72
+ });
73
+ child.on("close", (code) => {
74
+ clearTimeout(timer);
75
+ resolve({
76
+ exitCode: timedOut ? -1 : code ?? -1,
77
+ stdout,
78
+ stderr,
79
+ durationMs: Date.now() - started,
80
+ timedOut
81
+ });
82
+ });
83
+ });
84
+ };
85
+ /** lark-cli 输出信封解析:stdout JSON 优先,退回 stderr JSON(lark-shared:成功看 ok/exit 0,非 code==0)。 */
86
+ function parseEnvelope(result) {
87
+ const out = safeJson(result.stdout);
88
+ const err = safeJson(result.stderr);
89
+ if (result.timedOut) return {
90
+ ok: false,
91
+ payload: out,
92
+ error: {
93
+ type: "timeout",
94
+ message: "lark-cli 超时被击杀,服务端结果未知(Q7)"
95
+ }
96
+ };
97
+ if (result.exitCode === 0 && out !== null) return {
98
+ ok: true,
99
+ payload: out,
100
+ error: null
101
+ };
102
+ if (result.exitCode === 0) return {
103
+ ok: true,
104
+ payload: { raw: result.stdout },
105
+ error: null
106
+ };
107
+ return {
108
+ ok: false,
109
+ payload: out,
110
+ error: err ?? {
111
+ type: "unknown",
112
+ message: result.stderr || result.stdout || `exit ${result.exitCode}`
113
+ }
114
+ };
115
+ }
116
+ function safeJson(text) {
117
+ const t = text.trim();
118
+ if (t === "") return null;
119
+ try {
120
+ return JSON.parse(t);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+ // ../core/src/url.ts
126
+ /**
127
+ * 文档 URL / 裸 token 解析器(design.md §5 步骤 1-2)。
128
+ * 事实依据:lark-drive SKILL「文档 URL 格式与 Token 处理」表、lark-shared
129
+ * wiki-token-routing、lark-minutes/lark-slides/lark-drive-inspect 实例(2026-10-22 核对)。
130
+ */
131
+ /** 路径前缀 → 资源类型。顺序无关(前缀互不冲突),但保持与设计文档一致的清单。 */
132
+ const PATH_RULES = [
133
+ ["/docx/", "docx"],
134
+ ["/doc/", "doc"],
135
+ ["/wiki/", "wiki"],
136
+ ["/sheets/", "sheets"],
137
+ ["/base/", "base"],
138
+ ["/slides/", "slides"],
139
+ ["/mindnotes/", "mindnotes"],
140
+ ["/whiteboard/", "whiteboard"],
141
+ ["/minutes/", "minutes"],
142
+ ["/drive/folder/", "folder"],
143
+ ["/page/", "apps"]
144
+ ];
145
+ /**
146
+ * 裸 token 前缀 → 资源类型。仅收录有本地文档实例佐证的前缀;
147
+ * slides 为宽松前缀(实例既见 `slides…` 也见无前缀形态)。
148
+ */
149
+ const TOKEN_RULES = [
150
+ ["doxcn", "docx"],
151
+ ["doccn", "doc"],
152
+ ["wikcn", "wiki"],
153
+ ["shtcn", "sheets"],
154
+ ["bascn", "base"],
155
+ ["fldcn", "folder"],
156
+ ["obcn", "minutes"],
157
+ ["slides", "slides"]
158
+ ];
159
+ const WIKI_TYPES = /* @__PURE__ */ new Set(["wiki"]);
160
+ /** 域名 → 品牌证据。doubao.com 及一切未列域名 → "none"(Q3 决议:不做默认品牌猜测)。 */
161
+ const BRAND_DOMAIN_SUFFIXES = [
162
+ ["feishu.cn", "feishu"],
163
+ ["larksuite.com", "lark"],
164
+ ["larkoffice.com", "lark"]
165
+ ];
166
+ function brandFromDomain(host) {
167
+ for (const [suffix, brand] of BRAND_DOMAIN_SUFFIXES) if (host === suffix || host.endsWith(`.${suffix}`)) return brand;
168
+ return "none";
169
+ }
170
+ function makeParsed(input, kind, domain, resourceType, token) {
171
+ return {
172
+ input,
173
+ kind,
174
+ domain,
175
+ brand: domain ? brandFromDomain(domain) : "none",
176
+ resourceType,
177
+ token,
178
+ wikiUnwrapNeeded: resourceType !== null && WIKI_TYPES.has(resourceType)
179
+ };
180
+ }
181
+ const HOST_PATH_RE = /^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}\//;
182
+ /**
183
+ * 解析用户粘贴的 URL 或裸 token。纯函数,不抛异常——识别失败以
184
+ * resourceType=null / token="" 表达,由 resolve 决定如何拒绝。
185
+ */
186
+ function parseDoc(rawInput) {
187
+ const input = rawInput.trim();
188
+ if (input === "") return makeParsed(input, "token", null, null, "");
189
+ let urlString = null;
190
+ if (/:\/\//.test(input)) urlString = input;
191
+ else if (HOST_PATH_RE.test(input)) urlString = `https://${input}`;
192
+ if (urlString !== null) {
193
+ let url;
194
+ try {
195
+ url = new URL(urlString);
196
+ } catch {
197
+ return makeParsed(input, "url", null, null, "");
198
+ }
199
+ const path = url.pathname;
200
+ for (const [prefix, type] of PATH_RULES) {
201
+ const idx = path.indexOf(prefix);
202
+ if (idx === -1) continue;
203
+ const token = path.slice(idx + prefix.length).split("/").find((seg) => seg !== "") ?? "";
204
+ return makeParsed(input, "url", url.hostname.toLowerCase(), type, decodeURIComponent(token));
205
+ }
206
+ return makeParsed(input, "url", url.hostname.toLowerCase(), null, "");
207
+ }
208
+ for (const [prefix, type] of TOKEN_RULES) if (input.startsWith(prefix)) return makeParsed(input, "token", null, type, input);
209
+ return makeParsed(input, "token", null, null, "");
210
+ }
211
+ // ../core/src/registry.ts
212
+ /** Registry 内存操作(纯函数;文件 IO/锁在 CLI 层,M2)。 */
213
+ function createRegistry() {
214
+ return {
215
+ version: 1,
216
+ bindings: [],
217
+ pins: []
218
+ };
219
+ }
220
+ function findBinding(registry, profile) {
221
+ return registry.bindings.find((b) => b.profile === profile);
222
+ }
223
+ /**
224
+ * 可路由绑定:排除 incomplete(Q8 决议)。
225
+ * brand 为具体品牌时按品牌过滤(design.md §5 步骤 3);"none" 不过滤。
226
+ */
227
+ function routableBindings(registry, brand) {
228
+ return registry.bindings.filter((b) => b.status !== "incomplete" && (brand === "none" || b.brand === brand));
229
+ }
230
+ /** pin 以归一化 token 为键精确匹配(Q3/Q10:永久、无 TTL)。 */
231
+ function findPin(registry, token) {
232
+ return registry.pins.find((p) => p.token === token);
233
+ }
234
+ function upsertPin(registry, pin) {
235
+ const rest = registry.pins.filter((p) => p.token !== pin.token);
236
+ return {
237
+ ...registry,
238
+ pins: [...rest, pin]
239
+ };
240
+ }
241
+ function upsertBinding(registry, binding) {
242
+ const rest = registry.bindings.filter((b) => b.profile !== binding.profile);
243
+ return {
244
+ ...registry,
245
+ bindings: [...rest, binding]
246
+ };
247
+ }
248
+ /** 结构校验:profile 唯一、appId 全局唯一(ADR-0001 不变量)、pin 指向存在的绑定。 */
249
+ function validateRegistry(registry) {
250
+ const issues = [];
251
+ const profiles = /* @__PURE__ */ new Set();
252
+ const appIds = /* @__PURE__ */ new Map();
253
+ for (const b of registry.bindings) {
254
+ if (!b.profile) issues.push({
255
+ field: "bindings[].profile",
256
+ message: "profile 名为空"
257
+ });
258
+ if (profiles.has(b.profile)) issues.push({
259
+ field: "bindings[].profile",
260
+ message: `profile "${b.profile}" 重复`
261
+ });
262
+ profiles.add(b.profile);
263
+ const existingOwner = appIds.get(b.appId);
264
+ if (existingOwner !== void 0) issues.push({
265
+ field: "bindings[].appId",
266
+ message: `appId "${b.appId}" 同时被 "${existingOwner}" 与 "${b.profile}" 使用(ADR-0001:每个绑定独立应用)`
267
+ });
268
+ else appIds.set(b.appId, b.profile);
269
+ }
270
+ for (const p of registry.pins) if (!profiles.has(p.profile)) issues.push({
271
+ field: "pins[].profile",
272
+ message: `pin(token=${p.token.slice(0, 8)}…) 指向不存在的绑定 "${p.profile}"`
273
+ });
274
+ return issues;
275
+ }
276
+ // ../core/src/resolve.ts
277
+ var ResolveError = class extends Error {
278
+ code;
279
+ parsed;
280
+ constructor(code, message, parsed) {
281
+ super(message);
282
+ this.name = "ResolveError";
283
+ this.code = code;
284
+ this.parsed = parsed;
285
+ }
286
+ };
287
+ function toCandidate(b, domain) {
288
+ return {
289
+ profile: b.profile,
290
+ brand: b.brand,
291
+ status: b.status,
292
+ tenantHintMatch: domain !== null && (b.tenantHints ?? []).includes(domain)
293
+ };
294
+ }
295
+ function resolve(registry, input, opts = {}) {
296
+ const parsed = parseDoc(input);
297
+ if (parsed.resourceType === null || parsed.token === "") throw new ResolveError("unrecognized-input", `无法识别的文档输入:${JSON.stringify(input)}(支持 URL / doubao 同构链接 / 已知前缀的裸 token;纯描述不支持)`, parsed);
298
+ if (parsed.resourceType === "apps") throw new ResolveError("unsupported-resource", "/page/ 链接是妙搭创意模式应用,不属于文档域(lark-apps SKILL),不在 larklink 操作范围", parsed);
299
+ const named = opts.explicitProfile ?? opts.askedProfile;
300
+ if (named !== void 0) {
301
+ const binding = findBinding(registry, named);
302
+ if (binding === void 0) throw new ResolveError("binding-not-found", `绑定 "${named}" 不存在`, parsed);
303
+ if (binding.status === "incomplete") throw new ResolveError("binding-incomplete", `绑定 "${named}" 授权未完成,不可路由(Q8)`, parsed);
304
+ if (parsed.brand !== "none" && parsed.brand !== binding.brand) throw new ResolveError("brand-mismatch", `显式指定绑定 "${named}" 品牌为 ${binding.brand},与输入品牌证据 ${parsed.brand} 不符(Q5-②)`, parsed);
305
+ const confidence = opts.askedProfile !== void 0 ? "asked" : "explicit";
306
+ return {
307
+ status: "resolved",
308
+ parsed,
309
+ profile: binding.profile,
310
+ brand: binding.brand,
311
+ confidence,
312
+ bindingStatus: binding.status,
313
+ reason: parsed.brand === "none" ? `用户显式指定(输入无品牌证据,选择同时确定品牌)` : `用户显式指定(品牌证据 ${parsed.brand} 校验通过)`
314
+ };
315
+ }
316
+ const pin = findPin(registry, parsed.token);
317
+ if (pin !== void 0) {
318
+ const binding = findBinding(registry, pin.profile);
319
+ if (binding !== void 0 && binding.status !== "incomplete") return {
320
+ status: "resolved",
321
+ parsed,
322
+ profile: binding.profile,
323
+ brand: binding.brand,
324
+ confidence: "pinned",
325
+ bindingStatus: binding.status,
326
+ reason: `文档 pin 命中(记忆于 ${pin.updatedAt ?? "未知时间"};失效不自动换绑定,Q1)`
327
+ };
328
+ }
329
+ const candidates = routableBindings(registry, parsed.brand);
330
+ if (candidates.length === 0) throw new ResolveError("no-routable-binding", `无可路由绑定(${parsed.brand === "none" ? "全部绑定" : `${parsed.brand} 品牌绑定`} 中没有可用绑定;incomplete 不可路由)。若本机已有 lark-cli profile,先 larklink_bindings(action=adopt) 导入为绑定;全新机器需先在终端 larklink bindings add(app secret 不经模型)。`, parsed);
331
+ if (candidates.length === 1) {
332
+ const sole = candidates[0];
333
+ const repair = sole.status !== "ready" ? `;该绑定状态 ${sole.status},需修复授权而非换绑定` : "";
334
+ return {
335
+ status: "resolved",
336
+ parsed,
337
+ profile: sole.profile,
338
+ brand: sole.brand,
339
+ confidence: "sole",
340
+ bindingStatus: sole.status,
341
+ reason: `品牌候选集内唯一绑定(存在性判定,不看登录状态,Q3-②)${repair}`
342
+ };
343
+ }
344
+ return {
345
+ status: "ambiguous",
346
+ parsed,
347
+ candidates: candidates.map((b) => toCandidate(b, parsed.domain)),
348
+ reason: parsed.brand === "none" ? "输入无品牌证据且存在多个候选绑定,需用户选择(选择同时确定品牌并记为 pin,Q3-③)" : "同品牌多个候选绑定,需用户选择(选择记为 pin,Q1/Q3)"
349
+ };
350
+ }
351
+ // ../core/src/index.ts
352
+ var src_exports = /* @__PURE__ */ __exportAll({ parseDoc: () => parseDoc });
353
+ // ../cli/src/store.ts
354
+ /**
355
+ * registry 持久化:~/.larklink/registry.json(机器级,Q10-②)。
356
+ * LARKLINK_HOME 环境变量可重定向(测试与多实例用)。
357
+ * 并发写:文件锁(.lock,创建式)+ 原子替换(tmp + rename)。
358
+ */
359
+ function larklinkHome() {
360
+ return process.env["LARKLINK_HOME"]?.trim() || join(process.env["USERPROFILE"] ?? process.env["HOME"] ?? ".", ".larklink");
361
+ }
362
+ function registryPath(home = larklinkHome()) {
363
+ return join(home, "registry.json");
364
+ }
365
+ function auditPath(home = larklinkHome()) {
366
+ return join(home, "audit.jsonl");
367
+ }
368
+ async function loadRegistry(home = larklinkHome()) {
369
+ const file = registryPath(home);
370
+ if (!existsSync(file)) return createRegistry();
371
+ const text = await readFile(file, "utf8");
372
+ if (text.trim() === "") return createRegistry();
373
+ const parsed = safeParse(text);
374
+ if (parsed === null || typeof parsed !== "object" || parsed.version !== 1) throw new Error(`registry 文件损坏或版本不兼容:${file}(请人工检查备份后删除重建)`);
375
+ return parsed;
376
+ }
377
+ /** 保存并做结构校验(ADR-0001 appId 唯一等);返回新 registry(引用不变性由调用方维护)。 */
378
+ async function saveRegistry(registry, home = larklinkHome()) {
379
+ const issues = validateRegistry(registry);
380
+ if (issues.length > 0) {
381
+ if (issues.filter((i) => i.field.startsWith("bindings")).length > 0) throw new Error(`registry 结构校验失败,拒绝写入:\n${issues.map((i) => ` - [${i.field}] ${i.message}`).join("\n")}`);
382
+ }
383
+ const dir = home;
384
+ await mkdir(dir, { recursive: true });
385
+ await withLock(dir, async () => {
386
+ const tmp = join(dir, `registry.json.${process.pid}.tmp`);
387
+ await writeFile(tmp, JSON.stringify(registry, null, 2) + "\n", "utf8");
388
+ await rename(tmp, join(dir, "registry.json"));
389
+ });
390
+ }
391
+ const LOCK_FILE = "registry.lock";
392
+ const STALE_MS = 5e3;
393
+ async function withLock(dir, fn) {
394
+ const lockPath = join(dir, LOCK_FILE);
395
+ let handle = null;
396
+ for (let attempt = 0; attempt < 60; attempt++) try {
397
+ handle = await open(lockPath, "wx");
398
+ break;
399
+ } catch {
400
+ try {
401
+ const st = await stat(lockPath);
402
+ if (Date.now() - st.mtimeMs > STALE_MS) await rm(lockPath, { force: true });
403
+ } catch {}
404
+ await sleep(50);
405
+ }
406
+ if (handle === null) throw new Error(`registry 锁竞争超时:${lockPath}`);
407
+ try {
408
+ await fn();
409
+ } finally {
410
+ await handle.close();
411
+ await rm(lockPath, { force: true });
412
+ }
413
+ }
414
+ function sleep(ms) {
415
+ return new Promise((r) => setTimeout(r, ms));
416
+ }
417
+ function safeParse(text) {
418
+ try {
419
+ return JSON.parse(text);
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+ // ../cli/src/audit.ts
425
+ /**
426
+ * 审计日志(Q10-③:默认脱敏)。每行一个 JSON:resolve/exec 的决策与结果摘要。
427
+ * 载荷参数(--content/--data 等)不记值,只记长度与 sha1;LARKLINK_AUDIT_VERBOSE=1 记全量。
428
+ */
429
+ /** 值即文档内容/记录数据的参数(Q10-③ 脱敏对象)。 */
430
+ const PAYLOAD_FLAGS = [
431
+ "--content",
432
+ "--data",
433
+ "--body",
434
+ "--markdown",
435
+ "--html",
436
+ "--text",
437
+ "--payload",
438
+ "--blocks"
439
+ ];
440
+ function redactValue(value) {
441
+ const sha1 = createHash("sha1").update(value, "utf8").digest("hex").slice(0, 12);
442
+ return `<redacted len=${value.length} sha1=${sha1}>`;
443
+ }
444
+ function redactArgv(argv) {
445
+ const out = [];
446
+ let i = 0;
447
+ const verbose = process.env["LARKLINK_AUDIT_VERBOSE"] === "1";
448
+ while (i < argv.length) {
449
+ const arg = argv[i];
450
+ const eq = arg.indexOf("=");
451
+ const bare = eq === -1 ? arg : arg.slice(0, eq);
452
+ if (PAYLOAD_FLAGS.includes(bare)) {
453
+ if (eq !== -1) {
454
+ out.push(verbose ? arg : `${bare}=${redactValue(arg.slice(eq + 1))}`);
455
+ i += 1;
456
+ } else if (i + 1 < argv.length) {
457
+ out.push(arg);
458
+ out.push(verbose ? argv[i + 1] : redactValue(argv[i + 1]));
459
+ i += 2;
460
+ } else {
461
+ out.push(arg);
462
+ i += 1;
463
+ }
464
+ } else {
465
+ out.push(arg);
466
+ i += 1;
467
+ }
468
+ }
469
+ return out;
470
+ }
471
+ /** 追加一条审计记录;审计关闭(LARKLINK_AUDIT=off)时静默跳过。 */
472
+ async function appendAudit(entry, home = larklinkHome()) {
473
+ if (process.env["LARKLINK_AUDIT"] === "off") return;
474
+ const record = {
475
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
476
+ ...entry
477
+ };
478
+ if (record.argv !== void 0) record.argv = redactArgv(record.argv);
479
+ await mkdir(home, { recursive: true });
480
+ await appendFile(auditPath(home), JSON.stringify(record) + "\n", "utf8");
481
+ }
482
+ // ../cli/src/commands/bindings.ts
483
+ /**
484
+ * bindings 生命周期命令(design.md §6,决议 Q8/Q10)。
485
+ * 所有 lark-cli 调用经注入的 runner,便于测试 mock。
486
+ */
487
+ const stdEmit = (payload) => console.log(JSON.stringify(payload, null, 2));
488
+ /** lark-cli 状态字符串 → BindingStatus。无已登录用户 = incomplete(Q8:不可路由)。 */
489
+ function mapStatus(raw, hasUser) {
490
+ const s = (raw ?? "").toLowerCase();
491
+ if (!hasUser) return "incomplete";
492
+ if (s.includes("refresh") || s.includes("expire")) return "needs_refresh";
493
+ if (s.includes("unauthorized") || s.includes("revoked") || s.includes("invalid")) return "unauthorized";
494
+ return "ready";
495
+ }
496
+ function bindingFromProfileList(e) {
497
+ const profile = e.name;
498
+ if (!profile || !e.brand || e.brand !== "feishu" && e.brand !== "lark") return null;
499
+ const hasUser = e.user !== void 0 && e.user !== null && e.user !== "";
500
+ return {
501
+ profile,
502
+ brand: e.brand,
503
+ appId: (e.appId ?? e.app_id ?? "").toString(),
504
+ userName: userNameOf(e.user),
505
+ status: mapStatus(e.tokenStatus ?? e.token_status, hasUser),
506
+ adopted: true,
507
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
508
+ };
509
+ }
510
+ function userNameOf(user) {
511
+ if (typeof user === "string") return user || void 0;
512
+ return user?.name || void 0;
513
+ }
514
+ /** adopt:导入既有 lark-cli profile(不动原 profile,Q8-①)。 */
515
+ async function cmdAdopt(ctx, opts, emit = stdEmit) {
516
+ const home = ctx.home ?? larklinkHome();
517
+ const res = await ctx.runner(["profile", "list"], { timeoutMs: 2e4 });
518
+ const payload = safeJson(res.stdout);
519
+ const entries = Array.isArray(payload) ? payload : [];
520
+ if (entries.length === 0) {
521
+ emit({
522
+ ok: false,
523
+ code: "lark-cli-error",
524
+ message: `profile list 无结果(exit ${res.exitCode}):${res.stderr.slice(0, 300)}`
525
+ });
526
+ return 1;
527
+ }
528
+ const wanted = opts.profiles ? new Set(opts.profiles) : null;
529
+ let registry = await loadRegistry(home);
530
+ const adopted = [];
531
+ const skipped = [];
532
+ for (const e of entries) {
533
+ const b = bindingFromProfileList(e);
534
+ if (!b) {
535
+ skipped.push(e.name ?? "(未知)");
536
+ continue;
537
+ }
538
+ if (wanted && !wanted.has(b.profile)) continue;
539
+ const existing = findBinding(registry, b.profile);
540
+ registry = upsertBinding(registry, existing ? {
541
+ ...existing,
542
+ ...b,
543
+ adopted: existing.adopted ?? b.adopted
544
+ } : b);
545
+ adopted.push(b.profile);
546
+ }
547
+ await saveRegistry(registry, home);
548
+ await appendAudit({
549
+ kind: "bindings",
550
+ message: `adopt: ${adopted.join(", ") || "(none)"}`,
551
+ profile: adopted.join(",")
552
+ }, home);
553
+ emit({
554
+ ok: true,
555
+ adopted,
556
+ skipped,
557
+ hint: skipped.length ? "跳过项为无 brand/用户的 profile" : void 0
558
+ }, null, 2);
559
+ return 0;
560
+ }
561
+ function pendingAuthPath(home) {
562
+ return join(home, "pending-auth.json");
563
+ }
564
+ async function readPendingAuth(home) {
565
+ try {
566
+ const parsed = safeJson(await readFile(pendingAuthPath(home), "utf8"));
567
+ if (parsed === null || typeof parsed !== "object" || !parsed.profile || !parsed.deviceCode) return null;
568
+ return parsed;
569
+ } catch {
570
+ return null;
571
+ }
572
+ }
573
+ async function writePendingAuth(home, flow) {
574
+ await mkdir(home, { recursive: true });
575
+ await writeFile(pendingAuthPath(home), JSON.stringify(flow, null, 2) + "\n", "utf8");
576
+ }
577
+ async function clearPendingAuth(home) {
578
+ await rm(pendingAuthPath(home), { force: true });
579
+ }
580
+ /** 尽力打开系统浏览器(DSH 宿主运行在用户本机;失败不阻塞,envelope 里报告)。 */
581
+ function openInBrowser(url) {
582
+ try {
583
+ const cmd = process.platform === "win32" ? "rundll32" : process.platform === "darwin" ? "open" : "xdg-open";
584
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
585
+ const child = spawn(cmd, args, {
586
+ shell: false,
587
+ stdio: "ignore",
588
+ detached: true
589
+ });
590
+ child.on("error", () => {});
591
+ child.unref();
592
+ return true;
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ /**
598
+ * auth:split-flow 授权/恢复(Q8-②:中断后每次都发起新流程,绝不缓存授权码复用)。
599
+ * 全程无密钥经手——app secret 留在 lark-cli profile 里,lark-cli 官方 agent 契约本身就要求
600
+ * agent 展示 verification_url/二维码并自行执行 --device-code 收尾。
601
+ * - 发起:`auth login --domain <域> --no-wait --json` → verification_url + device_code + 二维码 →(默认)自动开浏览器 → 暂存待收尾
602
+ * - 收尾:`auth login --device-code <code>`(码取自参数或暂存)→ 同步绑定状态
603
+ */
604
+ async function cmdAuth(ctx, opts, emit = stdEmit) {
605
+ const home = ctx.home ?? larklinkHome();
606
+ if (opts.complete === true || opts.deviceCode !== void 0) {
607
+ let deviceCode = opts.deviceCode;
608
+ let fromStash = false;
609
+ if (deviceCode === void 0) {
610
+ const pending = await readPendingAuth(home);
611
+ if (pending === null) {
612
+ emit({
613
+ ok: false,
614
+ code: "no-pending-auth",
615
+ message: `没有进行中的授权流程(${pendingAuthPath(home)} 不存在)。先执行 auth-start 发起新流程。`
616
+ });
617
+ return 2;
618
+ }
619
+ if (pending.profile !== opts.profile) {
620
+ emit({
621
+ ok: false,
622
+ code: "pending-auth-mismatch",
623
+ message: `进行中的授权流程属于绑定 ${pending.profile},与请求的 ${opts.profile} 不符。请先对 ${pending.profile} 收尾或重新发起。`
624
+ });
625
+ return 2;
626
+ }
627
+ if (Date.parse(pending.expiresAt) <= Date.now()) {
628
+ await clearPendingAuth(home);
629
+ emit({
630
+ ok: false,
631
+ code: "pending-auth-expired",
632
+ message: `授权流程已过期(发起于 ${pending.startedAt})。请重新 auth-start 发起全新流程(Q8-②:不缓存旧授权码)。`
633
+ });
634
+ return 2;
635
+ }
636
+ deviceCode = pending.deviceCode;
637
+ fromStash = true;
638
+ }
639
+ const res = await ctx.runner([
640
+ "--profile",
641
+ opts.profile,
642
+ "auth",
643
+ "login",
644
+ "--device-code",
645
+ deviceCode,
646
+ "--json"
647
+ ], { timeoutMs: 6e4 });
648
+ if (res.exitCode !== 0) {
649
+ emit({
650
+ ok: false,
651
+ code: "lark-cli-error",
652
+ exitCode: res.exitCode,
653
+ error: safeJson(res.stderr) ?? res.stderr.slice(0, 400),
654
+ hint: "若用户尚未在浏览器完成授权,请等用户确认后重新 auth-complete(轮询重试)"
655
+ });
656
+ return 1;
657
+ }
658
+ if (fromStash) await clearPendingAuth(home);
659
+ const health = await syncBindingStatus(ctx, home, opts.profile);
660
+ await appendAudit({
661
+ kind: "bindings",
662
+ profile: opts.profile,
663
+ message: `auth complete(status=${health.status ?? "?"})`
664
+ }, home);
665
+ emit({
666
+ ok: true,
667
+ profile: opts.profile,
668
+ status: health.status,
669
+ userName: health.userName,
670
+ warning: health.warning,
671
+ next: health.status === "ready" ? "授权恢复,可继续原操作" : `状态仍为 ${health.status},可能需要检查应用配置后重新授权`
672
+ }, null, 2);
673
+ return 0;
674
+ }
675
+ const domain = opts.domain ?? "docs";
676
+ const res = await ctx.runner([
677
+ "--profile",
678
+ opts.profile,
679
+ "auth",
680
+ "login",
681
+ "--domain",
682
+ domain,
683
+ "--no-wait",
684
+ "--json"
685
+ ], { timeoutMs: 3e4 });
686
+ const payload = safeJson(res.stdout);
687
+ const errPayload = safeJson(res.stderr);
688
+ if (res.exitCode !== 0 || payload === null) {
689
+ emit({
690
+ ok: false,
691
+ code: "lark-cli-error",
692
+ exitCode: res.exitCode,
693
+ error: errPayload ?? res.stderr.slice(0, 400)
694
+ });
695
+ return 1;
696
+ }
697
+ const url = payload["verification_url"] ?? payload["url"];
698
+ const deviceCode = payload["device_code"] ?? payload["deviceCode"];
699
+ if (!url) {
700
+ emit({
701
+ ok: false,
702
+ code: "no-verification-url",
703
+ message: "lark-cli 未返回 verification_url",
704
+ payload
705
+ });
706
+ return 1;
707
+ }
708
+ const expiresInSec = typeof payload["expires_in"] === "number" ? payload["expires_in"] : 600;
709
+ let qrcode;
710
+ try {
711
+ await mkdir(home, { recursive: true });
712
+ const qrName = `qrcode-${opts.profile}.png`;
713
+ if ((await ctx.runner([
714
+ "auth",
715
+ "qrcode",
716
+ url,
717
+ "--output",
718
+ qrName
719
+ ], {
720
+ timeoutMs: 15e3,
721
+ cwd: home
722
+ })).exitCode === 0) qrcode = join(home, qrName);
723
+ } catch {}
724
+ const flow = {
725
+ profile: opts.profile,
726
+ deviceCode: deviceCode ?? "",
727
+ verificationUrl: url,
728
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
729
+ expiresAt: new Date(Date.now() + expiresInSec * 1e3).toISOString()
730
+ };
731
+ if (deviceCode !== void 0) await writePendingAuth(home, flow);
732
+ const browserOpened = opts.open !== false ? openInBrowser(url) : false;
733
+ await appendAudit({
734
+ kind: "bindings",
735
+ profile: opts.profile,
736
+ message: `auth start(domain=${domain},expiresIn=${expiresInSec}s)`
737
+ }, home);
738
+ emit({
739
+ ok: true,
740
+ profile: opts.profile,
741
+ status: "auth-started",
742
+ verificationUrl: url,
743
+ qrcode,
744
+ expiresInSeconds: expiresInSec,
745
+ browserOpened,
746
+ next: [
747
+ "1. 让用户在浏览器(已自动打开;未打开则手动访问 verificationUrl 或扫码二维码)完成授权确认",
748
+ "2. 用户确认后调用 auth-complete(device_code 已暂存,无需携带)",
749
+ "3. 授权 URL 10 分钟内有效,过期须重新 auth-start(Q8-②)"
750
+ ].join("\n")
751
+ }, null, 2);
752
+ return 0;
753
+ }
754
+ /**
755
+ * 用真实 `auth status --json --verify` 结构同步(identities.user.{userName,tokenStatus,refreshExpiresAt})。
756
+ * 无 user 身份 → incomplete(绑定=app+user,bot-only 不可作用户身份路由)。
757
+ */
758
+ async function syncBindingStatus(ctx, home, profile) {
759
+ const user = safeJson((await ctx.runner([
760
+ "--profile",
761
+ profile,
762
+ "auth",
763
+ "status",
764
+ "--json",
765
+ "--verify"
766
+ ], { timeoutMs: 2e4 })).stdout)?.identities?.user;
767
+ if (user === void 0) {
768
+ await setBindingStatus(home, profile, "incomplete");
769
+ return {
770
+ status: "incomplete",
771
+ warning: `绑定 ${profile} 无 user 身份(bot-only)→ incomplete,不可路由`
772
+ };
773
+ }
774
+ const status = mapStatus(user.tokenStatus ?? user.status, true);
775
+ const refreshExpiresAt = user.refreshExpiresAt;
776
+ let warning;
777
+ if (refreshExpiresAt !== void 0) {
778
+ const expiry = Date.parse(refreshExpiresAt);
779
+ if (!Number.isNaN(expiry)) {
780
+ const hoursLeft = (expiry - Date.now()) / 36e5;
781
+ if (hoursLeft < 0) warning = `绑定 ${profile} refresh token 已过期,需重新授权`;
782
+ else if (hoursLeft < 48) warning = `绑定 ${profile} refresh token ${hoursLeft.toFixed(1)}h 后过期,建议尽快重新授权`;
783
+ }
784
+ }
785
+ let registry = await loadRegistry(home);
786
+ const existing = findBinding(registry, profile);
787
+ if (existing) {
788
+ registry = upsertBinding(registry, {
789
+ ...existing,
790
+ status,
791
+ userName: user.userName ?? existing.userName,
792
+ openId: user.openId ?? existing.openId
793
+ });
794
+ await saveRegistry(registry, home);
795
+ }
796
+ return {
797
+ status,
798
+ userName: user.userName,
799
+ openId: user.openId,
800
+ refreshExpiresAt,
801
+ warning
802
+ };
803
+ }
804
+ async function setBindingStatus(home, profile, status) {
805
+ let registry = await loadRegistry(home);
806
+ const existing = findBinding(registry, profile);
807
+ if (!existing) return false;
808
+ registry = upsertBinding(registry, {
809
+ ...existing,
810
+ status
811
+ });
812
+ await saveRegistry(registry, home);
813
+ return true;
814
+ }
815
+ /** list / health:同步各绑定真实状态;health 额外清理死 pin(Q10-①)。 */
816
+ async function cmdList(ctx, opts, emit = stdEmit) {
817
+ const home = ctx.home ?? larklinkHome();
818
+ let registry = await loadRegistry(home);
819
+ const warnings = [];
820
+ /** 实时核验结果(openId / refreshExpiresAt 不落 registry,仅随本次信封透出)。 */
821
+ const liveInfo = /* @__PURE__ */ new Map();
822
+ if (!opts.fast) {
823
+ for (const b of registry.bindings) try {
824
+ const s = await syncBindingStatus(ctx, home, b.profile);
825
+ if (s.warning) warnings.push(s.warning);
826
+ if (s.openId !== void 0 || s.refreshExpiresAt !== void 0) liveInfo.set(b.profile, {
827
+ openId: s.openId,
828
+ refreshExpiresAt: s.refreshExpiresAt
829
+ });
830
+ if (opts.health && s.status && s.status !== "ready" && s.status !== "incomplete") warnings.push(`绑定 ${b.profile} 状态 ${s.status}:调用 larklink_bindings(action=auth-start, profile=${b.profile}) 发起登录修复`);
831
+ } catch (e) {
832
+ warnings.push(`绑定 ${b.profile} 状态同步失败:${e.message}`);
833
+ }
834
+ registry = await loadRegistry(home);
835
+ }
836
+ if (opts.health) {
837
+ const profiles = new Set(registry.bindings.map((b) => b.profile));
838
+ const dead = registry.pins.filter((p) => !profiles.has(p.profile));
839
+ if (dead.length > 0) {
840
+ for (const d of dead) registry = {
841
+ ...registry,
842
+ pins: registry.pins.filter((p) => p.token !== d.token)
843
+ };
844
+ await saveRegistry(registry, home);
845
+ warnings.push(`清理死 pin ${dead.length} 条`);
846
+ }
847
+ }
848
+ emit({
849
+ ok: true,
850
+ bindings: registry.bindings.map((b) => ({
851
+ profile: b.profile,
852
+ brand: b.brand,
853
+ appId: b.appId,
854
+ userName: b.userName,
855
+ openId: liveInfo.get(b.profile)?.openId ?? b.openId,
856
+ refreshExpiresAt: liveInfo.get(b.profile)?.refreshExpiresAt,
857
+ status: b.status,
858
+ adopted: b.adopted ?? false
859
+ })),
860
+ pins: registry.pins,
861
+ warnings
862
+ }, null, 2);
863
+ return 0;
864
+ }
865
+ /** pin:手动增删查(兜底入口)。 */
866
+ async function cmdPin(opts, home = larklinkHome(), emit = stdEmit) {
867
+ const registry = await loadRegistry(home);
868
+ if (opts.op === "list") {
869
+ emit({
870
+ ok: true,
871
+ pins: registry.pins
872
+ }, null, 2);
873
+ return 0;
874
+ }
875
+ if (!opts.token || opts.op === "add" && !opts.profile) {
876
+ emit({
877
+ ok: false,
878
+ code: "usage",
879
+ message: "pin add 需要 --token 与 --profile;remove 只需 --token"
880
+ });
881
+ return 2;
882
+ }
883
+ if (opts.op === "add") {
884
+ const token = opts.token;
885
+ const profile = opts.profile;
886
+ if (!token || !profile) {
887
+ emit({
888
+ ok: false,
889
+ code: "usage",
890
+ message: "pin add 需要 --token 与 --profile"
891
+ });
892
+ return 2;
893
+ }
894
+ if (!findBinding(registry, profile)) {
895
+ emit({
896
+ ok: false,
897
+ code: "binding-not-found",
898
+ message: `绑定 ${profile} 不存在`
899
+ });
900
+ return 2;
901
+ }
902
+ await saveRegistry(upsertPin(registry, {
903
+ token,
904
+ profile,
905
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
906
+ }), home);
907
+ emit({
908
+ ok: true,
909
+ pinned: token,
910
+ profile
911
+ });
912
+ return 0;
913
+ }
914
+ await saveRegistry({
915
+ ...registry,
916
+ pins: registry.pins.filter((p) => p.token !== opts.token)
917
+ }, home);
918
+ emit({
919
+ ok: true,
920
+ removed: opts.token && findPin(registry, opts.token) ? opts.token : null
921
+ });
922
+ return 0;
923
+ }
924
+ // ../cli/src/commands/resolve.ts
925
+ /**
926
+ * resolve 命令:core 消歧 + Q4 只读探测(标注不代选)+ pin 写入。
927
+ */
928
+ /** 只读探测(Q4:8s 超时,真实 API 调用会留租户审计痕迹;永不影响选择)。 */
929
+ function classifyProbe(result) {
930
+ if (result.timedOut) return "timeout";
931
+ if (result.exitCode === 0) return "accessible";
932
+ const raw = safeJson(result.stderr) ?? safeJson(result.stdout);
933
+ const err = raw?.error ?? raw;
934
+ const text = `${err?.type ?? ""} ${err?.message ?? ""} ${result.stderr}`.toLowerCase();
935
+ if (err?.code === 403 || text.includes("permission") || text.includes("forbidden")) return "no-permission";
936
+ if (text.includes("auth") || text.includes("token") || text.includes("login")) return "auth-failed";
937
+ return "error";
938
+ }
939
+ async function probeCandidate(runner, input, profile, timeoutMs = 8e3) {
940
+ return classifyProbe(await runner([
941
+ "--profile",
942
+ profile,
943
+ "drive",
944
+ "+inspect",
945
+ "--url",
946
+ input,
947
+ "--json"
948
+ ], { timeoutMs }));
949
+ }
950
+ async function cmdResolve(runner, opts, home = larklinkHome(), emit = stdEmit) {
951
+ let registry = await loadRegistry(home);
952
+ const result = resolve(registry, opts.input, {
953
+ explicitProfile: opts.explicitProfile,
954
+ askedProfile: opts.askedProfile
955
+ });
956
+ if (result.status === "ambiguous") {
957
+ let candidates = result.candidates;
958
+ if (opts.probe !== false) candidates = await Promise.all(result.candidates.map(async (c) => {
959
+ try {
960
+ return {
961
+ ...c,
962
+ probe: await probeCandidate(runner, opts.input, c.profile, opts.probeTimeoutMs)
963
+ };
964
+ } catch {
965
+ return {
966
+ ...c,
967
+ probe: "error"
968
+ };
969
+ }
970
+ }));
971
+ await appendAudit({
972
+ kind: "resolve",
973
+ outcome: "ambiguous",
974
+ confidence: "none",
975
+ target: result.parsed.token,
976
+ argv: [opts.input],
977
+ message: `候选:${candidates.map((c) => c.profile).join(", ")}`
978
+ }, home);
979
+ emit({
980
+ ok: true,
981
+ status: "ambiguous",
982
+ parsed: result.parsed,
983
+ candidates,
984
+ reason: result.reason,
985
+ next: `询问用户后:larklink resolve <input> --asked <profile> --pin`
986
+ }, null, 2);
987
+ return 3;
988
+ }
989
+ if (opts.pin && (result.confidence === "asked" || result.confidence === "explicit")) {
990
+ registry = upsertPin(registry, {
991
+ token: result.parsed.token,
992
+ profile: result.profile,
993
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
994
+ });
995
+ await saveRegistry(registry, home);
996
+ }
997
+ await appendAudit({
998
+ kind: "resolve",
999
+ outcome: "resolved",
1000
+ confidence: result.confidence,
1001
+ profile: result.profile,
1002
+ target: result.parsed.token,
1003
+ argv: [opts.input]
1004
+ }, home);
1005
+ emit({
1006
+ ok: true,
1007
+ status: "resolved",
1008
+ profile: result.profile,
1009
+ brand: result.brand,
1010
+ confidence: result.confidence,
1011
+ bindingStatus: result.bindingStatus,
1012
+ reason: result.reason,
1013
+ parsed: result.parsed,
1014
+ wikiUnwrapNeeded: result.parsed.wikiUnwrapNeeded,
1015
+ pinned: opts.pin === true
1016
+ }, null, 2);
1017
+ return 0;
1018
+ }
1019
+ // ../cli/src/execguard.ts
1020
+ /**
1021
+ * exec 守卫链(决议 Q5/Q6/Q7/Q9 落地):
1022
+ * - Q9:命令域白名单(文档域)
1023
+ * - Q6:执行身份只能来自工具参数 as;argv 夹带 --as 一律拒绝
1024
+ * - Q5:argv 提取目标与路由结果比对,不一致即拒;未识别形态放行(fail-open)
1025
+ * - Q6-②:confirmationId = profile+as+argv 指纹;--yes 重试必须携带一致指纹
1026
+ */
1027
+ /** Q9-①:v1 仅文档域。 */
1028
+ const EXEC_DOMAINS = /* @__PURE__ */ new Set([
1029
+ "docs",
1030
+ "drive",
1031
+ "sheets",
1032
+ "markdown",
1033
+ "slides",
1034
+ "base",
1035
+ "whiteboard",
1036
+ "wiki",
1037
+ "mindnotes"
1038
+ ]);
1039
+ /** Q5-①:从 argv 提取目标文档的白名单旗标(fail-open:未收录旗标不比对)。 */
1040
+ const TARGET_FLAGS = [
1041
+ "--doc",
1042
+ "--url",
1043
+ "--file",
1044
+ "--token"
1045
+ ];
1046
+ function domainOf(argv) {
1047
+ return (argv[0] ?? "").replace(/^:+/, "");
1048
+ }
1049
+ /** argv 中出现 --as / --as=... → 拒绝(显式声明唯一入口是工具参数 as)。 */
1050
+ function findArgvAs(argv) {
1051
+ for (let i = 0; i < argv.length; i++) {
1052
+ const a = argv[i];
1053
+ if (a === "--as") return {
1054
+ value: argv[i + 1] ?? "",
1055
+ index: i
1056
+ };
1057
+ if (a.startsWith("--as=")) return {
1058
+ value: a.slice(5),
1059
+ index: i
1060
+ };
1061
+ }
1062
+ return null;
1063
+ }
1064
+ function extractTargets(argv) {
1065
+ const out = [];
1066
+ for (let i = 0; i < argv.length; i++) {
1067
+ const a = argv[i];
1068
+ const eq = a.indexOf("=");
1069
+ const bare = eq === -1 ? a : a.slice(0, eq);
1070
+ if (TARGET_FLAGS.includes(bare)) {
1071
+ if (eq !== -1) out.push({
1072
+ flag: bare,
1073
+ value: a.slice(eq + 1)
1074
+ });
1075
+ else if (i + 1 < argv.length) out.push({
1076
+ flag: bare,
1077
+ value: argv[i + 1]
1078
+ });
1079
+ }
1080
+ }
1081
+ return out;
1082
+ }
1083
+ /**
1084
+ * Q5-① 目标一致性:能解析出的目标 token 与路由 token 全量比对。
1085
+ * 解析不出 token 的目标值放行(fail-open,M3 扩白名单)。
1086
+ */
1087
+ function checkTargets(targets, resolved) {
1088
+ for (const t of targets) {
1089
+ if (t.value === resolved.token || t.value === resolved.input) continue;
1090
+ const p = parseDoc(t.value);
1091
+ if (p.token === "") continue;
1092
+ if (p.token !== resolved.token) return {
1093
+ ok: false,
1094
+ error: {
1095
+ code: "target-mismatch",
1096
+ message: `argv ${t.flag} 的目标与路由结果不符:路由=${resolved.input}(token ${resolved.token}),命令指向 ${t.value}(token ${p.token})。拒绝执行,防错账写入。`
1097
+ }
1098
+ };
1099
+ }
1100
+ return { ok: true };
1101
+ }
1102
+ /** Q6-② 确认指纹:覆盖 profile + 执行身份 + 去除确认旗标后的最终 argv。 */
1103
+ function computeConfirmationId(profile, as, argv) {
1104
+ return createHash("sha256").update(JSON.stringify({
1105
+ profile,
1106
+ as,
1107
+ argv
1108
+ }), "utf8").digest("hex").slice(0, 32);
1109
+ }
1110
+ // ../cli/src/commands/exec.ts
1111
+ /**
1112
+ * exec 命令:守卫链 + 信封透传(决议 Q5/Q6/Q7/Q9 全量落地)。
1113
+ * 退出码:0 成功 | 1 lark-cli 业务错误 | 2 larklink 守卫/路由错误 | 3 歧义待选择
1114
+ * | 10 needsConfirmation(镜像 lark-cli) | 11 timeout-unknown
1115
+ */
1116
+ async function cmdExec(runner, opts, home = larklinkHome(), emit = stdEmit) {
1117
+ const fail = (code, message, extra) => {
1118
+ emit({
1119
+ ok: false,
1120
+ code,
1121
+ message,
1122
+ ...extra
1123
+ }, null, 2);
1124
+ return 2;
1125
+ };
1126
+ const rawArgs = opts.rawArgs;
1127
+ if (rawArgs.length === 0) return fail("usage", "缺少命令:exec -- <lark-cli 命令参数…>");
1128
+ const domain = domainOf(rawArgs);
1129
+ if (!EXEC_DOMAINS.has(domain)) return fail("domain-not-allowed", `域 "${domain}" 不在文档域白名单(Q9-①):${[...EXEC_DOMAINS].join(", ")}。其他域请直接使用 lark-cli。`);
1130
+ const argvAs = findArgvAs(rawArgs);
1131
+ if (argvAs !== null) return fail("argv-as-forbidden", `argv 中的 --as(${argvAs.value || "…"})被拒绝:执行身份只能用 exec 的 --as 参数显式声明(Q6-①)`);
1132
+ const as = opts.as ?? "user";
1133
+ const registry = await loadRegistry(home);
1134
+ let profile;
1135
+ let confidence;
1136
+ let routedToken = null;
1137
+ if (opts.url !== void 0) try {
1138
+ const result = resolve(registry, opts.url, { explicitProfile: opts.profile });
1139
+ if (result.status === "ambiguous") {
1140
+ emit({
1141
+ ok: false,
1142
+ code: "ambiguous",
1143
+ message: result.reason,
1144
+ candidates: result.candidates,
1145
+ parsed: result.parsed,
1146
+ next: "larklink resolve 后用 --profile 显式指定"
1147
+ }, null, 2);
1148
+ return 3;
1149
+ }
1150
+ profile = result.profile;
1151
+ confidence = result.confidence;
1152
+ routedToken = result.parsed.token;
1153
+ if (result.bindingStatus !== "ready") {
1154
+ await appendAudit({
1155
+ kind: "exec",
1156
+ profile,
1157
+ outcome: "blocked-auth",
1158
+ confidence,
1159
+ domain,
1160
+ as
1161
+ }, home);
1162
+ emit({
1163
+ ok: false,
1164
+ code: "binding-needs-repair",
1165
+ profile,
1166
+ bindingStatus: result.bindingStatus,
1167
+ message: `绑定 ${profile} 状态 ${result.bindingStatus},需修复授权(不自动换绑定,Q1)`,
1168
+ fix: `立即调用 larklink_bindings(action=auth-start, profile=${profile}) 发起登录:自动打开浏览器并生成二维码;用户确认后 auth-complete,然后重试本命令`
1169
+ }, null, 2);
1170
+ return 2;
1171
+ }
1172
+ } catch (e) {
1173
+ const err = e;
1174
+ emit({
1175
+ ok: false,
1176
+ code: err.code ?? "resolve-error",
1177
+ message: err.message
1178
+ }, null, 2);
1179
+ return 2;
1180
+ }
1181
+ else if (opts.profile !== void 0) {
1182
+ const binding = findBinding(registry, opts.profile);
1183
+ if (!binding) return fail("binding-not-found", `绑定 ${opts.profile} 不存在`);
1184
+ if (binding.status === "incomplete") return fail("binding-incomplete", `绑定 ${opts.profile} 授权未完成(Q8)`);
1185
+ profile = opts.profile;
1186
+ confidence = "explicit";
1187
+ } else return fail("usage", "需要 --url(自动路由)或 --profile(显式指定)之一");
1188
+ if (routedToken !== null) {
1189
+ const check = checkTargets(extractTargets(rawArgs), (await Promise.resolve().then(() => src_exports)).parseDoc(opts.url));
1190
+ if (!check.ok) {
1191
+ await appendAudit({
1192
+ kind: "exec",
1193
+ profile,
1194
+ outcome: "blocked-target-mismatch",
1195
+ confidence,
1196
+ domain,
1197
+ as,
1198
+ argv: rawArgs
1199
+ }, home);
1200
+ emit({
1201
+ ok: false,
1202
+ code: check.error.code,
1203
+ message: check.error.message
1204
+ }, null, 2);
1205
+ return 2;
1206
+ }
1207
+ }
1208
+ const confirmationId = computeConfirmationId(profile, as, rawArgs);
1209
+ if (opts.yes === true) {
1210
+ if (!opts.confirmationId) return fail("confirmation-id-required", "--yes 必须携带首次 needsConfirmation 返回的 --confirmation-id(Q6-②)");
1211
+ if (opts.confirmationId !== confirmationId) return fail("confirmation-id-mismatch", `确认指纹不符:本次命令与用户确认的不是同一条请求(期望 ${confirmationId},收到 ${opts.confirmationId})。确认不可挪用。`);
1212
+ }
1213
+ const finalArgv = [
1214
+ "--profile",
1215
+ profile,
1216
+ ...rawArgs,
1217
+ "--as",
1218
+ as,
1219
+ ...opts.yes ? ["--yes"] : []
1220
+ ];
1221
+ if (opts.dryRun) {
1222
+ emit({
1223
+ ok: true,
1224
+ status: "dry-run",
1225
+ profile,
1226
+ confidence,
1227
+ as,
1228
+ argv: finalArgv,
1229
+ confirmationId
1230
+ }, null, 2);
1231
+ return 0;
1232
+ }
1233
+ const result = await runner(finalArgv, { timeoutMs: opts.timeoutMs });
1234
+ const envelope = parseEnvelope(result);
1235
+ const errObj = envelope.error ?? {};
1236
+ const isConfirmation = result.exitCode === 10 || errObj["type"] === "confirmation";
1237
+ let status;
1238
+ let exit;
1239
+ if (result.timedOut) {
1240
+ status = "timeout-unknown";
1241
+ exit = 11;
1242
+ } else if (isConfirmation && opts.yes !== true) {
1243
+ status = "needs-confirmation";
1244
+ exit = 10;
1245
+ } else if (envelope.ok) {
1246
+ status = "success";
1247
+ exit = 0;
1248
+ } else {
1249
+ status = "error";
1250
+ exit = 1;
1251
+ }
1252
+ await appendAudit({
1253
+ kind: "exec",
1254
+ profile,
1255
+ confidence,
1256
+ domain,
1257
+ as,
1258
+ outcome: status,
1259
+ durationMs: result.durationMs,
1260
+ argv: finalArgv
1261
+ }, home);
1262
+ const out = {
1263
+ ok: status === "success",
1264
+ status,
1265
+ profile,
1266
+ confidence,
1267
+ as,
1268
+ argv: finalArgv,
1269
+ durationMs: result.durationMs,
1270
+ exitCode: result.exitCode,
1271
+ result: envelope.payload ?? safeJson(result.stdout),
1272
+ error: envelope.error
1273
+ };
1274
+ if (status === "needs-confirmation") {
1275
+ out["confirmationId"] = confirmationId;
1276
+ out["next"] = `用户同意后:原命令 + --yes --confirmation-id ${confirmationId}`;
1277
+ out["message"] = "高风险操作需用户确认(exit 10 门禁透传)。用户看到的就是这条 argv,同意的也是它。";
1278
+ }
1279
+ if (status === "timeout-unknown") out["message"] = "超时被击杀,服务端结果未知(Q7):禁止直接重试同 argv;先只读核查(如 drive +inspect)确认实际状态。";
1280
+ emit(out, null, 2);
1281
+ return exit;
1282
+ }
1283
+ // src/tools.ts
1284
+ /**
1285
+ * larklink 工具注册:三个模型可见工具,全部薄适配到 CLI 命令函数(同一执行引擎)。
1286
+ * 信封经 emit 缝捕获后作为工具结果返回。
1287
+ */
1288
+ /** defineTool 泛型按字面量收窄过严(动态 spec 推断为 never);宽松壳仅放宽类型,运行时原样。 */
1289
+ const defineToolLoose = defineTool;
1290
+ /** 捕获 emit 信封(工具结果)。 */
1291
+ function capture() {
1292
+ let value;
1293
+ const emit = (p) => {
1294
+ value = p;
1295
+ };
1296
+ return {
1297
+ emit,
1298
+ payload: () => value
1299
+ };
1300
+ }
1301
+ function asStringArray(value, field) {
1302
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) throw new Error(`${field} 必须是字符串数组(lark-cli 命令参数,逐个元素)`);
1303
+ return value;
1304
+ }
1305
+ function registerLarklinkTools(deps) {
1306
+ const { ctx, runner, config } = deps;
1307
+ const home = config.home;
1308
+ const disposers = [];
1309
+ const register = (spec) => {
1310
+ const dispose = ctx.tools.register(defineToolLoose({
1311
+ output: {
1312
+ schema: {
1313
+ type: "object",
1314
+ additionalProperties: true,
1315
+ description: "larklink JSON envelope"
1316
+ },
1317
+ render: (_args, value) => [{
1318
+ type: "text",
1319
+ text: JSON.stringify(value, null, 2)
1320
+ }]
1321
+ },
1322
+ ...spec
1323
+ }));
1324
+ disposers.push(typeof dispose === "function" ? dispose : () => {});
1325
+ };
1326
+ register({
1327
+ name: "larklink_resolve",
1328
+ description: "Resolve which lark/feishu account (binding) should operate on a document URL or bare token, and manage lark-cli login lifecycle. Use PROACTIVELY whenever the user mentions a feishu.cn / larksuite.com / larkoffice.com / doubao.com document link or a bare token (doxcn/shtcn/wikcn/bascn/...), or when any lark-cli/larklink operation reports auth failure, token expiry, or not-logged-in — regardless of how many bindings exist. Returns the routing decision (profile, confidence, brand) or an ambiguous candidate list with read-only probe annotations (accessible / no-permission / auth-failed / timeout). Never auto-picks among multiple candidates — ask the user.",
1329
+ parameters: {
1330
+ input: {
1331
+ type: "string",
1332
+ required: true,
1333
+ description: "Document URL or bare token (doxcn/shtcn/wikcn/bascn/...)"
1334
+ },
1335
+ explicitProfile: {
1336
+ type: "string",
1337
+ description: "User explicitly named the binding to use this turn."
1338
+ },
1339
+ askedProfile: {
1340
+ type: "string",
1341
+ description: "The user's answer to an ambiguity question (records confidence=asked)."
1342
+ },
1343
+ pin: {
1344
+ type: "boolean",
1345
+ description: "Remember the asked/explicit choice for this document token (document-level preference)."
1346
+ },
1347
+ probe: {
1348
+ type: "boolean",
1349
+ description: "Read-only probe of ambiguous candidates via drive +inspect (default true)."
1350
+ }
1351
+ },
1352
+ async execute(args) {
1353
+ const cap = capture();
1354
+ await cmdResolve(runner, {
1355
+ input: String(args["input"]),
1356
+ explicitProfile: args["explicitProfile"] !== void 0 ? String(args["explicitProfile"]) : void 0,
1357
+ askedProfile: args["askedProfile"] !== void 0 ? String(args["askedProfile"]) : void 0,
1358
+ pin: args["pin"] === true,
1359
+ probe: args["probe"] !== false,
1360
+ probeTimeoutMs: config.probeTimeoutMs
1361
+ }, home, cap.emit);
1362
+ return cap.payload();
1363
+ }
1364
+ });
1365
+ register({
1366
+ name: "larklink_exec",
1367
+ description: "Execute a guarded lark-cli document-domain command (docs/drive/sheets/markdown/slides/base/whiteboard/wiki/mindnotes) as the resolved or explicitly named binding. Use for every feishu/lark document read/write operation (single OR multiple bindings). Enforces: target consistency vs routing, no --as smuggling (use the as param), exit-10 confirmation fingerprint binding, timeout-unknown reporting. Prefer larklink_resolve first. On binding-needs-repair, run larklink_bindings auth-start immediately instead of asking the user to use a terminal.",
1368
+ parameters: {
1369
+ args: {
1370
+ type: "array",
1371
+ items: { type: "string" },
1372
+ required: true,
1373
+ description: "lark-cli command after the profile flag, e.g. [\"docs\",\"+update\",\"--doc\",\"<url>\",\"--content\",\"…\"]"
1374
+ },
1375
+ url: {
1376
+ type: "string",
1377
+ description: "Document URL/token being operated on (enables routing + target guard)."
1378
+ },
1379
+ profile: {
1380
+ type: "string",
1381
+ description: "Explicit binding name (skips routing)."
1382
+ },
1383
+ as: {
1384
+ type: "string",
1385
+ enum: ["user", "bot"],
1386
+ description: "Execution identity; default user; bot requires explicit user request."
1387
+ },
1388
+ yes: {
1389
+ type: "boolean",
1390
+ description: "Confirmed retry after needs-confirmation; must carry the matching confirmationId."
1391
+ },
1392
+ confirmationId: {
1393
+ type: "string",
1394
+ description: "The confirmationId from the needs-confirmation envelope."
1395
+ },
1396
+ dryRun: {
1397
+ type: "boolean",
1398
+ description: "Return the final argv without touching lark-cli."
1399
+ }
1400
+ },
1401
+ async execute(args) {
1402
+ const cap = capture();
1403
+ await cmdExec(runner, {
1404
+ rawArgs: asStringArray(args["args"], "args"),
1405
+ url: args["url"] !== void 0 ? String(args["url"]) : void 0,
1406
+ profile: args["profile"] !== void 0 ? String(args["profile"]) : void 0,
1407
+ as: args["as"] === "bot" ? "bot" : "user",
1408
+ yes: args["yes"] === true,
1409
+ confirmationId: args["confirmationId"] !== void 0 ? String(args["confirmationId"]) : void 0,
1410
+ dryRun: args["dryRun"] === true,
1411
+ timeoutMs: config.execTimeoutMs
1412
+ }, home, cap.emit);
1413
+ return cap.payload();
1414
+ }
1415
+ });
1416
+ register({
1417
+ name: "larklink_bindings",
1418
+ description: "Binding management and lark-cli login lifecycle. Actions: adopt existing lark-cli profiles as larklink bindings; list bindings with live status; health sync (also cleans dead pins); manage document pins; auth-start initiates the QR login flow for a binding (auto-opens the browser, returns verification URL + QR code path; run it IMMEDIATELY whenever resolve/exec reports binding-needs-repair, auth-failed, needs_refresh, unauthorized, or incomplete — do not send the user to a terminal); auth-complete finishes login after the user confirms in the browser (device code is stashed server-side; no code needed). Only adding new apps (app secret via stdin) and removing bindings stay in the user's terminal (larklink bindings add|remove).",
1419
+ parameters: {
1420
+ action: {
1421
+ type: "string",
1422
+ required: true,
1423
+ enum: [
1424
+ "adopt",
1425
+ "list",
1426
+ "health",
1427
+ "pin-add",
1428
+ "pin-remove",
1429
+ "pin-list",
1430
+ "auth-start",
1431
+ "auth-complete"
1432
+ ],
1433
+ description: "Management action."
1434
+ },
1435
+ profile: {
1436
+ type: "string",
1437
+ description: "Binding profile name (adopt filter / pin target / auth-start & auth-complete target)."
1438
+ },
1439
+ token: {
1440
+ type: "string",
1441
+ description: "Document token for pin-add / pin-remove."
1442
+ },
1443
+ fast: {
1444
+ type: "boolean",
1445
+ description: "list: skip live status verification."
1446
+ },
1447
+ domain: {
1448
+ type: "string",
1449
+ description: "auth-start: lark-cli scope domains, comma-separated (default docs)."
1450
+ },
1451
+ open: {
1452
+ type: "boolean",
1453
+ description: "auth-start: open the browser at the verification URL (default true)."
1454
+ }
1455
+ },
1456
+ async execute(args) {
1457
+ const cap = capture();
1458
+ const action = String(args["action"]);
1459
+ const profile = args["profile"] !== void 0 ? String(args["profile"]) : void 0;
1460
+ switch (action) {
1461
+ case "adopt":
1462
+ await cmdAdopt({
1463
+ runner,
1464
+ home
1465
+ }, { profiles: profile ? [profile] : void 0 }, cap.emit);
1466
+ break;
1467
+ case "list":
1468
+ case "health":
1469
+ await cmdList({
1470
+ runner,
1471
+ home
1472
+ }, {
1473
+ fast: args["fast"] === true,
1474
+ health: action === "health"
1475
+ }, cap.emit);
1476
+ break;
1477
+ case "pin-add":
1478
+ case "pin-remove": {
1479
+ const token = args["token"];
1480
+ if (token === void 0 || profile === void 0) throw new Error("pin-add / pin-remove 需要 token 与 profile");
1481
+ await cmdPin({
1482
+ op: action === "pin-add" ? "add" : "remove",
1483
+ token: String(token),
1484
+ profile
1485
+ }, home, cap.emit);
1486
+ break;
1487
+ }
1488
+ case "pin-list":
1489
+ await cmdPin({ op: "list" }, home, cap.emit);
1490
+ break;
1491
+ case "auth-start":
1492
+ case "auth-complete":
1493
+ if (profile === void 0) throw new Error(`${action} 需要 profile`);
1494
+ await cmdAuth({
1495
+ runner,
1496
+ home
1497
+ }, {
1498
+ profile,
1499
+ domain: args["domain"] !== void 0 ? String(args["domain"]) : void 0,
1500
+ complete: action === "auth-complete",
1501
+ open: args["open"] !== false
1502
+ }, cap.emit);
1503
+ break;
1504
+ default: throw new Error(`未知 action: ${action}`);
1505
+ }
1506
+ return cap.payload();
1507
+ }
1508
+ });
1509
+ return disposers;
1510
+ }
1511
+ // src/routes.ts
1512
+ const ROUTE_BASE = "/larklink";
1513
+ function sendJson(res, status, body) {
1514
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
1515
+ res.end(JSON.stringify(body));
1516
+ }
1517
+ /** 同源栅栏:origin/host 不一致或跨站 sec-fetch 拒绝(照 browser-skill fence 语义)。 */
1518
+ function fenceViolation(req) {
1519
+ const origin = req.headers["origin"];
1520
+ if (typeof origin === "string" && origin !== "") try {
1521
+ const o = new URL(origin);
1522
+ const host = req.headers["host"];
1523
+ if (o.host !== host) return "origin mismatch";
1524
+ } catch {
1525
+ return "bad origin";
1526
+ }
1527
+ const site = req.headers["sec-fetch-site"];
1528
+ if (typeof site === "string" && site !== "same-origin" && site !== "none") return `cross-site ${site}`;
1529
+ }
1530
+ function readBody(req) {
1531
+ return new Promise((resolve, reject) => {
1532
+ let data = "";
1533
+ let done = false;
1534
+ /** 单次收尾:防 end/error/超时重复触发;清掉超时定时器。 */
1535
+ const finish = (settle) => {
1536
+ if (done) return;
1537
+ done = true;
1538
+ clearTimeout(timer);
1539
+ settle();
1540
+ };
1541
+ const timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error("body timeout"))), 5e3);
1542
+ req.on("data", (c) => {
1543
+ data += c.toString("utf8");
1544
+ if (data.length > 65536) finish(() => reject(/* @__PURE__ */ new Error("body too large")));
1545
+ });
1546
+ req.on("end", () => finish(() => resolve(data)));
1547
+ req.on("error", (e) => finish(() => reject(e)));
1548
+ });
1549
+ }
1550
+ function captureEmit() {
1551
+ let value;
1552
+ const emit = (p) => {
1553
+ value = p;
1554
+ };
1555
+ return {
1556
+ emit,
1557
+ payload: () => value
1558
+ };
1559
+ }
1560
+ function safeParseBody(text) {
1561
+ try {
1562
+ const parsed = JSON.parse(text);
1563
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
1564
+ } catch {
1565
+ return {};
1566
+ }
1567
+ }
1568
+ function registerLarklinkRoutes(deps) {
1569
+ const { ctx, runner, home } = deps;
1570
+ let disposed = false;
1571
+ const disposers = [];
1572
+ ctx.inject(["webServer"], (injected) => {
1573
+ const webServer = injected.get("webServer");
1574
+ if (webServer === null || typeof webServer.register !== "function") return () => {};
1575
+ if (disposed) return () => {};
1576
+ const route = (method, path, handler) => {
1577
+ const dispose = webServer.register({
1578
+ kind: "exact",
1579
+ path,
1580
+ handler: (req, res) => {
1581
+ const violation = fenceViolation(req);
1582
+ if (violation !== void 0) {
1583
+ sendJson(res, 403, {
1584
+ ok: false,
1585
+ error: `forbidden: ${violation}`
1586
+ });
1587
+ return;
1588
+ }
1589
+ if ((req.method ?? "GET") !== method) {
1590
+ sendJson(res, 405, {
1591
+ ok: false,
1592
+ error: "method not allowed"
1593
+ });
1594
+ return;
1595
+ }
1596
+ Promise.resolve(handler(req, res)).catch((e) => {
1597
+ sendJson(res, 500, {
1598
+ ok: false,
1599
+ error: e.message
1600
+ });
1601
+ });
1602
+ }
1603
+ });
1604
+ disposers.push(typeof dispose === "function" ? dispose : () => {});
1605
+ };
1606
+ route("GET", `${ROUTE_BASE}/bindings`, async (req, res) => {
1607
+ await readBody(req).catch(() => "");
1608
+ const live = new URL(req.url ?? "/", "http://localhost").searchParams.get("live") === "1";
1609
+ const cap = captureEmit();
1610
+ await cmdList({
1611
+ runner,
1612
+ home
1613
+ }, {
1614
+ fast: !live,
1615
+ health: false
1616
+ }, cap.emit);
1617
+ sendJson(res, 200, cap.payload());
1618
+ });
1619
+ route("POST", `${ROUTE_BASE}/adopt`, async (req, res) => {
1620
+ await readBody(req);
1621
+ const cap = captureEmit();
1622
+ await cmdAdopt({
1623
+ runner,
1624
+ home
1625
+ }, {}, cap.emit);
1626
+ sendJson(res, 200, cap.payload());
1627
+ });
1628
+ route("POST", `${ROUTE_BASE}/health`, async (req, res) => {
1629
+ await readBody(req);
1630
+ const cap = captureEmit();
1631
+ await cmdList({
1632
+ runner,
1633
+ home
1634
+ }, { health: true }, cap.emit);
1635
+ sendJson(res, 200, cap.payload());
1636
+ });
1637
+ const authRoute = (complete) => async (req, res) => {
1638
+ const body = safeParseBody(await readBody(req).catch(() => ""));
1639
+ const profile = typeof body["profile"] === "string" ? body["profile"] : void 0;
1640
+ if (profile === void 0 || profile === "") {
1641
+ sendJson(res, 400, {
1642
+ ok: false,
1643
+ error: "需要 JSON body { profile }"
1644
+ });
1645
+ return;
1646
+ }
1647
+ const cap = captureEmit();
1648
+ await cmdAuth({
1649
+ runner,
1650
+ home
1651
+ }, {
1652
+ profile,
1653
+ complete,
1654
+ open: !complete
1655
+ }, cap.emit);
1656
+ sendJson(res, 200, cap.payload());
1657
+ };
1658
+ route("POST", `${ROUTE_BASE}/auth-start`, authRoute(false));
1659
+ route("POST", `${ROUTE_BASE}/auth-complete`, authRoute(true));
1660
+ return () => {
1661
+ for (const d of [...disposers].reverse()) d();
1662
+ disposers.length = 0;
1663
+ };
1664
+ });
1665
+ return () => {
1666
+ disposed = true;
1667
+ for (const d of [...disposers].reverse()) d();
1668
+ disposers.length = 0;
1669
+ };
1670
+ }
1671
+ // src/skill-content.ts
1672
+ /** 内嵌 SKILL 内容(随插件注册到 DSH skills 服务,source: bundled)。 */
1673
+ const LARKLINK_SKILL_NAME = "larklink";
1674
+ const LARKLINK_SKILL_DESCRIPTION = "Feishu/Lark document operations + login lifecycle, routed through larklink tools. Use PROACTIVELY, regardless of how many accounts are bound, whenever: (1) the user provides a feishu.cn / larksuite.com / larkoffice.com / doubao.com document link or a bare token (doxcn/shtcn/wikcn/bascn/doccn/fldcn/obcn...), or asks to read/edit/create any Feishu/Lark document, sheet, base, wiki or slides; (2) any lark-cli or lark-* skill operation fails with auth errors, token expiry, needs_refresh, unauthorized, or not-logged-in — larklink triggers the QR login flow automatically (opens the browser; no terminal needed).";
1675
+ const LARKLINK_SKILL_MARKDOWN = `# larklink —— 飞书/Lark 文档操作路由与登录管理
1676
+
1677
+ ## 何时使用(无条件触发,不看绑定数量)
1678
+
1679
+ - 用户给出 feishu.cn / larksuite.com / larkoffice.com / doubao.com 的文档链接,或 doxcn / doccn / wikcn / shtcn / bascn / fldcn / obcn 裸 token,要求读取或修改
1680
+ - 用户要求创建/编辑飞书文档、表格、Base、知识库、幻灯片
1681
+ - 任何 lark-cli / lark-* skill 操作报授权错误(未登录 / token 过期 / needs_refresh / unauthorized / incomplete)——立即走本 skill 的登录修复流程
1682
+
1683
+ **单一绑定也要走 larklink**:larklink 统一提供路由、授权生命周期管理(自动触发扫码登录)、目标守卫与审计。不要因为"只有一个账号"就绕过。
1684
+
1685
+ ## 硬纪律(多账号防串号)
1686
+
1687
+ 1. 一切文档域操作必须 \`larklink_resolve\` → \`larklink_exec\`,**禁止**直接调用 lark-cli 文档命令(多账号时会静默打到默认账号,单账号时绕过授权管理与审计)。
1688
+ 2. 身份选择优先级:用户显式指定 > 文档 pin > 唯一绑定 > 询问用户。**能力 ≠ 意图**:探测结果、ready 状态、tenantHint 永不构成自动换号理由。绑定失效 → 走登录修复流程,绝不换绑定重试。
1689
+ 3. 执行身份默认 user;以应用身份(bot)执行必须用户明确要求(\`as: "bot"\`),绝不静默回退。
1690
+
1691
+ ## 标准流程
1692
+
1693
+ 1. \`larklink_resolve(input)\` → \`resolved\`(profile / confidence / wikiUnwrapNeeded)或 \`ambiguous\`(candidates 含 probe 标注:accessible / no-permission / auth-failed / timeout)
1694
+ - 报 \`no-routable-binding\` 且本机有 lark-cli profile:先 \`larklink_bindings(action=adopt)\` 导入再重试 resolve
1695
+ - 全新机器(无任何 profile):提示用户在终端执行 \`larklink bindings add\`(app secret 不经模型)
1696
+ 2. ambiguous → 把候选与标注呈现给用户选择;选择后 \`larklink_resolve(input, askedProfile, pin=true)\` 记住偏好
1697
+ 3. \`larklink_exec(args, url 或 profile)\` 执行 lark-cli 文档命令,返回信封:
1698
+ - \`needs-confirmation\`:向用户展示 action 与参数预览;用户明确同意后,**原样命令** + \`yes=true\` + 返回的 \`confirmationId\`(指纹不符会被拒绝——确认不可挪用)
1699
+ - \`timeout-unknown\`:禁止直接重试同命令;先只读核查(drive +inspect / docs +fetch)确认服务端实际状态
1700
+ - \`error\`:读 error 字段修复参数或授权后重试
1701
+ 4. wiki 链接(wikcn…)先 \`drive +inspect\` 解包出真实 token/type 再路由到对应命令
1702
+
1703
+ ## 登录修复(未登录/授权失效时自动触发,不请用户去终端)
1704
+
1705
+ 只要 resolve / exec / 探测出现 \`binding-needs-repair\`、\`auth-failed\`、\`needs_refresh\`、\`unauthorized\`、\`incomplete\` 任一信号,立即:
1706
+
1707
+ 1. \`larklink_bindings(action=auth-start, profile=<绑定>)\` —— 自动打开系统浏览器并生成二维码(envelope 含 verificationUrl、qrcode 路径、expiresInSeconds)。把 verificationUrl 作为可点链接给用户;若环境支持读图,用 read_image 展示 qrcode PNG;浏览器没自动打开时提醒用户手动点链接或扫码
1708
+ 2. 等用户在浏览器完成授权确认(10 分钟内有效)。用户回复完成后:\`larklink_bindings(action=auth-complete, profile=<绑定>)\`(device_code 已在插件侧暂存,无需携带;过期会报 pending-auth-expired,届时重新 auth-start)
1709
+ 3. auth-complete 返回 ready 后**原样重试**之前的操作
1710
+
1711
+ 注意:auth-complete 失败通常意味着用户还没在浏览器确认——提示用户确认后再次 auth-complete,不要立刻重新发起。绝不静默换绑定。
1712
+
1713
+ ## 写操作纪律
1714
+
1715
+ - \`overwrite\` / 整段替换前**必须先 fetch 最新内容**做增量合并;优先 str_replace 类局部修改(目标文本不存在会失败而非误覆盖)
1716
+ - 批量多文档:逐个 resolve+exec,失败不中断,最后汇总成功/失败清单
1717
+ - 超时(timeout-unknown)后先核查再决定,禁止盲目重试
1718
+
1719
+ ## 命令域白名单(v1)
1720
+
1721
+ docs · drive · sheets · markdown · slides · base · whiteboard · wiki · mindnotes
1722
+
1723
+ 其他域(im / mail / task / calendar…)不经 larklink,直接用 lark-cli / lark-* skills。
1724
+
1725
+ ## 绑定管理
1726
+
1727
+ - \`larklink_bindings\` 工具动作:adopt(导入既有 profile)/ list / health / pin 管理 / **auth-start / auth-complete(扫码登录与授权修复,无密钥经手)**
1728
+ - 仅以下操作让用户在终端执行:\`larklink bindings add\`(新建应用,app secret 走 stdin)、\`larklink bindings remove\`(破坏性删除)
1729
+ `;
1730
+ // src/index.ts
1731
+ /**
1732
+ * @timeryu/dsh-larklink —— DSH 插件入口(cordis 契约:name / inject / Config / apply)。
1733
+ * 工具注册 + 内嵌 SKILL 注册;所有命令走 CLI 层同一执行引擎(Q5-Q9 守卫全量生效)。
1734
+ */
1735
+ const name = "@timeryu/dsh-larklink";
1736
+ const inject = ["tools"];
1737
+ /** 运行时配置(cordis 校验并填充默认值)。 */
1738
+ const Config = Schema.object({
1739
+ home: Schema.string().default("").description("larklink 主目录覆盖(默认 ~/.larklink;测试/多实例用)。"),
1740
+ execTimeoutMs: Schema.number().default(12e4).description("exec 默认超时(毫秒);超时按 timeout-unknown 处理。"),
1741
+ probeTimeoutMs: Schema.number().default(8e3).description("歧义候选只读探测超时(毫秒)。")
1742
+ });
1743
+ function apply(ctx, config = {}, options = {}) {
1744
+ const disposers = [];
1745
+ disposers.push(...registerLarklinkTools({
1746
+ ctx,
1747
+ runner: options.runner ?? defaultLarkRunner,
1748
+ config: {
1749
+ home: config.home || void 0,
1750
+ execTimeoutMs: config.execTimeoutMs,
1751
+ probeTimeoutMs: config.probeTimeoutMs
1752
+ }
1753
+ }));
1754
+ const skills = ctx.get("skills");
1755
+ if (skills != null && typeof skills.register === "function") {
1756
+ const unregister = skills.register({
1757
+ name: LARKLINK_SKILL_NAME,
1758
+ description: LARKLINK_SKILL_DESCRIPTION,
1759
+ content: LARKLINK_SKILL_MARKDOWN,
1760
+ source: "bundled"
1761
+ });
1762
+ if (typeof unregister === "function") disposers.push(unregister);
1763
+ }
1764
+ disposers.push(registerLarklinkRoutes({
1765
+ ctx,
1766
+ runner: options.runner ?? defaultLarkRunner,
1767
+ home: config.home || void 0
1768
+ }));
1769
+ ctx.effect(() => () => {
1770
+ for (const dispose of [...disposers].reverse()) dispose();
1771
+ });
1772
+ }
1773
+ export { Config, apply, inject, name };