@webskill/sdk 0.0.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/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/browser.d.ts +219 -0
- package/dist/browser.js +856 -0
- package/dist/dist-9fczg9Pa.js +1122 -0
- package/dist/dist-B_ldNWwT.js +1975 -0
- package/dist/dist-RcqvzAGF.js +15382 -0
- package/dist/governance.d.ts +440 -0
- package/dist/governance.js +844 -0
- package/dist/index-88KNOnbr.d.ts +955 -0
- package/dist/index-Bq-bTWh3.d.ts +219 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +237 -0
- package/dist/mcp.js +513 -0
- package/dist/node.d.ts +3 -0
- package/dist/node.js +4 -0
- package/dist/ui-react.d.ts +40 -0
- package/dist/ui-react.js +239 -0
- package/dist/ui-vue.d.ts +72 -0
- package/dist/ui-vue.js +196 -0
- package/dist/ui.d.ts +171 -0
- package/dist/ui.js +4 -0
- package/package.json +91 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import { D as normalizeToolContent, G as parseSkillMarkdown, H as isValidSkillName, J as resolveInsideRoot, O as parseBridgeRequest, R as WebSkillError, Y as validateSkills, o as FsArtifactStore, s as FsMemoryStore, x as bridgeError } from "./dist-B_ldNWwT.js";
|
|
2
|
+
import { existsSync, promises, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { format, promisify } from "node:util";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { mkdtemp } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { unzipSync, zipSync } from "fflate";
|
|
11
|
+
import * as tar from "tar";
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
//#region ../node/dist/index.js
|
|
16
|
+
const toPlatform$3 = (p) => p.split("/").join(path.sep);
|
|
17
|
+
const toPosix = (p) => p.split(path.sep).join("/");
|
|
18
|
+
const isEnoent = (e) => typeof e === "object" && e !== null && e.code === "ENOENT";
|
|
19
|
+
/** 基于 node:fs/promises 的 FileSystemProvider;接受 `/` 风格路径,写入自动创建父目录 */
|
|
20
|
+
var NodeFS = class {
|
|
21
|
+
kind = "node";
|
|
22
|
+
async readText(p) {
|
|
23
|
+
try {
|
|
24
|
+
return await promises.readFile(toPlatform$3(p), "utf8");
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw this.#mapError(e, p);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async writeText(p, content) {
|
|
30
|
+
const target = toPlatform$3(p);
|
|
31
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
32
|
+
await promises.writeFile(target, content, "utf8");
|
|
33
|
+
}
|
|
34
|
+
async readBinary(p) {
|
|
35
|
+
try {
|
|
36
|
+
return await promises.readFile(toPlatform$3(p));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw this.#mapError(e, p);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async writeBinary(p, content) {
|
|
42
|
+
const target = toPlatform$3(p);
|
|
43
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
44
|
+
await promises.writeFile(target, content);
|
|
45
|
+
}
|
|
46
|
+
async exists(p) {
|
|
47
|
+
try {
|
|
48
|
+
await promises.access(toPlatform$3(p));
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async stat(p) {
|
|
55
|
+
try {
|
|
56
|
+
const s = await promises.stat(toPlatform$3(p));
|
|
57
|
+
return {
|
|
58
|
+
path: toPosix(p),
|
|
59
|
+
type: s.isDirectory() ? "directory" : "file",
|
|
60
|
+
size: s.isFile() ? s.size : void 0,
|
|
61
|
+
mtimeMs: s.mtimeMs
|
|
62
|
+
};
|
|
63
|
+
} catch (e) {
|
|
64
|
+
throw this.#mapError(e, p);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async list(p) {
|
|
68
|
+
let dirents;
|
|
69
|
+
try {
|
|
70
|
+
dirents = await promises.readdir(toPlatform$3(p), { withFileTypes: true });
|
|
71
|
+
} catch (e) {
|
|
72
|
+
throw this.#mapError(e, p);
|
|
73
|
+
}
|
|
74
|
+
return dirents.map((d) => ({
|
|
75
|
+
path: toPosix(path.join(toPlatform$3(p), d.name)),
|
|
76
|
+
type: d.isDirectory() ? "directory" : "file"
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
async mkdir(p) {
|
|
80
|
+
await promises.mkdir(toPlatform$3(p), { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
async remove(p, options) {
|
|
83
|
+
try {
|
|
84
|
+
await promises.rm(toPlatform$3(p), { recursive: options?.recursive ?? false });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw this.#mapError(e, p);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
#mapError(e, p) {
|
|
90
|
+
if (isEnoent(e)) return new WebSkillError("FS_NOT_FOUND", `Path not found: ${p}`);
|
|
91
|
+
return e;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const baseName$1 = (p) => p.split("/").pop() ?? p;
|
|
95
|
+
const toPlatform$2 = (p) => p.split("/").join(path.sep);
|
|
96
|
+
/**
|
|
97
|
+
* 进程内脚本执行器。接口按沙箱语义设计(context 无隐式宿主访问),
|
|
98
|
+
* worker_threads 沙箱见 deferred-items D1。
|
|
99
|
+
*/
|
|
100
|
+
var NodeScriptExecutor = class {
|
|
101
|
+
#fs;
|
|
102
|
+
constructor(fs) {
|
|
103
|
+
this.#fs = fs;
|
|
104
|
+
}
|
|
105
|
+
/** 定位 scripts/<name>.ts|js;冲突/缺失抛结构化错误 */
|
|
106
|
+
async #locateScript(skillRoot, scriptName) {
|
|
107
|
+
const tsPath = `${skillRoot}/scripts/${scriptName}.ts`;
|
|
108
|
+
const jsPath = `${skillRoot}/scripts/${scriptName}.js`;
|
|
109
|
+
const [hasTs, hasJs] = await Promise.all([this.#fs.exists(tsPath), this.#fs.exists(jsPath)]);
|
|
110
|
+
if (hasTs && hasJs) throw new WebSkillError("TOOL_EXECUTION_FAILED", `Script "${scriptName}" is ambiguous: both .ts and .js exist under ${skillRoot}/scripts`);
|
|
111
|
+
if (!hasTs && !hasJs) throw new WebSkillError("TOOL_NOT_FOUND", `Script "${scriptName}" not found under ${skillRoot}/scripts`);
|
|
112
|
+
return hasTs ? tsPath : jsPath;
|
|
113
|
+
}
|
|
114
|
+
async #loadModule(scriptPath) {
|
|
115
|
+
return await import(`${pathToFileURL(toPlatform$2(scriptPath)).href}?t=${Date.now()}`);
|
|
116
|
+
}
|
|
117
|
+
async loadDefinition(skillRoot, scriptName) {
|
|
118
|
+
const scriptPath = await this.#locateScript(skillRoot, scriptName);
|
|
119
|
+
const skillName = baseName$1(skillRoot);
|
|
120
|
+
let module;
|
|
121
|
+
try {
|
|
122
|
+
module = await this.#loadModule(scriptPath);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
throw new WebSkillError("TOOL_EXECUTION_FAILED", `Failed to load script "${scriptName}": ${e instanceof Error ? e.message : String(e)}`, e);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
name: `${skillName}__${scriptName}`,
|
|
128
|
+
description: typeof module.description === "string" ? module.description : void 0,
|
|
129
|
+
inputSchema: typeof module.inputSchema === "object" && module.inputSchema !== null ? module.inputSchema : void 0,
|
|
130
|
+
source: "script",
|
|
131
|
+
skillName
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async execute(input) {
|
|
135
|
+
const { skillRoot, scriptName, args, context, timeoutMs } = input;
|
|
136
|
+
let scriptPath;
|
|
137
|
+
try {
|
|
138
|
+
scriptPath = await this.#locateScript(skillRoot, scriptName);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return this.#failure(e);
|
|
141
|
+
}
|
|
142
|
+
let module;
|
|
143
|
+
try {
|
|
144
|
+
module = await this.#loadModule(scriptPath);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return this.#failure(e);
|
|
147
|
+
}
|
|
148
|
+
if (typeof module.run !== "function") return {
|
|
149
|
+
ok: false,
|
|
150
|
+
content: [],
|
|
151
|
+
error: {
|
|
152
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
153
|
+
message: `Script "${scriptName}" does not export a run function`
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const missing = this.#missingRequiredArgs(module.inputSchema, args);
|
|
157
|
+
if (missing.length > 0) return {
|
|
158
|
+
ok: false,
|
|
159
|
+
content: [],
|
|
160
|
+
error: {
|
|
161
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
162
|
+
message: `Missing required argument(s): ${missing.join(", ")}`
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const stdout = [];
|
|
166
|
+
const stderr = [];
|
|
167
|
+
const originals = {
|
|
168
|
+
log: console.log,
|
|
169
|
+
info: console.info,
|
|
170
|
+
debug: console.debug,
|
|
171
|
+
warn: console.warn,
|
|
172
|
+
error: console.error
|
|
173
|
+
};
|
|
174
|
+
console.log = console.info = console.debug = (...a) => stdout.push(format(...a));
|
|
175
|
+
console.warn = console.error = (...a) => stderr.push(format(...a));
|
|
176
|
+
let timer;
|
|
177
|
+
try {
|
|
178
|
+
const runFn = module.run;
|
|
179
|
+
const timeout = new Promise((_, reject) => {
|
|
180
|
+
timer = setTimeout(() => reject(new WebSkillError("RUN_TIMEOUT", `Script "${scriptName}" timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
181
|
+
});
|
|
182
|
+
const content = normalizeToolContent(await Promise.race([Promise.resolve().then(() => runFn(args, context)), timeout]));
|
|
183
|
+
if (stdout.length > 0) content.push({
|
|
184
|
+
type: "text",
|
|
185
|
+
text: stdout.join("\n")
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
content
|
|
190
|
+
};
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return this.#failure(e, stderr);
|
|
193
|
+
} finally {
|
|
194
|
+
if (timer) clearTimeout(timer);
|
|
195
|
+
console.log = originals.log;
|
|
196
|
+
console.info = originals.info;
|
|
197
|
+
console.debug = originals.debug;
|
|
198
|
+
console.warn = originals.warn;
|
|
199
|
+
console.error = originals.error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
#missingRequiredArgs(schema, args) {
|
|
203
|
+
if (typeof schema !== "object" || schema === null) return [];
|
|
204
|
+
const required = schema.required;
|
|
205
|
+
if (!Array.isArray(required)) return [];
|
|
206
|
+
return required.filter((key) => typeof key === "string" && args[key] === void 0);
|
|
207
|
+
}
|
|
208
|
+
#failure(e, stderr = []) {
|
|
209
|
+
const isTimeout = e instanceof WebSkillError && e.code === "RUN_TIMEOUT";
|
|
210
|
+
const code = e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED";
|
|
211
|
+
const base = e instanceof Error ? e.message : String(e);
|
|
212
|
+
const stderrSummary = stderr.length > 0 ? ` | stderr: ${stderr.join(" | ").slice(0, 500)}` : "";
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
content: [],
|
|
216
|
+
error: {
|
|
217
|
+
code: isTimeout ? "RUN_TIMEOUT" : code,
|
|
218
|
+
message: `${base}${stderrSummary}`
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const baseName = (p) => p.split("/").pop() ?? p;
|
|
224
|
+
const toPlatform$1 = (p) => p.split("/").join(path.sep);
|
|
225
|
+
const messageOf$5 = (e) => e instanceof Error ? e.message : String(e);
|
|
226
|
+
/** Worker 入口文件:src 为同目录 .ts;dist 为 dist/executor/ 下的 .js */
|
|
227
|
+
function workerEntryPath() {
|
|
228
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
229
|
+
const candidates = [
|
|
230
|
+
path.join(dir, "sandboxWorkerEntry.js"),
|
|
231
|
+
path.join(dir, "sandboxWorkerEntry.ts"),
|
|
232
|
+
path.join(dir, "executor", "sandboxWorkerEntry.js")
|
|
233
|
+
];
|
|
234
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
235
|
+
return candidates[0];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* D1:worker_threads 沙箱脚本执行器。
|
|
239
|
+
* 每次执行独立 Worker(resourceLimits + env 白名单),loadDefinition 同样在
|
|
240
|
+
* Worker 内完成(禁止主进程 import 不可信脚本);超时 terminate(可杀同步死循环)。
|
|
241
|
+
* 能力桥协议与浏览器同一来源(runtime/sandbox/bridgeProtocol)。
|
|
242
|
+
*/
|
|
243
|
+
var SandboxedScriptExecutor = class {
|
|
244
|
+
#fs;
|
|
245
|
+
#options;
|
|
246
|
+
#capabilities;
|
|
247
|
+
constructor(fs, options = {}) {
|
|
248
|
+
this.#fs = fs;
|
|
249
|
+
this.#options = options;
|
|
250
|
+
this.#capabilities = {
|
|
251
|
+
readReference: options.capabilities?.readReference ?? true,
|
|
252
|
+
writeArtifact: options.capabilities?.writeArtifact ?? true,
|
|
253
|
+
confirm: options.capabilities?.confirm ?? true
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async #locateScript(skillRoot, scriptName) {
|
|
257
|
+
const tsPath = `${skillRoot}/scripts/${scriptName}.ts`;
|
|
258
|
+
const jsPath = `${skillRoot}/scripts/${scriptName}.js`;
|
|
259
|
+
const [hasTs, hasJs] = await Promise.all([this.#fs.exists(tsPath), this.#fs.exists(jsPath)]);
|
|
260
|
+
if (hasTs && hasJs) throw new WebSkillError("TOOL_EXECUTION_FAILED", `Script "${scriptName}" is ambiguous: both .ts and .js exist under ${skillRoot}/scripts`);
|
|
261
|
+
if (!hasTs && !hasJs) throw new WebSkillError("TOOL_NOT_FOUND", `Script "${scriptName}" not found under ${skillRoot}/scripts`);
|
|
262
|
+
return hasTs ? tsPath : jsPath;
|
|
263
|
+
}
|
|
264
|
+
async loadDefinition(skillRoot, scriptName) {
|
|
265
|
+
const scriptPath = await this.#locateScript(skillRoot, scriptName);
|
|
266
|
+
const skillName = baseName(skillRoot);
|
|
267
|
+
const result = await this.#runWorker({
|
|
268
|
+
mode: "load",
|
|
269
|
+
scriptPath: toPlatform$1(scriptPath),
|
|
270
|
+
scriptName
|
|
271
|
+
});
|
|
272
|
+
if (!result.ok) throw new WebSkillError("TOOL_EXECUTION_FAILED", `Failed to load script "${scriptName}": ${result.error?.message ?? "unknown error"}`);
|
|
273
|
+
return {
|
|
274
|
+
name: `${skillName}__${scriptName}`,
|
|
275
|
+
description: result.definition?.description,
|
|
276
|
+
inputSchema: result.definition?.inputSchema,
|
|
277
|
+
source: "script",
|
|
278
|
+
skillName
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async execute(input) {
|
|
282
|
+
const { skillRoot, scriptName, args, context, timeoutMs } = input;
|
|
283
|
+
let scriptPath;
|
|
284
|
+
try {
|
|
285
|
+
scriptPath = await this.#locateScript(skillRoot, scriptName);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
content: [],
|
|
290
|
+
error: {
|
|
291
|
+
code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
|
|
292
|
+
message: messageOf$5(e)
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const result = await this.#runWorker({
|
|
298
|
+
mode: "execute",
|
|
299
|
+
scriptPath: toPlatform$1(scriptPath),
|
|
300
|
+
scriptName,
|
|
301
|
+
skillName: context.skillName,
|
|
302
|
+
runId: context.runId,
|
|
303
|
+
args
|
|
304
|
+
}, timeoutMs, (request) => this.#handleBridge(request, context));
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
const stderrSummary = result.stderr?.length ? ` | stderr: ${result.stderr.join(" | ").slice(0, 500)}` : "";
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
content: [],
|
|
310
|
+
error: {
|
|
311
|
+
code: result.error?.code ?? "TOOL_EXECUTION_FAILED",
|
|
312
|
+
message: `${result.error?.message ?? "script execution failed"}${stderrSummary}`
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const content = normalizeToolContent(result.value);
|
|
317
|
+
if (result.stdout?.length) content.push({
|
|
318
|
+
type: "text",
|
|
319
|
+
text: result.stdout.join("\n")
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
content
|
|
324
|
+
};
|
|
325
|
+
} catch (e) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
content: [],
|
|
329
|
+
error: {
|
|
330
|
+
code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
|
|
331
|
+
message: messageOf$5(e)
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** 起独立 Worker 执行任务;超时 terminate(同步死循环可杀);非零退出码 → TOOL_EXECUTION_FAILED */
|
|
337
|
+
#runWorker(workerData, timeoutMs = 3e4, onBridge) {
|
|
338
|
+
const envWhitelist = this.#options.envWhitelist ?? [];
|
|
339
|
+
const env = Object.fromEntries(envWhitelist.filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]]));
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
const worker = new Worker(workerEntryPath(), {
|
|
342
|
+
workerData,
|
|
343
|
+
env,
|
|
344
|
+
resourceLimits: this.#options.resourceLimits ?? {
|
|
345
|
+
maxOldGenerationSizeMb: 64,
|
|
346
|
+
maxYoungGenerationSizeMb: 16
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
let settled = false;
|
|
350
|
+
const timer = setTimeout(() => {
|
|
351
|
+
if (settled) return;
|
|
352
|
+
settled = true;
|
|
353
|
+
worker.terminate();
|
|
354
|
+
reject(new WebSkillError("RUN_TIMEOUT", `Sandboxed script timed out after ${timeoutMs}ms (worker terminated)`));
|
|
355
|
+
}, timeoutMs);
|
|
356
|
+
const done = (fn) => {
|
|
357
|
+
if (settled) return;
|
|
358
|
+
settled = true;
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
fn();
|
|
361
|
+
};
|
|
362
|
+
worker.on("message", (msg) => {
|
|
363
|
+
if (msg?.type === "bridge" && onBridge) {
|
|
364
|
+
const request = parseBridgeRequest(msg.request);
|
|
365
|
+
const respond = (response) => worker.postMessage({
|
|
366
|
+
type: "bridge-response",
|
|
367
|
+
response
|
|
368
|
+
});
|
|
369
|
+
if (!request) {
|
|
370
|
+
respond(bridgeError("unknown", "TOOL_EXECUTION_FAILED", "invalid bridge request"));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf$5(e))));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (msg?.type === "load-result" || msg?.type === "execute-result") done(() => resolve(msg));
|
|
377
|
+
});
|
|
378
|
+
worker.on("error", (e) => done(() => reject(e)));
|
|
379
|
+
worker.on("exit", (code) => {
|
|
380
|
+
if (code !== 0) done(() => reject(new WebSkillError("TOOL_EXECUTION_FAILED", `Sandbox worker exited with code ${code}`)));
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** 能力桥宿主侧处理:能力关闭即 TOOL_UNSUPPORTED;委托 context */
|
|
385
|
+
async #handleBridge(request, context) {
|
|
386
|
+
const unsupported = (capability) => bridgeError(request.id, "TOOL_UNSUPPORTED", `Capability "${capability}" is disabled`);
|
|
387
|
+
try {
|
|
388
|
+
switch (request.kind) {
|
|
389
|
+
case "readReference":
|
|
390
|
+
if (!this.#capabilities.readReference) return unsupported("readReference");
|
|
391
|
+
return {
|
|
392
|
+
id: request.id,
|
|
393
|
+
ok: true,
|
|
394
|
+
value: await context.readReference(request.path)
|
|
395
|
+
};
|
|
396
|
+
case "writeArtifact": {
|
|
397
|
+
if (!this.#capabilities.writeArtifact) return unsupported("writeArtifact");
|
|
398
|
+
const content = typeof request.content === "string" ? request.content : new Uint8Array(request.content);
|
|
399
|
+
const artifact = await context.writeArtifact(request.path, content, { ...request.mimeType ? { mimeType: request.mimeType } : {} });
|
|
400
|
+
return {
|
|
401
|
+
id: request.id,
|
|
402
|
+
ok: true,
|
|
403
|
+
value: artifact
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
case "confirm":
|
|
407
|
+
if (!this.#capabilities.confirm || !context.confirm) return unsupported("confirm");
|
|
408
|
+
return {
|
|
409
|
+
id: request.id,
|
|
410
|
+
ok: true,
|
|
411
|
+
value: await context.confirm(request.message)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
} catch (e) {
|
|
415
|
+
return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf$5(e));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
/**
|
|
420
|
+
* FileArtifactStore:兼容别名,语义同阶段 2-4。
|
|
421
|
+
* 实现已上移到 runtime 的 FsArtifactStore;node 侧保留 NodeFS 默认值。
|
|
422
|
+
*/
|
|
423
|
+
var FileArtifactStore = class extends FsArtifactStore {
|
|
424
|
+
constructor(deps) {
|
|
425
|
+
super({
|
|
426
|
+
root: deps.root,
|
|
427
|
+
fs: deps.fs ?? new NodeFS()
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* FileMemoryStore:兼容别名,语义同阶段 3。
|
|
433
|
+
* 实现已上移到 runtime 的 FsMemoryStore;node 侧保留 NodeFS 默认值。
|
|
434
|
+
*/
|
|
435
|
+
var FileMemoryStore = class extends FsMemoryStore {
|
|
436
|
+
constructor(deps) {
|
|
437
|
+
super({
|
|
438
|
+
root: deps.root,
|
|
439
|
+
fs: deps.fs ?? new NodeFS()
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* 命令行 UiBridge:ask→问答,confirm→y/n(带默认值),
|
|
445
|
+
* form→逐字段提示(显示默认值与必填标记),select→编号列表。
|
|
446
|
+
* 输入/输出流可注入(测试用 PassThrough)。
|
|
447
|
+
*/
|
|
448
|
+
var CliUiBridge = class {
|
|
449
|
+
#input;
|
|
450
|
+
#output;
|
|
451
|
+
constructor(deps = {}) {
|
|
452
|
+
this.#input = deps.input ?? process.stdin;
|
|
453
|
+
this.#output = deps.output ?? process.stdout;
|
|
454
|
+
}
|
|
455
|
+
async request(input) {
|
|
456
|
+
switch (input.type) {
|
|
457
|
+
case "ask": return {
|
|
458
|
+
id: input.id,
|
|
459
|
+
value: await this.#question(`${input.message}\n> `)
|
|
460
|
+
};
|
|
461
|
+
case "confirm": {
|
|
462
|
+
const hint = input.defaultValue === false ? "y/N" : "Y/n";
|
|
463
|
+
const answer = (await this.#question(`${input.message} [${hint}] `)).trim().toLowerCase();
|
|
464
|
+
const value = answer === "" ? input.defaultValue ?? true : answer === "y" || answer === "yes";
|
|
465
|
+
return {
|
|
466
|
+
id: input.id,
|
|
467
|
+
value
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
case "form": {
|
|
471
|
+
const value = {};
|
|
472
|
+
if (input.title) this.#write(`${input.title}\n`);
|
|
473
|
+
for (const field of input.fields) value[field.name] = await this.#askField(field);
|
|
474
|
+
return {
|
|
475
|
+
id: input.id,
|
|
476
|
+
value
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
case "select": {
|
|
480
|
+
const lines = input.options.map((o, i) => ` ${i + 1}) ${o.label}`).join("\n");
|
|
481
|
+
const answer = (await this.#question(`${input.message}\n${lines}\n> `)).trim();
|
|
482
|
+
const index = Number.parseInt(answer, 10) - 1;
|
|
483
|
+
const option = input.options[index];
|
|
484
|
+
return {
|
|
485
|
+
id: input.id,
|
|
486
|
+
value: option ? option.value : void 0
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async progress(input) {
|
|
492
|
+
const pct = input.value !== void 0 ? ` (${Math.round(input.value * 100)}%)` : "";
|
|
493
|
+
this.#write(`[progress] ${input.message}${pct}\n`);
|
|
494
|
+
}
|
|
495
|
+
async renderResult(input) {
|
|
496
|
+
const lines = [`\n[result] ${input.summary ?? ""}`];
|
|
497
|
+
for (const block of input.blocks) if (block.type === "markdown") lines.push(block.text);
|
|
498
|
+
else if (block.type === "json") lines.push(JSON.stringify(block.data, null, 2));
|
|
499
|
+
else if (block.type === "file") lines.push(`[file] ${block.path}`);
|
|
500
|
+
this.#write(`${lines.join("\n")}\n`);
|
|
501
|
+
}
|
|
502
|
+
#write(text) {
|
|
503
|
+
this.#output.write(text);
|
|
504
|
+
}
|
|
505
|
+
async #askField(field) {
|
|
506
|
+
const requiredMark = field.required ? " (required)" : "";
|
|
507
|
+
const defaultMark = field.defaultValue !== void 0 ? ` [${JSON.stringify(field.defaultValue)}]` : "";
|
|
508
|
+
if (field.type === "select" && field.options) {
|
|
509
|
+
const lines = field.options.map((o, i) => ` ${i + 1}) ${o.label}`).join("\n");
|
|
510
|
+
const answer = (await this.#question(`${field.label}${requiredMark}${defaultMark}:\n${lines}\n> `)).trim();
|
|
511
|
+
if (answer === "") return field.defaultValue;
|
|
512
|
+
const option = field.options[Number.parseInt(answer, 10) - 1];
|
|
513
|
+
return option ? option.value : field.defaultValue;
|
|
514
|
+
}
|
|
515
|
+
const answer = (await this.#question(`${field.label}${requiredMark}${defaultMark}: `)).trim();
|
|
516
|
+
if (answer === "") return field.defaultValue;
|
|
517
|
+
switch (field.type) {
|
|
518
|
+
case "number": {
|
|
519
|
+
const n = Number(answer);
|
|
520
|
+
return Number.isNaN(n) ? field.defaultValue : n;
|
|
521
|
+
}
|
|
522
|
+
case "boolean": return answer === "y" || answer === "yes" || answer === "true";
|
|
523
|
+
case "textarea": try {
|
|
524
|
+
return JSON.parse(answer);
|
|
525
|
+
} catch {
|
|
526
|
+
return answer;
|
|
527
|
+
}
|
|
528
|
+
default: return answer;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
#rl;
|
|
532
|
+
#lines = [];
|
|
533
|
+
#waiters = [];
|
|
534
|
+
/** 常驻 line 监听进入内部队列:预写入的多行输入不会在两次提问之间丢失 */
|
|
535
|
+
#ensureInterface() {
|
|
536
|
+
if (this.#rl) return;
|
|
537
|
+
this.#rl = createInterface({
|
|
538
|
+
input: this.#input,
|
|
539
|
+
output: this.#output,
|
|
540
|
+
terminal: false
|
|
541
|
+
});
|
|
542
|
+
this.#rl.on("line", (line) => {
|
|
543
|
+
const waiter = this.#waiters.shift();
|
|
544
|
+
if (waiter) waiter(line);
|
|
545
|
+
else this.#lines.push(line);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async #question(prompt) {
|
|
549
|
+
this.#ensureInterface();
|
|
550
|
+
this.#output.write(prompt);
|
|
551
|
+
const queued = this.#lines.shift();
|
|
552
|
+
if (queued !== void 0) return queued;
|
|
553
|
+
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const messageOf$4 = (e) => e instanceof Error ? e.message : String(e);
|
|
557
|
+
const toPlatform = (p) => p.split("/").join(path.sep);
|
|
558
|
+
const isZipArchive = (data) => data.length > 1 && data[0] === 80 && data[1] === 75;
|
|
559
|
+
/**
|
|
560
|
+
* zip 解包(fflate)。每个条目路径过 resolveInsideRoot:
|
|
561
|
+
* 拒绝 `..` 段、绝对路径、反斜杠穿越(zip-slip 防护)。
|
|
562
|
+
*/
|
|
563
|
+
async function extractZipData(fs, data, destRoot) {
|
|
564
|
+
let entries;
|
|
565
|
+
try {
|
|
566
|
+
entries = unzipSync(data);
|
|
567
|
+
} catch (e) {
|
|
568
|
+
throw new WebSkillError("INSTALL_FAILED", `Failed to read zip archive: ${messageOf$4(e)}`, e);
|
|
569
|
+
}
|
|
570
|
+
for (const [entryPath, content] of Object.entries(entries)) {
|
|
571
|
+
const target = resolveInsideRoot(destRoot, entryPath);
|
|
572
|
+
if (entryPath.endsWith("/")) await fs.mkdir(target);
|
|
573
|
+
else await fs.writeBinary(target, content);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* tar/tar.gz 解包(tar 包)。先列条目逐一过 resolveInsideRoot 校验,
|
|
578
|
+
* 再解包;符号链接/硬链接条目不跟随(跳过)。
|
|
579
|
+
*/
|
|
580
|
+
async function extractTarFile(archivePath, destRoot) {
|
|
581
|
+
try {
|
|
582
|
+
const entryPaths = [];
|
|
583
|
+
await tar.t({
|
|
584
|
+
file: toPlatform(archivePath),
|
|
585
|
+
onentry: (entry) => {
|
|
586
|
+
entryPaths.push(entry.path);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
for (const p of entryPaths) if (!p.replace(/\\/g, "/").split("/").every((s) => s === "" || s === ".")) resolveInsideRoot(destRoot, p);
|
|
590
|
+
await tar.x({
|
|
591
|
+
file: toPlatform(archivePath),
|
|
592
|
+
cwd: toPlatform(destRoot),
|
|
593
|
+
filter: (_p, entry) => !("type" in entry) || entry.type !== "SymbolicLink" && entry.type !== "Link"
|
|
594
|
+
});
|
|
595
|
+
} catch (e) {
|
|
596
|
+
if (e instanceof WebSkillError) throw e;
|
|
597
|
+
throw new WebSkillError("INSTALL_FAILED", `Failed to extract tar archive: ${messageOf$4(e)}`, e);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** 递归列出目录内全部文件(相对路径,posix 分隔,不含目录条目) */
|
|
601
|
+
async function listFiles(fs, root, prefix = "") {
|
|
602
|
+
const out = [];
|
|
603
|
+
for (const entry of await fs.list(root)) {
|
|
604
|
+
const name = entry.path.split("/").pop() ?? "";
|
|
605
|
+
const rel = prefix === "" ? name : `${prefix}/${name}`;
|
|
606
|
+
if (entry.type === "directory") out.push(...await listFiles(fs, entry.path, rel));
|
|
607
|
+
else out.push(rel);
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
/** 递归拷贝目录(含子目录与二进制文件) */
|
|
612
|
+
async function copyDir(fs, from, to) {
|
|
613
|
+
await fs.mkdir(to);
|
|
614
|
+
for (const entry of await fs.list(from)) {
|
|
615
|
+
const name = entry.path.split("/").pop() ?? "";
|
|
616
|
+
if (entry.type === "directory") await copyDir(fs, entry.path, `${to}/${name}`);
|
|
617
|
+
else await fs.writeBinary(`${to}/${name}`, await fs.readBinary(entry.path));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** 删除目录(不存在时静默) */
|
|
621
|
+
async function removeDirQuiet(fs, dir) {
|
|
622
|
+
try {
|
|
623
|
+
if (await fs.exists(dir)) await fs.remove(dir, { recursive: true });
|
|
624
|
+
} catch {}
|
|
625
|
+
}
|
|
626
|
+
/** 技能目录内的安装清单文件名(不参与 files 列表与 digest) */
|
|
627
|
+
const SKILL_MANIFEST_FILE = "webskill.skill-manifest.json";
|
|
628
|
+
/** managed root 下的锁定文件名(不参与 files 列表与 digest) */
|
|
629
|
+
const SKILLS_LOCKFILE = "skills.lock.json";
|
|
630
|
+
const messageOf$3 = (e) => e instanceof Error ? e.message : String(e);
|
|
631
|
+
/** 导出技能目录全部文件(含 manifest)为 zip/tar 归档,返回 outPath */
|
|
632
|
+
async function exportArchive(fs, skillRoot, options) {
|
|
633
|
+
try {
|
|
634
|
+
if (options.format === "zip") {
|
|
635
|
+
const files = {};
|
|
636
|
+
for (const rel of await listFiles(fs, skillRoot)) files[rel] = await fs.readBinary(`${skillRoot}/${rel}`);
|
|
637
|
+
await fs.writeBinary(options.outPath, zipSync(files, { level: 6 }));
|
|
638
|
+
} else await tar.c({
|
|
639
|
+
file: toPlatform(options.outPath),
|
|
640
|
+
cwd: toPlatform(skillRoot)
|
|
641
|
+
}, ["."]);
|
|
642
|
+
return options.outPath;
|
|
643
|
+
} catch (e) {
|
|
644
|
+
if (e instanceof WebSkillError) throw e;
|
|
645
|
+
throw new WebSkillError("EXPORT_FAILED", `Failed to export ${options.format} archive: ${messageOf$3(e)}`, e);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/** 只解出归档中的 webskill.skill-manifest.json 条目(安装前预览) */
|
|
649
|
+
async function readArchiveManifest(fs, archivePath) {
|
|
650
|
+
const notFound = () => new WebSkillError("EXPORT_FAILED", `Archive ${archivePath} does not contain ${SKILL_MANIFEST_FILE}`);
|
|
651
|
+
const data = await fs.readBinary(archivePath);
|
|
652
|
+
if (isZipArchive(data)) {
|
|
653
|
+
const entries = unzipSync(data);
|
|
654
|
+
const key = Object.keys(entries).find((k) => k === "webskill.skill-manifest.json" || k.endsWith(`/webskill.skill-manifest.json`));
|
|
655
|
+
if (!key) throw notFound();
|
|
656
|
+
return JSON.parse(new TextDecoder().decode(entries[key]));
|
|
657
|
+
}
|
|
658
|
+
let manifestText;
|
|
659
|
+
try {
|
|
660
|
+
await tar.t({
|
|
661
|
+
file: toPlatform(archivePath),
|
|
662
|
+
onentry: (entry) => {
|
|
663
|
+
if (entry.path === "webskill.skill-manifest.json" || entry.path.endsWith(`/webskill.skill-manifest.json`)) {
|
|
664
|
+
const chunks = [];
|
|
665
|
+
entry.on("data", (chunk) => chunks.push(chunk));
|
|
666
|
+
entry.on("end", () => {
|
|
667
|
+
manifestText = Buffer.concat(chunks).toString("utf8");
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
} catch (e) {
|
|
673
|
+
throw new WebSkillError("EXPORT_FAILED", `Failed to read tar archive: ${messageOf$3(e)}`, e);
|
|
674
|
+
}
|
|
675
|
+
if (manifestText === void 0) throw notFound();
|
|
676
|
+
return JSON.parse(manifestText);
|
|
677
|
+
}
|
|
678
|
+
const _execFileP = promisify(execFile);
|
|
679
|
+
const messageOf$2 = (e) => e instanceof Error ? e.message : String(e);
|
|
680
|
+
/**
|
|
681
|
+
* 跨平台执行命令:Windows 下对 npm 等 cmd 包装的命令通过 cmd.exe 代理执行,
|
|
682
|
+
* git 等原生 exe 不受影响。
|
|
683
|
+
*/
|
|
684
|
+
async function execFileP(command, args) {
|
|
685
|
+
if (process.platform === "win32" && command === "npm") return _execFileP("cmd", [
|
|
686
|
+
"/c",
|
|
687
|
+
command,
|
|
688
|
+
...args
|
|
689
|
+
]);
|
|
690
|
+
return _execFileP(command, args);
|
|
691
|
+
}
|
|
692
|
+
/** 命令缺失/失败 → INSTALL_FAILED 结构化诊断 */
|
|
693
|
+
function commandFailed(command, e) {
|
|
694
|
+
return new WebSkillError("INSTALL_FAILED", `Failed to run ${command}: ${e?.code === "ENOENT" ? `command "${command}" not found on this system` : messageOf$2(e)}`, e);
|
|
695
|
+
}
|
|
696
|
+
/** git 源:clone --depth 1 + rev-parse HEAD 记录 commit */
|
|
697
|
+
async function stageGit(source, ctx) {
|
|
698
|
+
const skillDir = `${ctx.stagingRoot}/repo`;
|
|
699
|
+
const args = [
|
|
700
|
+
"clone",
|
|
701
|
+
"--depth",
|
|
702
|
+
"1",
|
|
703
|
+
...source.ref ? ["--branch", source.ref] : [],
|
|
704
|
+
source.url,
|
|
705
|
+
skillDir
|
|
706
|
+
];
|
|
707
|
+
try {
|
|
708
|
+
await execFileP("git", args);
|
|
709
|
+
} catch (e) {
|
|
710
|
+
throw commandFailed("git", e);
|
|
711
|
+
}
|
|
712
|
+
let commit;
|
|
713
|
+
try {
|
|
714
|
+
const { stdout } = await execFileP("git", [
|
|
715
|
+
"-C",
|
|
716
|
+
skillDir,
|
|
717
|
+
"rev-parse",
|
|
718
|
+
"HEAD"
|
|
719
|
+
]);
|
|
720
|
+
commit = stdout.trim() || void 0;
|
|
721
|
+
} catch {}
|
|
722
|
+
return {
|
|
723
|
+
skillDir,
|
|
724
|
+
source: {
|
|
725
|
+
...source,
|
|
726
|
+
...commit ? { commit } : {}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function sha256Hex(data) {
|
|
731
|
+
return createHash("sha256").update(data).digest("hex");
|
|
732
|
+
}
|
|
733
|
+
/** 整体 digest:逐文件 "<path>:<sha256>\n" 拼接串(文件按 path 排序)的 sha256 */
|
|
734
|
+
function computeDigest(files) {
|
|
735
|
+
return sha256Hex([...files].sort((a, b) => a.path.localeCompare(b.path)).map((f) => `${f.path}:${f.sha256}\n`).join(""));
|
|
736
|
+
}
|
|
737
|
+
const messageOf$1 = (e) => e instanceof Error ? e.message : String(e);
|
|
738
|
+
/** http 源:下载归档(可选 expectedSha256 校验包体)→ 解包 → 定位技能根 */
|
|
739
|
+
async function stageHttp(source, ctx, expectedSha256) {
|
|
740
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
741
|
+
let res;
|
|
742
|
+
try {
|
|
743
|
+
res = await fetchImpl(source.url);
|
|
744
|
+
} catch (e) {
|
|
745
|
+
throw new WebSkillError("INSTALL_FAILED", `Download failed: ${messageOf$1(e)}`, e);
|
|
746
|
+
}
|
|
747
|
+
if (!res.ok) throw new WebSkillError("INSTALL_FAILED", `Download failed with HTTP ${res.status}`);
|
|
748
|
+
const data = new Uint8Array(await res.arrayBuffer());
|
|
749
|
+
if (expectedSha256 !== void 0) {
|
|
750
|
+
const actual = sha256Hex(data);
|
|
751
|
+
if (actual !== expectedSha256) throw new WebSkillError("INSTALL_FAILED", `Archive checksum mismatch: expected ${expectedSha256}, got ${actual}`);
|
|
752
|
+
}
|
|
753
|
+
const contentDir = `${ctx.stagingRoot}/content`;
|
|
754
|
+
await ctx.fs.mkdir(contentDir);
|
|
755
|
+
if (isZipArchive(data)) await extractZipData(ctx.fs, data, contentDir);
|
|
756
|
+
else {
|
|
757
|
+
const archivePath = `${ctx.stagingRoot}/archive.tar`;
|
|
758
|
+
await ctx.fs.writeBinary(archivePath, data);
|
|
759
|
+
await extractTarFile(toPlatform(archivePath), toPlatform(contentDir));
|
|
760
|
+
}
|
|
761
|
+
if (await ctx.fs.exists(`${contentDir}/SKILL.md`)) return {
|
|
762
|
+
skillDir: contentDir,
|
|
763
|
+
source
|
|
764
|
+
};
|
|
765
|
+
const dirs = (await ctx.fs.list(contentDir)).filter((e) => e.type === "directory");
|
|
766
|
+
if (dirs.length === 1 && await ctx.fs.exists(`${dirs[0]?.path}/SKILL.md`)) return {
|
|
767
|
+
skillDir: dirs[0].path,
|
|
768
|
+
source
|
|
769
|
+
};
|
|
770
|
+
throw new WebSkillError("INSTALL_FAILED", "Archive does not contain a SKILL.md at its root or in a single top-level directory");
|
|
771
|
+
}
|
|
772
|
+
/** local 源:校验源目录存在,拷贝到 staging */
|
|
773
|
+
async function stageLocal(source, ctx) {
|
|
774
|
+
if (!await ctx.fs.exists(source.path)) throw new WebSkillError("INSTALL_FAILED", `Local skill directory not found: ${source.path}`);
|
|
775
|
+
const skillDir = `${ctx.stagingRoot}/skill`;
|
|
776
|
+
await copyDir(ctx.fs, source.path, skillDir);
|
|
777
|
+
return {
|
|
778
|
+
skillDir,
|
|
779
|
+
source
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/** npm 源:npm pack → 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根 */
|
|
783
|
+
async function stageNpm(source, ctx) {
|
|
784
|
+
const spec = source.version ? `${source.packageName}@${source.version}` : source.packageName;
|
|
785
|
+
let stdout;
|
|
786
|
+
try {
|
|
787
|
+
({stdout} = await execFileP("npm", [
|
|
788
|
+
"pack",
|
|
789
|
+
spec,
|
|
790
|
+
"--pack-destination",
|
|
791
|
+
ctx.stagingRoot
|
|
792
|
+
]));
|
|
793
|
+
} catch (e) {
|
|
794
|
+
throw commandFailed("npm", e);
|
|
795
|
+
}
|
|
796
|
+
const tarball = stdout.trim().split("\n").filter(Boolean).pop();
|
|
797
|
+
const tgzPath = tarball ? `${ctx.stagingRoot}/${tarball}` : "";
|
|
798
|
+
if (!tarball || !await ctx.fs.exists(tgzPath)) throw new WebSkillError("INSTALL_FAILED", `npm pack output not found in staging directory: ${tarball ?? "(empty output)"}`);
|
|
799
|
+
const pkgDir = `${ctx.stagingRoot}/pkg`;
|
|
800
|
+
await ctx.fs.mkdir(pkgDir);
|
|
801
|
+
await extractTarFile(toPlatform(tgzPath), toPlatform(pkgDir));
|
|
802
|
+
const pkgRoot = `${pkgDir}/package`;
|
|
803
|
+
const skillDir = await ctx.fs.exists(`${pkgRoot}/skill/SKILL.md`) ? `${pkgRoot}/skill` : pkgRoot;
|
|
804
|
+
let packageName = source.packageName;
|
|
805
|
+
let version = source.version;
|
|
806
|
+
try {
|
|
807
|
+
const pkg = JSON.parse(await ctx.fs.readText(`${pkgRoot}/package.json`));
|
|
808
|
+
packageName = pkg.name ?? packageName;
|
|
809
|
+
version = pkg.version ?? version;
|
|
810
|
+
} catch {}
|
|
811
|
+
return {
|
|
812
|
+
skillDir,
|
|
813
|
+
source: {
|
|
814
|
+
type: "npm",
|
|
815
|
+
packageName,
|
|
816
|
+
...version ? { version } : {}
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
const emptyLockfile = () => ({
|
|
821
|
+
schemaVersion: 1,
|
|
822
|
+
skills: {}
|
|
823
|
+
});
|
|
824
|
+
/** 读取 lockfile;不存在返回空结构;损坏 JSON → INTEGRITY_FAILED */
|
|
825
|
+
async function readLockfile(fs, managedRoot) {
|
|
826
|
+
const path = `${managedRoot}/${SKILLS_LOCKFILE}`;
|
|
827
|
+
if (!await fs.exists(path)) return emptyLockfile();
|
|
828
|
+
try {
|
|
829
|
+
const parsed = JSON.parse(await fs.readText(path));
|
|
830
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.skills !== "object") throw new Error("unexpected lockfile shape");
|
|
831
|
+
return parsed;
|
|
832
|
+
} catch (e) {
|
|
833
|
+
throw new WebSkillError("INTEGRITY_FAILED", `Skills lockfile at ${path} is corrupted: ${e instanceof Error ? e.message : String(e)}`, e);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
async function upsertLockEntry(fs, managedRoot, name, entry) {
|
|
837
|
+
const lockfile = await readLockfile(fs, managedRoot);
|
|
838
|
+
lockfile.skills[name] = entry;
|
|
839
|
+
await fs.writeText(`${managedRoot}/${SKILLS_LOCKFILE}`, JSON.stringify(lockfile, null, 2));
|
|
840
|
+
return lockfile;
|
|
841
|
+
}
|
|
842
|
+
async function removeLockEntry(fs, managedRoot, name) {
|
|
843
|
+
const lockfile = await readLockfile(fs, managedRoot);
|
|
844
|
+
delete lockfile.skills[name];
|
|
845
|
+
await fs.writeText(`${managedRoot}/${SKILLS_LOCKFILE}`, JSON.stringify(lockfile, null, 2));
|
|
846
|
+
return lockfile;
|
|
847
|
+
}
|
|
848
|
+
const EXCLUDED_FILES = /* @__PURE__ */ new Set([SKILL_MANIFEST_FILE, SKILLS_LOCKFILE]);
|
|
849
|
+
/** 递归收集技能目录内的文件(相对路径,posix 分隔) */
|
|
850
|
+
async function walkFiles(fs, root, prefix = "") {
|
|
851
|
+
const out = [];
|
|
852
|
+
for (const entry of await fs.list(root)) {
|
|
853
|
+
const name = entry.path.split("/").pop() ?? "";
|
|
854
|
+
const rel = prefix === "" ? name : `${prefix}/${name}`;
|
|
855
|
+
if (entry.type === "directory") out.push(...await walkFiles(fs, entry.path, rel));
|
|
856
|
+
else if (!EXCLUDED_FILES.has(name)) out.push(rel);
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
/** 生成并写入 webskill.skill-manifest.json */
|
|
861
|
+
async function createManifest(fs, skillRoot, input) {
|
|
862
|
+
const files = [];
|
|
863
|
+
for (const rel of (await walkFiles(fs, skillRoot)).sort()) {
|
|
864
|
+
const content = await fs.readBinary(`${skillRoot}/${rel}`);
|
|
865
|
+
files.push({
|
|
866
|
+
path: rel,
|
|
867
|
+
size: content.length,
|
|
868
|
+
sha256: sha256Hex(content)
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
const manifest = {
|
|
872
|
+
schemaVersion: 1,
|
|
873
|
+
name: input.name,
|
|
874
|
+
...input.version !== void 0 ? { version: input.version } : {},
|
|
875
|
+
source: input.source,
|
|
876
|
+
installedAt: input.installedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
877
|
+
integrity: {
|
|
878
|
+
algorithm: "sha256",
|
|
879
|
+
digest: computeDigest(files)
|
|
880
|
+
},
|
|
881
|
+
files
|
|
882
|
+
};
|
|
883
|
+
await fs.writeText(`${skillRoot}/${SKILL_MANIFEST_FILE}`, JSON.stringify(manifest, null, 2));
|
|
884
|
+
return manifest;
|
|
885
|
+
}
|
|
886
|
+
/** 读取技能目录内的 manifest;缺失/损坏 → INTEGRITY_FAILED */
|
|
887
|
+
async function readManifest(fs, skillRoot) {
|
|
888
|
+
const path = `${skillRoot}/${SKILL_MANIFEST_FILE}`;
|
|
889
|
+
if (!await fs.exists(path)) throw new WebSkillError("INTEGRITY_FAILED", `Skill manifest not found at ${path} (was the skill installed via SkillManager?)`);
|
|
890
|
+
try {
|
|
891
|
+
return JSON.parse(await fs.readText(path));
|
|
892
|
+
} catch (e) {
|
|
893
|
+
throw new WebSkillError("INTEGRITY_FAILED", `Skill manifest at ${path} is corrupted: ${e instanceof Error ? e.message : String(e)}`, e);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
/** 按 manifest.files 逐文件重算 sha256 比对 */
|
|
897
|
+
async function verifyIntegrity(fs, skillRoot) {
|
|
898
|
+
const manifest = await readManifest(fs, skillRoot);
|
|
899
|
+
const mismatches = [];
|
|
900
|
+
for (const file of manifest.files) {
|
|
901
|
+
const path = `${skillRoot}/${file.path}`;
|
|
902
|
+
if (!await fs.exists(path)) {
|
|
903
|
+
mismatches.push(file.path);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (sha256Hex(await fs.readBinary(path)) !== file.sha256) mismatches.push(file.path);
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
ok: mismatches.length === 0,
|
|
910
|
+
mismatches
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
const messageOf = (e) => e instanceof Error ? e.message : String(e);
|
|
914
|
+
const asInstallFailed = (e) => e instanceof WebSkillError && e.code === "INSTALL_FAILED" ? e : new WebSkillError("INSTALL_FAILED", `Install failed: ${messageOf(e)}`, e);
|
|
915
|
+
/**
|
|
916
|
+
* 技能管理门面:统一安装管线(staging → 解析 name → 校验 → 拷贝 → manifest → lockfile),
|
|
917
|
+
* 任何失败清理现场抛 INSTALL_FAILED。
|
|
918
|
+
*/
|
|
919
|
+
var SkillManager = class {
|
|
920
|
+
#managedRoot;
|
|
921
|
+
#fs;
|
|
922
|
+
#fetchImpl;
|
|
923
|
+
constructor(deps) {
|
|
924
|
+
this.#managedRoot = deps.managedRoot.replace(/\/+$/, "");
|
|
925
|
+
this.#fs = deps.fs ?? new NodeFS();
|
|
926
|
+
this.#fetchImpl = deps.fetchImpl;
|
|
927
|
+
}
|
|
928
|
+
async install(source, options) {
|
|
929
|
+
const fs = this.#fs;
|
|
930
|
+
const stagingRoot = (await mkdtemp(path.join(tmpdir(), "webskill-install-"))).split(path.sep).join("/");
|
|
931
|
+
/** 已写入目标目录时用于回滚 */
|
|
932
|
+
let targetDir;
|
|
933
|
+
try {
|
|
934
|
+
const ctx = {
|
|
935
|
+
fs,
|
|
936
|
+
stagingRoot,
|
|
937
|
+
...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
|
|
938
|
+
};
|
|
939
|
+
let staged;
|
|
940
|
+
switch (source.type) {
|
|
941
|
+
case "local":
|
|
942
|
+
staged = await stageLocal(source, ctx);
|
|
943
|
+
break;
|
|
944
|
+
case "http":
|
|
945
|
+
staged = await stageHttp(source, ctx, options?.expectedSha256);
|
|
946
|
+
break;
|
|
947
|
+
case "git":
|
|
948
|
+
staged = await stageGit(source, ctx);
|
|
949
|
+
break;
|
|
950
|
+
case "npm":
|
|
951
|
+
staged = await stageNpm(source, ctx);
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
let name;
|
|
955
|
+
let version;
|
|
956
|
+
try {
|
|
957
|
+
const { metadata } = parseSkillMarkdown(await fs.readText(`${staged.skillDir}/SKILL.md`));
|
|
958
|
+
name = metadata.name;
|
|
959
|
+
version = typeof metadata["version"] === "string" ? metadata["version"] : void 0;
|
|
960
|
+
} catch (e) {
|
|
961
|
+
throw new WebSkillError("INSTALL_FAILED", `Failed to read skill name from SKILL.md: ${messageOf(e)}`, e);
|
|
962
|
+
}
|
|
963
|
+
const finalRoot = `${stagingRoot}/final`;
|
|
964
|
+
const finalDir = `${finalRoot}/${name}`;
|
|
965
|
+
await copyDir(fs, staged.skillDir, finalDir);
|
|
966
|
+
const report = await validateSkills(fs, [finalRoot]);
|
|
967
|
+
if (!report.ok) {
|
|
968
|
+
const errors = report.issues.filter((i) => i.severity === "error");
|
|
969
|
+
throw new WebSkillError("INSTALL_FAILED", `Skill "${name}" failed validation: ${errors.map((i) => i.message).join("; ")}`, errors);
|
|
970
|
+
}
|
|
971
|
+
targetDir = `${this.#managedRoot}/${name}`;
|
|
972
|
+
if (await fs.exists(targetDir)) await fs.remove(targetDir, { recursive: true });
|
|
973
|
+
await copyDir(fs, finalDir, targetDir);
|
|
974
|
+
const manifest = await createManifest(fs, targetDir, {
|
|
975
|
+
name,
|
|
976
|
+
...version ? { version } : {},
|
|
977
|
+
source: staged.source
|
|
978
|
+
});
|
|
979
|
+
await upsertLockEntry(fs, this.#managedRoot, name, {
|
|
980
|
+
digest: manifest.integrity.digest,
|
|
981
|
+
installedAt: manifest.installedAt,
|
|
982
|
+
source: staged.source
|
|
983
|
+
});
|
|
984
|
+
targetDir = void 0;
|
|
985
|
+
return manifest;
|
|
986
|
+
} catch (e) {
|
|
987
|
+
if (targetDir) await removeDirQuiet(fs, targetDir);
|
|
988
|
+
throw asInstallFailed(e);
|
|
989
|
+
} finally {
|
|
990
|
+
await removeDirQuiet(fs, stagingRoot);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async uninstall(name) {
|
|
994
|
+
if (!isValidSkillName(name)) throw new WebSkillError("UNINSTALL_FAILED", `Invalid skill name (path traversal rejected): ${JSON.stringify(name)}`);
|
|
995
|
+
const targetDir = `${this.#managedRoot}/${name}`;
|
|
996
|
+
if (!await this.#fs.exists(targetDir)) throw new WebSkillError("UNINSTALL_FAILED", `Skill "${name}" is not installed`);
|
|
997
|
+
try {
|
|
998
|
+
await this.#fs.remove(targetDir, { recursive: true });
|
|
999
|
+
await removeLockEntry(this.#fs, this.#managedRoot, name);
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
throw new WebSkillError("UNINSTALL_FAILED", `Failed to uninstall "${name}": ${messageOf(e)}`, e);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async verifyIntegrity(name) {
|
|
1005
|
+
if (!isValidSkillName(name)) throw new WebSkillError("INTEGRITY_FAILED", `Invalid skill name (path traversal rejected): ${JSON.stringify(name)}`);
|
|
1006
|
+
return verifyIntegrity(this.#fs, `${this.#managedRoot}/${name}`);
|
|
1007
|
+
}
|
|
1008
|
+
async listInstalled() {
|
|
1009
|
+
return readLockfile(this.#fs, this.#managedRoot);
|
|
1010
|
+
}
|
|
1011
|
+
async exportArchive(name, options) {
|
|
1012
|
+
if (!isValidSkillName(name)) throw new WebSkillError("EXPORT_FAILED", `Invalid skill name (path traversal rejected): ${JSON.stringify(name)}`);
|
|
1013
|
+
const skillRoot = `${this.#managedRoot}/${name}`;
|
|
1014
|
+
if (!await this.#fs.exists(skillRoot)) throw new WebSkillError("EXPORT_FAILED", `Skill "${name}" is not installed`);
|
|
1015
|
+
return exportArchive(this.#fs, skillRoot, options);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
/**
|
|
1019
|
+
* 解析 .env(简单 KEY=VALUE 行解析,不引依赖),
|
|
1020
|
+
* 读取 VITE_BASE_URL / VITE_API_KEY / VITE_MODEL_NAME;缺项返回 undefined 供测试 skip 判断。
|
|
1021
|
+
*/
|
|
1022
|
+
function loadLlmConfigFromEnv(dotenvPath = ".env") {
|
|
1023
|
+
let raw;
|
|
1024
|
+
try {
|
|
1025
|
+
raw = readFileSync(dotenvPath, "utf8");
|
|
1026
|
+
} catch {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const vars = /* @__PURE__ */ new Map();
|
|
1030
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1031
|
+
const trimmed = line.trim();
|
|
1032
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
1033
|
+
const eq = trimmed.indexOf("=");
|
|
1034
|
+
if (eq <= 0) continue;
|
|
1035
|
+
const key = trimmed.slice(0, eq).trim();
|
|
1036
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
1037
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
1038
|
+
vars.set(key, value);
|
|
1039
|
+
}
|
|
1040
|
+
const baseUrl = vars.get("VITE_BASE_URL");
|
|
1041
|
+
const apiKey = vars.get("VITE_API_KEY");
|
|
1042
|
+
const model = vars.get("VITE_MODEL_NAME");
|
|
1043
|
+
if (!baseUrl || !apiKey || !model) return void 0;
|
|
1044
|
+
return {
|
|
1045
|
+
baseUrl,
|
|
1046
|
+
apiKey,
|
|
1047
|
+
model
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
let capabilitiesCache;
|
|
1051
|
+
/**
|
|
1052
|
+
* 探测 LLM 能力(结果模块级缓存):
|
|
1053
|
+
* - available:GET /models 可达(与 checkAvailability 同语义)
|
|
1054
|
+
* - nonStreaming:最小非流式 chat completion(max_tokens=1,短超时);
|
|
1055
|
+
* HTTP 400/404 或明确不支持非流式的错误 → false
|
|
1056
|
+
* - streaming 能力由 available 推断(SSE 解析在 SDK 侧实现)
|
|
1057
|
+
* 环境变量 VITE_LLM_NON_STREAMING=0 可显式强制 nonStreaming=false(模拟流式-only 回归)。
|
|
1058
|
+
*/
|
|
1059
|
+
function probeLlmCapabilities(config) {
|
|
1060
|
+
capabilitiesCache ??= doProbeLlmCapabilities(config);
|
|
1061
|
+
return capabilitiesCache;
|
|
1062
|
+
}
|
|
1063
|
+
async function doProbeLlmCapabilities(config) {
|
|
1064
|
+
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
1065
|
+
const headers = { authorization: `Bearer ${config.apiKey}` };
|
|
1066
|
+
let available = false;
|
|
1067
|
+
try {
|
|
1068
|
+
available = (await fetch(`${baseUrl}/models`, {
|
|
1069
|
+
headers,
|
|
1070
|
+
signal: AbortSignal.timeout(1e4)
|
|
1071
|
+
})).ok;
|
|
1072
|
+
} catch {
|
|
1073
|
+
available = false;
|
|
1074
|
+
}
|
|
1075
|
+
if (!available) return {
|
|
1076
|
+
available: false,
|
|
1077
|
+
nonStreaming: false,
|
|
1078
|
+
reason: "endpoint unreachable or unauthorized"
|
|
1079
|
+
};
|
|
1080
|
+
if (process.env["VITE_LLM_NON_STREAMING"] === "0") return {
|
|
1081
|
+
available: true,
|
|
1082
|
+
nonStreaming: false,
|
|
1083
|
+
reason: "non-streaming disabled via VITE_LLM_NON_STREAMING=0 (simulated)"
|
|
1084
|
+
};
|
|
1085
|
+
try {
|
|
1086
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
1087
|
+
method: "POST",
|
|
1088
|
+
headers: {
|
|
1089
|
+
...headers,
|
|
1090
|
+
"content-type": "application/json"
|
|
1091
|
+
},
|
|
1092
|
+
body: JSON.stringify({
|
|
1093
|
+
model: config.model,
|
|
1094
|
+
messages: [{
|
|
1095
|
+
role: "user",
|
|
1096
|
+
content: "OK"
|
|
1097
|
+
}],
|
|
1098
|
+
max_tokens: 1
|
|
1099
|
+
}),
|
|
1100
|
+
signal: AbortSignal.timeout(15e3)
|
|
1101
|
+
});
|
|
1102
|
+
if (res.ok) return {
|
|
1103
|
+
available: true,
|
|
1104
|
+
nonStreaming: true
|
|
1105
|
+
};
|
|
1106
|
+
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
|
1107
|
+
return {
|
|
1108
|
+
available: true,
|
|
1109
|
+
nonStreaming: false,
|
|
1110
|
+
reason: `endpoint rejects non-streaming chat completions (HTTP ${res.status})${detail ? `: ${detail}` : ""}`
|
|
1111
|
+
};
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
return {
|
|
1114
|
+
available: true,
|
|
1115
|
+
nonStreaming: false,
|
|
1116
|
+
reason: `non-streaming probe failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
//#endregion
|
|
1122
|
+
export { NodeScriptExecutor as a, SandboxedScriptExecutor as c, probeLlmCapabilities as d, readArchiveManifest as f, NodeFS as i, SkillManager as l, FileArtifactStore as n, SKILLS_LOCKFILE as o, FileMemoryStore as r, SKILL_MANIFEST_FILE as s, CliUiBridge as t, loadLlmConfigFromEnv as u };
|