@evandu/super-pi 0.1.131 → 0.1.132

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 (60) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-feishu/index.ts +88 -0
  3. package/agents/super-pi-knowledge/index.ts +1 -1
  4. package/agents/super-pi-task/core.ts +177 -0
  5. package/agents/super-pi-task/index.ts +59 -0
  6. package/dist/client/.super-pi-client-sources.json +11 -8
  7. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  8. package/dist/client/assets/{dist-C9AkgNbt.js → dist--igDAzRS.js} +1 -1
  9. package/dist/client/assets/{dist-DP2ygAFF.js → dist-60usx__o.js} +1 -1
  10. package/dist/client/assets/{dist-B448EWTM.js → dist-8pdzh6h1.js} +1 -1
  11. package/dist/client/assets/{dist-lQWWWxh9.js → dist-B8OZ-n_C.js} +1 -1
  12. package/dist/client/assets/{dist-hfpd-aWa.js → dist-BAJCrbnX.js} +1 -1
  13. package/dist/client/assets/{dist-B68Q8uXX.js → dist-BJi5HmH4.js} +1 -1
  14. package/dist/client/assets/{dist-COp25nhe.js → dist-BRjGckmQ.js} +1 -1
  15. package/dist/client/assets/{dist-Fhkmvjdx.js → dist-Bg9zLfex.js} +1 -1
  16. package/dist/client/assets/{dist-D7bj81KQ.js → dist-BjZUYxLd.js} +1 -1
  17. package/dist/client/assets/{dist-B1_1Ui9E.js → dist-Bv6P2Rcp.js} +1 -1
  18. package/dist/client/assets/{dist-ByIqsb5-.js → dist-C6iAHGvb.js} +1 -1
  19. package/dist/client/assets/{dist-Cybsoapc.js → dist-CMscy2Nn.js} +1 -1
  20. package/dist/client/assets/{dist-D6PoSxR5.js → dist-CPLCQDqy.js} +1 -1
  21. package/dist/client/assets/{dist-C6qSkkeN.js → dist-CgmISAMr.js} +1 -1
  22. package/dist/client/assets/{dist-DxoWS5MH.js → dist-CoszOk5k.js} +1 -1
  23. package/dist/client/assets/{dist-CpuexUlh.js → dist-Cu75NyQ0.js} +1 -1
  24. package/dist/client/assets/{dist-r5Af94tH.js → dist-DNZMXYNq.js} +1 -1
  25. package/dist/client/assets/{dist-BXVFgriA.js → dist-D_Zdnhm8.js} +1 -1
  26. package/dist/client/assets/{dist-CtdlYgGc.js → dist-DvHghuGd.js} +1 -1
  27. package/dist/client/assets/{dist-BcE6tJvc.js → dist-Hj_dWgPr.js} +1 -1
  28. package/dist/client/assets/{dist-DXNDtyHP.js → dist-Zhdkfvng.js} +1 -1
  29. package/dist/client/assets/{dist-BFlq-kiF.js → dist-vsMBLoxV.js} +1 -1
  30. package/dist/client/assets/{index-BApJ3Zbq.js → index-ChyemF2I.js} +23 -21
  31. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  32. package/dist/client/index.html +2 -2
  33. package/dist/server/application/feishu-service.d.ts +132 -0
  34. package/dist/server/application/feishu-service.js +264 -0
  35. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  36. package/dist/server/application/scheduled-task-service.js +350 -0
  37. package/dist/server/command-handler.d.ts +25 -1
  38. package/dist/server/command-handler.js +60 -1
  39. package/dist/server/feishu-cli.d.ts +207 -0
  40. package/dist/server/feishu-cli.js +419 -0
  41. package/dist/server/files.js +3 -3
  42. package/dist/server/http-server.js +212 -1
  43. package/dist/server/index.js +29 -2
  44. package/dist/server/knowledge.js +10 -2
  45. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  46. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  47. package/dist/server/pi-session.js +3 -0
  48. package/dist/server/runtime.d.ts +21 -0
  49. package/dist/server/runtime.js +58 -6
  50. package/dist/server/scheduled-tasks.d.ts +101 -0
  51. package/dist/server/scheduled-tasks.js +138 -0
  52. package/dist/server/server-context.d.ts +16 -0
  53. package/dist/server/store.d.ts +30 -0
  54. package/dist/server/store.js +81 -2
  55. package/dist/server/task-tool.d.ts +18 -0
  56. package/dist/server/task-tool.js +18 -0
  57. package/dist/server/types.d.ts +56 -0
  58. package/package.json +17 -4
  59. package/dist/.super-pi-check-inputs.json +0 -4
  60. package/dist/client/assets/CodeMirrorEditor-BlQhH3vs.js +0 -2
@@ -0,0 +1,207 @@
1
+ /**
2
+ * THE OFFICIAL FEISHU CLI, wrapped.
3
+ *
4
+ * WHY THE CLI AND NOT THE OPENAPI. Feishu ships `@larksuite/cli` (`lark-cli`), and it talks to a
5
+ * HIGHER-LEVEL API than the document one this file could otherwise call: the `docs_ai` service,
6
+ * which takes Markdown and returns Markdown, creates a document with content in ONE call, and
7
+ * carries the revision id on every write. Super Pi's own OpenAPI client had to create an empty
8
+ * document, convert Markdown into blocks, insert them as nested blocks under the page block, pack
9
+ * them into batches of a thousand, strip the read-only merge information off every table, and
10
+ * space its writes out because the block API allows three per second.
11
+ *
12
+ * So the honest answer to "why not just call the API" is: because Feishu already built the better
13
+ * caller, and it is the one that is maintained.
14
+ *
15
+ * WHY IT IS ALSO THE RIGHT AUTH PATH. The CLI stores its credentials in the OS keychain and
16
+ * authenticates with a device-code flow: `config init --new` creates an application through a
17
+ * browser confirmation (no developer-console work at all), and `auth login` grants a user token by
18
+ * the same kind of confirmation. A user token means the agent reaches the user's OWN documents -
19
+ * which removes the per-document "添加文档应用" step that the application-identity path needs, and
20
+ * it means Super Pi never holds a secret: the credential lives in the CLI's keychain, and this
21
+ * file only ever runs commands.
22
+ *
23
+ * THE ENVELOPE. Every command answers with JSON: `{"ok": true, "identity": "user", "data": {...}}`
24
+ * on stdout with exit code 0, and `{"ok": false, "error": {"type", "subtype", "code", "message",
25
+ * "hint"}}` on stderr with a non-zero exit code. The `hint` is Feishu's own "what to do about it",
26
+ * which is why this wrapper's errors can pass it through instead of inventing its own table.
27
+ */
28
+ /** The tool that reads a Feishu document. */
29
+ export declare const FEISHU_READ_TOOL_NAME = "feishu_doc_read";
30
+ /** The tool that writes a Feishu document. */
31
+ export declare const FEISHU_WRITE_TOOL_NAME = "feishu_doc_write";
32
+ /** How the CLI is found. An explicit path wins, then the environment, then the PATH. */
33
+ export declare const FEISHU_CLI_ENV = "SUPER_PI_FEISHU_CLI";
34
+ /** The command name the CLI installs itself as. */
35
+ export declare const FEISHU_CLI_COMMAND = "lark-cli";
36
+ /** The success/failure envelope every CLI command answers with. */
37
+ export type FeishuCliEnvelope = {
38
+ ok?: boolean;
39
+ identity?: string;
40
+ data?: Record<string, unknown>;
41
+ error?: {
42
+ type?: string;
43
+ subtype?: string;
44
+ code?: number;
45
+ message?: string;
46
+ hint?: string;
47
+ };
48
+ meta?: Record<string, unknown>;
49
+ /** Set when the answer was not JSON at all - a crash, a missing binary, a killed process. */
50
+ raw?: string;
51
+ };
52
+ /** The identity the CLI is acting as. */
53
+ export type FeishuIdentity = "user" | "bot";
54
+ /**
55
+ * What the CLI knows about itself: is it installed, is an application configured, is a person
56
+ * logged in.
57
+ */
58
+ export type FeishuCliStatus = {
59
+ installed: boolean;
60
+ version?: string;
61
+ appId?: string;
62
+ userReady: boolean;
63
+ botReady: boolean;
64
+ identity?: FeishuIdentity;
65
+ /** Feishu's own "what to do about it", when the CLI had something to say. */
66
+ hint?: string;
67
+ };
68
+ /** The outcome of a CLI call, in the shape this product speaks in. */
69
+ export declare class FeishuCliError extends Error {
70
+ readonly hint?: string | undefined;
71
+ readonly code?: number | undefined;
72
+ constructor(message: string, hint?: string | undefined, code?: number | undefined);
73
+ }
74
+ /** A spawn function, injected in tests so the suite never runs a real binary. */
75
+ export type SpawnLike = (command: string, args: string[], options: {
76
+ shell: boolean;
77
+ cwd?: string;
78
+ env?: NodeJS.ProcessEnv;
79
+ input?: string;
80
+ timeoutMs: number;
81
+ }) => Promise<{
82
+ stdout: string;
83
+ stderr: string;
84
+ code: number | null;
85
+ }>;
86
+ /**
87
+ * A LONG-RUNNING command, whose output is watched while it runs.
88
+ *
89
+ * `config init --new` blocks until the person confirms the application in a browser, so its URL
90
+ * has to be read out of its output WHILE it is still running - which a promise of the finished
91
+ * output cannot do. Injected in tests for the same reason as the other spawn: the suite must
92
+ * never run the real binary, which would sit there waiting for a browser confirmation.
93
+ */
94
+ export type BackgroundSpawnLike = (command: string, args: string[], options: {
95
+ shell: boolean;
96
+ env?: NodeJS.ProcessEnv;
97
+ }) => {
98
+ onOutput: (listener: (chunk: string) => void) => void;
99
+ done: Promise<void>;
100
+ };
101
+ export type FeishuCliOptions = {
102
+ /** The command to run. Defaults to `lark-cli`, or `SUPER_PI_FEISHU_CLI` when it is set. */
103
+ command?: string;
104
+ /** Where the command runs. The CLI resolves `@file` paths against the working directory. */
105
+ cwd?: string;
106
+ /** Extra environment for the command. */
107
+ env?: NodeJS.ProcessEnv;
108
+ /** Injected in tests. */
109
+ spawn?: SpawnLike;
110
+ /** Injected in tests, for the one command that keeps running. */
111
+ backgroundSpawn?: BackgroundSpawnLike;
112
+ /** How long a command may take before it is killed. */
113
+ timeoutMs?: number;
114
+ };
115
+ export declare class FeishuCli {
116
+ private readonly options;
117
+ private readonly spawn;
118
+ private readonly backgroundSpawn;
119
+ private readonly timeoutMs;
120
+ constructor(options?: FeishuCliOptions);
121
+ /** The command to run. */
122
+ get command(): string;
123
+ /**
124
+ * RUNS ONE COMMAND AND PARSES ITS ENVELOPE.
125
+ *
126
+ * A command that is not found is an error the caller has to be able to tell apart from a
127
+ * command that ran and failed, so the missing-binary case is a distinct error code.
128
+ */
129
+ run(args: string[], options?: {
130
+ input?: string;
131
+ timeoutMs?: number;
132
+ }): Promise<FeishuCliEnvelope>;
133
+ /**
134
+ * THE CLI'S VERSION, or `undefined` when it is not installed.
135
+ *
136
+ * This is how the panel decides between "install it" and "connect it", so it must not throw.
137
+ */
138
+ version(): Promise<string | undefined>;
139
+ /**
140
+ * WHAT THE CLI KNOWS ABOUT ITSELF.
141
+ *
142
+ * `auth status` is the one command that answers without touching the network unless asked to
143
+ * verify, so it is what the panel polls.
144
+ */
145
+ status(): Promise<FeishuCliStatus>;
146
+ /**
147
+ * THE DEVICE-CODE LOGIN, in the two steps the CLI documents for agents: ask for a code without
148
+ * waiting, show the person the URL, and finish the exchange once they have authorized.
149
+ */
150
+ beginLogin(): Promise<{
151
+ deviceCode: string;
152
+ verificationUri?: string;
153
+ }>;
154
+ /** Finishes the device-code exchange once the person has authorized it. */
155
+ completeLogin(deviceCode: string): Promise<FeishuCliEnvelope>;
156
+ /**
157
+ * READS A DOCUMENT AS MARKDOWN.
158
+ *
159
+ * The CLI resolves a knowledge-base link itself, so a `/wiki/` URL is passed through untouched.
160
+ */
161
+ fetchDocument(reference: string): Promise<{
162
+ documentId?: string;
163
+ revisionId?: number;
164
+ content: string;
165
+ title?: string;
166
+ }>;
167
+ /** CREATES A DOCUMENT FROM MARKDOWN. */
168
+ createDocument(title: string, markdown: string): Promise<{
169
+ documentId?: string;
170
+ revisionId?: number;
171
+ url?: string;
172
+ }>;
173
+ /**
174
+ * UPDATES A DOCUMENT.
175
+ *
176
+ * `append` adds at the end; `overwrite` replaces the whole document and takes the revision the
177
+ * caller read, which is the optimistic lock the CLI's own API enforces.
178
+ */
179
+ updateDocument(reference: string, command: "append" | "overwrite", markdown: string, revisionId?: number): Promise<{
180
+ revisionId?: number;
181
+ updated?: number;
182
+ }>;
183
+ /**
184
+ * CREATES THE APPLICATION, the way the CLI does it: `config init --new` registers an application
185
+ * through a browser confirmation and prints the URL to confirm at.
186
+ *
187
+ * The command BLOCKS until the person finishes, so it runs in the background and the URL is
188
+ * taken from its output as soon as it appears. The promise resolves with that URL, or with
189
+ * `undefined` when the command printed none within the window - the command itself keeps
190
+ * running, because the person still has to confirm it in their browser.
191
+ */
192
+ startAppCreation(waitMs?: number): Promise<string | undefined>;
193
+ }
194
+ /**
195
+ * Parses an envelope out of a command's output.
196
+ *
197
+ * The CLI prints one JSON object per command, but it also prints human text in some paths, so the
198
+ * envelope is found by BRACE MATCHING rather than by taking everything between the first and the
199
+ * last brace - which would swallow two objects and fail to parse at all.
200
+ */
201
+ export declare function parseEnvelope(output: string): FeishuCliEnvelope | undefined;
202
+ /** The message a failed envelope carries. */
203
+ export declare function errorMessage(envelope: FeishuCliEnvelope): string;
204
+ /** The hint a failed envelope carries - Feishu's own "what to do about it". */
205
+ export declare function errorHint(envelope: FeishuCliEnvelope): string | undefined;
206
+ /** The code a failed envelope carries. */
207
+ export declare function errorCode(envelope: FeishuCliEnvelope): number | undefined;
@@ -0,0 +1,419 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * THE OFFICIAL FEISHU CLI, wrapped.
4
+ *
5
+ * WHY THE CLI AND NOT THE OPENAPI. Feishu ships `@larksuite/cli` (`lark-cli`), and it talks to a
6
+ * HIGHER-LEVEL API than the document one this file could otherwise call: the `docs_ai` service,
7
+ * which takes Markdown and returns Markdown, creates a document with content in ONE call, and
8
+ * carries the revision id on every write. Super Pi's own OpenAPI client had to create an empty
9
+ * document, convert Markdown into blocks, insert them as nested blocks under the page block, pack
10
+ * them into batches of a thousand, strip the read-only merge information off every table, and
11
+ * space its writes out because the block API allows three per second.
12
+ *
13
+ * So the honest answer to "why not just call the API" is: because Feishu already built the better
14
+ * caller, and it is the one that is maintained.
15
+ *
16
+ * WHY IT IS ALSO THE RIGHT AUTH PATH. The CLI stores its credentials in the OS keychain and
17
+ * authenticates with a device-code flow: `config init --new` creates an application through a
18
+ * browser confirmation (no developer-console work at all), and `auth login` grants a user token by
19
+ * the same kind of confirmation. A user token means the agent reaches the user's OWN documents -
20
+ * which removes the per-document "添加文档应用" step that the application-identity path needs, and
21
+ * it means Super Pi never holds a secret: the credential lives in the CLI's keychain, and this
22
+ * file only ever runs commands.
23
+ *
24
+ * THE ENVELOPE. Every command answers with JSON: `{"ok": true, "identity": "user", "data": {...}}`
25
+ * on stdout with exit code 0, and `{"ok": false, "error": {"type", "subtype", "code", "message",
26
+ * "hint"}}` on stderr with a non-zero exit code. The `hint` is Feishu's own "what to do about it",
27
+ * which is why this wrapper's errors can pass it through instead of inventing its own table.
28
+ */
29
+ /** The tool that reads a Feishu document. */
30
+ export const FEISHU_READ_TOOL_NAME = "feishu_doc_read";
31
+ /** The tool that writes a Feishu document. */
32
+ export const FEISHU_WRITE_TOOL_NAME = "feishu_doc_write";
33
+ /** How the CLI is found. An explicit path wins, then the environment, then the PATH. */
34
+ export const FEISHU_CLI_ENV = "SUPER_PI_FEISHU_CLI";
35
+ /** The command name the CLI installs itself as. */
36
+ export const FEISHU_CLI_COMMAND = "lark-cli";
37
+ /** The outcome of a CLI call, in the shape this product speaks in. */
38
+ export class FeishuCliError extends Error {
39
+ hint;
40
+ code;
41
+ constructor(message, hint, code) {
42
+ super(message);
43
+ this.hint = hint;
44
+ this.code = code;
45
+ this.name = "FeishuCliError";
46
+ }
47
+ }
48
+ /**
49
+ * Runs the CLI and reads its envelope.
50
+ *
51
+ * THE SPAWN IS A SHELL ON WINDOWS, because npm installs the CLI as a `.cmd` shim and Node cannot
52
+ * execute one without a shell. The same rule the runtime's Pi spawn follows.
53
+ */
54
+ const defaultSpawn = (command, args, options) => new Promise((resolve, reject) => {
55
+ const child = spawn(command, args, {
56
+ cwd: options.cwd,
57
+ env: options.env ?? process.env,
58
+ shell: process.platform === "win32",
59
+ stdio: ["pipe", "pipe", "pipe"],
60
+ });
61
+ let stdout = "";
62
+ let stderr = "";
63
+ child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
64
+ child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
65
+ const timer = setTimeout(() => { child.kill(); }, options.timeoutMs);
66
+ child.once("error", (error) => {
67
+ clearTimeout(timer);
68
+ // A missing binary is the normal way this fails, and it has to be recognisable: the panel
69
+ // answers "install it" instead of "something went wrong".
70
+ reject(Object.assign(new Error(`could not run ${command}: ${error.message}`), { code: "ENOENT" }));
71
+ });
72
+ child.once("close", (code) => {
73
+ clearTimeout(timer);
74
+ resolve({ stdout, stderr, code });
75
+ });
76
+ if (options.input !== undefined) {
77
+ child.stdin?.end(options.input);
78
+ }
79
+ else {
80
+ child.stdin?.end();
81
+ }
82
+ });
83
+ /**
84
+ * Runs a command that keeps going, and hands its output over as it arrives.
85
+ */
86
+ const defaultBackgroundSpawn = (command, args, options) => {
87
+ const child = spawn(command, args, {
88
+ shell: options.shell,
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ env: options.env ?? process.env,
91
+ });
92
+ return {
93
+ onOutput: (listener) => {
94
+ child.stdout?.on("data", (chunk) => listener(String(chunk)));
95
+ child.stderr?.on("data", (chunk) => listener(String(chunk)));
96
+ },
97
+ done: new Promise((resolve) => {
98
+ child.once("close", () => resolve());
99
+ child.once("error", () => resolve());
100
+ }),
101
+ };
102
+ };
103
+ /** Reads the first value that exists, so a renamed field does not take the feature down. */
104
+ function pick(source, ...paths) {
105
+ if (!source || typeof source !== "object")
106
+ return undefined;
107
+ for (const path of paths) {
108
+ let current = source;
109
+ for (const part of path.split(".")) {
110
+ if (!current || typeof current !== "object") {
111
+ current = undefined;
112
+ break;
113
+ }
114
+ current = current[part];
115
+ }
116
+ if (current !== undefined && current !== null)
117
+ return current;
118
+ }
119
+ return undefined;
120
+ }
121
+ function asString(value) {
122
+ return typeof value === "string" && value.length > 0 ? value : undefined;
123
+ }
124
+ function asNumber(value) {
125
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
126
+ }
127
+ export class FeishuCli {
128
+ options;
129
+ spawn;
130
+ backgroundSpawn;
131
+ timeoutMs;
132
+ constructor(options = {}) {
133
+ this.options = options;
134
+ this.spawn = options.spawn ?? defaultSpawn;
135
+ this.backgroundSpawn = options.backgroundSpawn ?? defaultBackgroundSpawn;
136
+ this.timeoutMs = options.timeoutMs ?? 90_000;
137
+ }
138
+ /** The command to run. */
139
+ get command() {
140
+ return this.options.command ?? process.env[FEISHU_CLI_ENV] ?? FEISHU_CLI_COMMAND;
141
+ }
142
+ /**
143
+ * RUNS ONE COMMAND AND PARSES ITS ENVELOPE.
144
+ *
145
+ * A command that is not found is an error the caller has to be able to tell apart from a
146
+ * command that ran and failed, so the missing-binary case is a distinct error code.
147
+ */
148
+ async run(args, options = {}) {
149
+ let result;
150
+ try {
151
+ result = await this.spawn(this.command, args, {
152
+ shell: process.platform === "win32",
153
+ ...(this.options.cwd ? { cwd: this.options.cwd } : {}),
154
+ ...(this.options.env ? { env: { ...process.env, ...this.options.env } } : {}),
155
+ ...(options.input !== undefined ? { input: options.input } : {}),
156
+ timeoutMs: options.timeoutMs ?? this.timeoutMs,
157
+ });
158
+ }
159
+ catch (error) {
160
+ if (error.code === "ENOENT") {
161
+ throw new FeishuCliError(`没有找到官方飞书 CLI(${this.command})。请先运行 npm i -g @larksuite/cli 安装,再回到这里点「连接飞书账号」。`);
162
+ }
163
+ throw error;
164
+ }
165
+ // Success answers on stdout, failures on stderr; a crash answers with neither.
166
+ const parsed = parseEnvelope(result.stdout) ?? parseEnvelope(result.stderr);
167
+ if (parsed)
168
+ return parsed;
169
+ const text = (result.stderr || result.stdout || "").trim();
170
+ // A command that printed something that is not an envelope did not do what was asked, and
171
+ // treating that as success would hand the model an empty document.
172
+ throw new FeishuCliError(text
173
+ ? `飞书 CLI 没有返回可读的结果:${text.slice(0, 300)}`
174
+ : `飞书 CLI 执行失败(退出码 ${result.code}),而且没有说明原因。`);
175
+ }
176
+ /**
177
+ * THE CLI'S VERSION, or `undefined` when it is not installed.
178
+ *
179
+ * This is how the panel decides between "install it" and "connect it", so it must not throw.
180
+ */
181
+ async version() {
182
+ try {
183
+ const result = await this.spawn(this.command, ["--version"], {
184
+ shell: process.platform === "win32",
185
+ timeoutMs: 20_000,
186
+ });
187
+ // A command that failed is not a CLI that is installed. The version has to look like one,
188
+ // because the answer decides between "install it" and "connect it".
189
+ if (result.code !== 0)
190
+ return undefined;
191
+ const match = /\d+\.\d+\.\d+[\w.+-]*/.exec(`${result.stdout} ${result.stderr}`);
192
+ return match ? match[0] : undefined;
193
+ }
194
+ catch {
195
+ return undefined;
196
+ }
197
+ }
198
+ /**
199
+ * WHAT THE CLI KNOWS ABOUT ITSELF.
200
+ *
201
+ * `auth status` is the one command that answers without touching the network unless asked to
202
+ * verify, so it is what the panel polls.
203
+ */
204
+ async status() {
205
+ const version = await this.version();
206
+ if (!version)
207
+ return { installed: false, userReady: false, botReady: false };
208
+ let envelope;
209
+ try {
210
+ envelope = await this.run(["auth", "status", "--json"], { timeoutMs: 30_000 });
211
+ }
212
+ catch (error) {
213
+ return {
214
+ installed: true,
215
+ version,
216
+ userReady: false,
217
+ botReady: false,
218
+ ...(error instanceof FeishuCliError ? { hint: error.message } : {}),
219
+ };
220
+ }
221
+ const identities = pick(envelope.data ?? envelope, "identities");
222
+ const user = identities?.user;
223
+ const bot = identities?.bot;
224
+ const identity = asString(envelope.identity) ?? pick(envelope.data ?? envelope, "identity");
225
+ return {
226
+ installed: true,
227
+ version,
228
+ ...(asString(pick(envelope.data ?? envelope, "appId", "app_id")) ? { appId: asString(pick(envelope.data ?? envelope, "appId", "app_id")) } : {}),
229
+ userReady: user?.available === true,
230
+ botReady: bot?.available === true,
231
+ ...(identity === "user" || identity === "bot" ? { identity } : {}),
232
+ ...(asString(user?.hint) ?? asString(bot?.hint) ? { hint: (asString(user?.hint) ?? asString(bot?.hint)) } : {}),
233
+ };
234
+ }
235
+ /**
236
+ * THE DEVICE-CODE LOGIN, in the two steps the CLI documents for agents: ask for a code without
237
+ * waiting, show the person the URL, and finish the exchange once they have authorized.
238
+ */
239
+ async beginLogin() {
240
+ const envelope = await this.run(["auth", "login", "--no-wait", "--json"], { timeoutMs: 60_000 });
241
+ if (envelope.ok === false)
242
+ throw new FeishuCliError(errorMessage(envelope), errorHint(envelope), errorCode(envelope));
243
+ const deviceCode = asString(pick(envelope.data ?? envelope, "device_code", "deviceCode"));
244
+ if (!deviceCode)
245
+ throw new FeishuCliError("飞书 CLI 没有返回设备码,无法发起登录。");
246
+ const verificationUri = asString(pick(envelope.data ?? envelope, "verification_uri_complete", "verificationUriComplete", "verification_uri", "verificationUri"));
247
+ return { deviceCode, ...(verificationUri ? { verificationUri } : {}) };
248
+ }
249
+ /** Finishes the device-code exchange once the person has authorized it. */
250
+ async completeLogin(deviceCode) {
251
+ return this.run(["auth", "login", "--device-code", deviceCode, "--json"], { timeoutMs: 120_000 });
252
+ }
253
+ /**
254
+ * READS A DOCUMENT AS MARKDOWN.
255
+ *
256
+ * The CLI resolves a knowledge-base link itself, so a `/wiki/` URL is passed through untouched.
257
+ */
258
+ async fetchDocument(reference) {
259
+ const envelope = await this.run(["docs", "+fetch", "--doc", reference, "--doc-format", "markdown", "--json"], { timeoutMs: 120_000 });
260
+ if (envelope.ok === false)
261
+ throw new FeishuCliError(errorMessage(envelope), errorHint(envelope), errorCode(envelope));
262
+ const document = pick(envelope.data ?? {}, "document");
263
+ const content = asString(pick(document ?? {}, "content")) ?? asString(pick(envelope.data ?? {}, "content")) ?? "";
264
+ return {
265
+ ...(asString(pick(document ?? {}, "document_id", "documentId")) ? { documentId: asString(pick(document ?? {}, "document_id", "documentId")) } : {}),
266
+ ...(asNumber(pick(document ?? {}, "revision_id", "revisionId")) !== undefined ? { revisionId: asNumber(pick(document ?? {}, "revision_id", "revisionId")) } : {}),
267
+ ...(asString(pick(document ?? {}, "title")) ? { title: asString(pick(document ?? {}, "title")) } : {}),
268
+ content,
269
+ };
270
+ }
271
+ /** CREATES A DOCUMENT FROM MARKDOWN. */
272
+ async createDocument(title, markdown) {
273
+ const envelope = await this.run(["docs", "+create", "--doc-format", "markdown", "--title", title, "--content", "-", "--json"], { input: markdown, timeoutMs: 180_000 });
274
+ if (envelope.ok === false)
275
+ throw new FeishuCliError(errorMessage(envelope), errorHint(envelope), errorCode(envelope));
276
+ const document = pick(envelope.data ?? {}, "document");
277
+ return {
278
+ ...(asString(pick(document ?? {}, "document_id", "documentId")) ? { documentId: asString(pick(document ?? {}, "document_id", "documentId")) } : {}),
279
+ ...(asNumber(pick(document ?? {}, "revision_id", "revisionId")) !== undefined ? { revisionId: asNumber(pick(document ?? {}, "revision_id", "revisionId")) } : {}),
280
+ ...(asString(pick(document ?? {}, "url")) ? { url: asString(pick(document ?? {}, "url")) } : {}),
281
+ };
282
+ }
283
+ /**
284
+ * UPDATES A DOCUMENT.
285
+ *
286
+ * `append` adds at the end; `overwrite` replaces the whole document and takes the revision the
287
+ * caller read, which is the optimistic lock the CLI's own API enforces.
288
+ */
289
+ async updateDocument(reference, command, markdown, revisionId) {
290
+ const args = ["docs", "+update", "--doc", reference, "--command", command, "--doc-format", "markdown", "--content", "-"];
291
+ if (revisionId !== undefined)
292
+ args.push("--revision-id", String(revisionId));
293
+ args.push("--json");
294
+ const envelope = await this.run(args, { input: markdown, timeoutMs: 180_000 });
295
+ if (envelope.ok === false)
296
+ throw new FeishuCliError(errorMessage(envelope), errorHint(envelope), errorCode(envelope));
297
+ const document = pick(envelope.data ?? {}, "document");
298
+ return {
299
+ ...(asNumber(pick(document ?? {}, "revision_id", "revisionId")) !== undefined ? { revisionId: asNumber(pick(document ?? {}, "revision_id", "revisionId")) } : {}),
300
+ ...(asNumber(pick(envelope.data ?? {}, "updated_blocks_count", "updatedBlocksCount")) !== undefined ? { updated: asNumber(pick(envelope.data ?? {}, "updated_blocks_count", "updatedBlocksCount")) } : {}),
301
+ };
302
+ }
303
+ /**
304
+ * CREATES THE APPLICATION, the way the CLI does it: `config init --new` registers an application
305
+ * through a browser confirmation and prints the URL to confirm at.
306
+ *
307
+ * The command BLOCKS until the person finishes, so it runs in the background and the URL is
308
+ * taken from its output as soon as it appears. The promise resolves with that URL, or with
309
+ * `undefined` when the command printed none within the window - the command itself keeps
310
+ * running, because the person still has to confirm it in their browser.
311
+ */
312
+ async startAppCreation(waitMs = 25_000) {
313
+ const handle = this.backgroundSpawn(this.command, ["config", "init", "--new"], {
314
+ shell: process.platform === "win32",
315
+ ...(this.options.env ? { env: { ...process.env, ...this.options.env } } : {}),
316
+ });
317
+ return new Promise((resolve) => {
318
+ let seen = "";
319
+ let settled = false;
320
+ const finish = (url) => {
321
+ if (settled)
322
+ return;
323
+ settled = true;
324
+ clearTimeout(timer);
325
+ resolve(url);
326
+ };
327
+ const timer = setTimeout(() => finish(undefined), waitMs);
328
+ handle.onOutput((chunk) => {
329
+ seen += chunk;
330
+ const match = /https:\/\/[^\s"'<>)\]]+/.exec(seen);
331
+ if (match)
332
+ finish(match[0]);
333
+ });
334
+ handle.done.then(() => finish(undefined));
335
+ });
336
+ }
337
+ }
338
+ /**
339
+ * Parses an envelope out of a command's output.
340
+ *
341
+ * The CLI prints one JSON object per command, but it also prints human text in some paths, so the
342
+ * envelope is found by BRACE MATCHING rather than by taking everything between the first and the
343
+ * last brace - which would swallow two objects and fail to parse at all.
344
+ */
345
+ export function parseEnvelope(output) {
346
+ const text = output.trim();
347
+ if (!text)
348
+ return undefined;
349
+ try {
350
+ const parsed = JSON.parse(text);
351
+ return parsed && typeof parsed === "object" ? parsed : undefined;
352
+ }
353
+ catch {
354
+ // fall through to the brace scan
355
+ }
356
+ for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
357
+ const end = matchingBrace(text, start);
358
+ if (end === -1)
359
+ continue;
360
+ try {
361
+ const parsed = JSON.parse(text.slice(start, end + 1));
362
+ if (parsed && typeof parsed === "object")
363
+ return parsed;
364
+ }
365
+ catch {
366
+ // not this one; keep looking
367
+ }
368
+ }
369
+ return undefined;
370
+ }
371
+ /**
372
+ * The index of the brace that closes the one at `start`, ignoring braces inside strings.
373
+ */
374
+ function matchingBrace(text, start) {
375
+ let depth = 0;
376
+ let inString = false;
377
+ let escaped = false;
378
+ for (let index = start; index < text.length; index += 1) {
379
+ const character = text[index];
380
+ if (escaped) {
381
+ escaped = false;
382
+ continue;
383
+ }
384
+ if (character === "\\") {
385
+ escaped = true;
386
+ continue;
387
+ }
388
+ if (character === '"') {
389
+ inString = !inString;
390
+ continue;
391
+ }
392
+ if (inString)
393
+ continue;
394
+ if (character === "{")
395
+ depth += 1;
396
+ else if (character === "}") {
397
+ depth -= 1;
398
+ if (depth === 0)
399
+ return index;
400
+ }
401
+ }
402
+ return -1;
403
+ }
404
+ /** The message a failed envelope carries. */
405
+ export function errorMessage(envelope) {
406
+ const message = asString(envelope.error?.message);
407
+ const code = envelope.error?.code;
408
+ if (message)
409
+ return code ? `飞书返回了错误(${code}):${message}` : `飞书返回了错误:${message}`;
410
+ return "飞书返回了错误,但没有说明原因。";
411
+ }
412
+ /** The hint a failed envelope carries - Feishu's own "what to do about it". */
413
+ export function errorHint(envelope) {
414
+ return asString(envelope.error?.hint);
415
+ }
416
+ /** The code a failed envelope carries. */
417
+ export function errorCode(envelope) {
418
+ return asNumber(envelope.error?.code);
419
+ }
@@ -150,17 +150,17 @@ function rejectUnsafeShape(requested) {
150
150
  // UNC: `\\server\share\...` and its POSIX-spelled twin `//server/share`. Named explicitly because
151
151
  // `isAbsolute` is true for the first on Windows and false for the second on Linux.
152
152
  if (/^[\\/]{2}/.test(requested))
153
- throw new FilesError(403, "拒绝 UNC 路径:路径必须在 workspace 内", "outside_workspace");
153
+ throw new FilesError(403, "拒绝 UNC 路径:请给出相对于工作区根目录的路径", "outside_workspace");
154
154
  // A drive letter in EITHER position: `C:\x` is absolute, `C:x` is drive-relative and resolves against
155
155
  // that drive's current directory, which is not a location the caller gets to choose. The colon is also
156
156
  // how NTFS alternate data streams are addressed (`notes.md:hidden`), which is a second file hiding
157
157
  // behind one path - handled by the segment check below.
158
158
  if (/^[A-Za-z]:/.test(requested))
159
- throw new FilesError(403, "拒绝 Windows 盘符路径:路径必须在 workspace 内", "outside_workspace");
159
+ throw new FilesError(403, "拒绝 Windows 盘符路径:请给出相对于工作区根目录的路径", "outside_workspace");
160
160
  // Rooted, POSIX or Windows: `/etc/passwd` and `\Windows`. Both are absolute, and the leading-slash
161
161
  // forms are spelled out so the refusal does not depend on the host's own path parser agreeing.
162
162
  if (requested.startsWith("/") || requested.startsWith("\\"))
163
- throw new FilesError(403, "拒绝绝对路径:路径必须在 workspace 内", "outside_workspace");
163
+ throw new FilesError(403, "拒绝绝对路径:请给出相对于工作区根目录的路径", "outside_workspace");
164
164
  return requested.replace(/\\/g, "/");
165
165
  }
166
166
  /**