@spzhongwin/skill-logger-plugin 1.0.18 → 1.0.19

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.
@@ -0,0 +1,591 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ FreeSkillWorkspaceError,
6
+ resolveFreeSkillWorkspace,
7
+ type FreeSkillWorkspace,
8
+ type FreeSkillWorkspaceContext,
9
+ } from "./free-skill-workspace.ts";
10
+
11
+ const FREE_SKILL_DIRECTORY_NAME = ".xg-platform";
12
+ const SKILL_FILE_NAME = "SKILL.md";
13
+
14
+ export type FreeSkillWriteErrorCode =
15
+ | "INVALID_SKILL_NAME"
16
+ | "INVALID_CONTENT"
17
+ | "INVALID_FRONTMATTER"
18
+ | "MISSING_NAME"
19
+ | "MISSING_DESCRIPTION"
20
+ | "EMPTY_BODY"
21
+ | "INVALID_METADATA"
22
+ | "TARGET_ESCAPE"
23
+ | "TARGET_SYMLINK"
24
+ | "TARGET_NOT_DIRECTORY"
25
+ | "TARGET_UNAVAILABLE"
26
+ | "SKILL_EXISTS"
27
+ | "ATOMIC_WRITE_FAILED"
28
+ | FreeSkillWorkspaceError["code"];
29
+
30
+ export class FreeSkillWriterError extends Error {
31
+ public readonly code: FreeSkillWriteErrorCode;
32
+
33
+ constructor(code: FreeSkillWriteErrorCode, message: string, options?: ErrorOptions) {
34
+ super(message, options);
35
+ this.code = code;
36
+ this.name = "FreeSkillWriterError";
37
+ }
38
+ }
39
+
40
+ type FrontmatterScalar = {
41
+ kind: "scalar";
42
+ value: string;
43
+ };
44
+
45
+ type FrontmatterOther = {
46
+ kind: "other";
47
+ };
48
+
49
+ type FrontmatterValue = FrontmatterScalar | FrontmatterOther;
50
+
51
+ type ParsedSkillContent = {
52
+ name: string;
53
+ description: string;
54
+ body: string;
55
+ };
56
+
57
+ type StringRecord = Record<string, unknown>;
58
+
59
+ /** 只声明写入核心使用的最小 fs 接口,便于测试原子 rename 失败。 */
60
+ export type FreeSkillWriterFileSystem = {
61
+ lstat: typeof fs.lstat;
62
+ realpath: typeof fs.realpath;
63
+ stat: typeof fs.stat;
64
+ mkdir: typeof fs.mkdir;
65
+ writeFile: typeof fs.writeFile;
66
+ rename: typeof fs.rename;
67
+ rm: typeof fs.rm;
68
+ };
69
+
70
+ export type FreeSkillWriteInput = {
71
+ /** Skill 的一级目录名;`name` 是兼容别名。 */
72
+ skillName?: unknown;
73
+ name?: unknown;
74
+ /** 完整 SKILL.md 内容。 */
75
+ content?: unknown;
76
+ skillContent?: unknown;
77
+ /** 可选 JSON 元数据,会落盘为 `.meta.json`。 */
78
+ metadata?: unknown;
79
+ /** 必须是 resolveFreeSkillWorkspace 的返回值。 */
80
+ workspace?: FreeSkillWorkspace;
81
+ /** 运行时注入的 workspace context;不会读取 target/path 字段。 */
82
+ context?: FreeSkillWorkspaceContext | unknown;
83
+ };
84
+
85
+ export type FreeSkillWriteSuccess = {
86
+ success: true;
87
+ skillName: string;
88
+ directoryPath: string;
89
+ skillFilePath: string;
90
+ metadataFilePath?: string;
91
+ };
92
+
93
+ export type FreeSkillWriteFailure = {
94
+ success: false;
95
+ code: FreeSkillWriteErrorCode;
96
+ message: string;
97
+ };
98
+
99
+ export type FreeSkillWriteResult = FreeSkillWriteSuccess | FreeSkillWriteFailure;
100
+
101
+ type WorkspaceInput = FreeSkillWorkspace | FreeSkillWorkspaceContext | unknown;
102
+
103
+ const defaultFileSystem: FreeSkillWriterFileSystem = {
104
+ lstat: fs.lstat,
105
+ realpath: fs.realpath,
106
+ stat: fs.stat,
107
+ mkdir: fs.mkdir,
108
+ writeFile: fs.writeFile,
109
+ rename: fs.rename,
110
+ rm: fs.rm,
111
+ };
112
+
113
+ function isRecord(value: unknown): value is StringRecord {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
115
+ }
116
+
117
+ function isWithin(parent: string, candidate: string): boolean {
118
+ const relative = path.relative(parent, candidate);
119
+ return relative === ""
120
+ || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
121
+ }
122
+
123
+ function asErrnoCode(error: unknown): string | undefined {
124
+ return typeof error === "object" && error !== null && "code" in error
125
+ ? String((error as { code?: unknown }).code)
126
+ : undefined;
127
+ }
128
+
129
+ function asMessage(error: unknown): string {
130
+ return error instanceof Error ? error.message : String(error);
131
+ }
132
+
133
+ function fail(code: FreeSkillWriteErrorCode, message: string): never {
134
+ throw new FreeSkillWriterError(code, message);
135
+ }
136
+
137
+ function stripYamlComment(value: string): string {
138
+ let quote: "'" | '"' | undefined;
139
+ for (let index = 0; index < value.length; index += 1) {
140
+ const character = value[index];
141
+ if (quote === '"' && character === "\\") {
142
+ index += 1;
143
+ continue;
144
+ }
145
+ if ((character === "'" || character === '"') && (!quote || quote === character)) {
146
+ if (quote === "'" && character === "'" && value[index + 1] === "'") {
147
+ index += 1;
148
+ continue;
149
+ }
150
+ quote = quote ? undefined : character;
151
+ continue;
152
+ }
153
+ if (!quote && character === "#" && (index === 0 || /\s/.test(value[index - 1]))) {
154
+ return value.slice(0, index).trimEnd();
155
+ }
156
+ }
157
+ return value.trim();
158
+ }
159
+
160
+ function parseQuotedScalar(rawValue: string): string | undefined {
161
+ if (rawValue.startsWith('"')) {
162
+ if (!rawValue.endsWith('"')) return undefined;
163
+ try {
164
+ const parsed: unknown = JSON.parse(rawValue);
165
+ return typeof parsed === "string" ? parsed : undefined;
166
+ } catch {
167
+ return undefined;
168
+ }
169
+ }
170
+
171
+ if (rawValue.startsWith("'")) {
172
+ if (!rawValue.endsWith("'")) return undefined;
173
+ const inner = rawValue.slice(1, -1);
174
+ let value = "";
175
+ for (let index = 0; index < inner.length; index += 1) {
176
+ if (inner[index] === "'" && inner[index + 1] === "'") {
177
+ value += "'";
178
+ index += 1;
179
+ } else if (inner[index] === "'") {
180
+ return undefined;
181
+ } else {
182
+ value += inner[index];
183
+ }
184
+ }
185
+ return value;
186
+ }
187
+
188
+ return undefined;
189
+ }
190
+
191
+ function hasBalancedCollection(value: string): boolean {
192
+ const opening = value[0];
193
+ const closing = opening === "[" ? "]" : "}";
194
+ if ((opening !== "[" && opening !== "{") || !value.endsWith(closing)) return false;
195
+
196
+ let depth = 0;
197
+ let quote: "'" | '"' | undefined;
198
+ for (let index = 0; index < value.length; index += 1) {
199
+ const character = value[index];
200
+ if (quote === '"' && character === "\\") {
201
+ index += 1;
202
+ continue;
203
+ }
204
+ if ((character === "'" || character === '"') && (!quote || quote === character)) {
205
+ if (quote === "'" && character === "'" && value[index + 1] === "'") {
206
+ index += 1;
207
+ continue;
208
+ }
209
+ quote = quote ? undefined : character;
210
+ continue;
211
+ }
212
+ if (quote) continue;
213
+ if (character === opening) depth += 1;
214
+ if (character === closing) depth -= 1;
215
+ if (depth < 0) return false;
216
+ }
217
+ return depth === 0 && !quote;
218
+ }
219
+
220
+ function parseInlineValue(rawValue: string): FrontmatterValue {
221
+ const value = stripYamlComment(rawValue);
222
+ if (value.startsWith('"') || value.startsWith("'")) {
223
+ const parsed = parseQuotedScalar(value);
224
+ if (parsed === undefined) fail("INVALID_FRONTMATTER", "SKILL.md frontmatter 中存在未闭合或无效字符串");
225
+ return { kind: "scalar", value: parsed };
226
+ }
227
+ if (value.startsWith("[") || value.startsWith("{")) {
228
+ if (!hasBalancedCollection(value)) {
229
+ fail("INVALID_FRONTMATTER", "SKILL.md frontmatter 中存在未闭合集合值");
230
+ }
231
+ return { kind: "other" };
232
+ }
233
+ return { kind: "scalar", value };
234
+ }
235
+
236
+ function parseBlockValue(
237
+ lines: string[],
238
+ startIndex: number,
239
+ header: string,
240
+ ): { value: string; nextIndex: number } {
241
+ const headerMatch = /^([|>])(?:[+-]|[1-9])?$/.exec(header);
242
+ if (!headerMatch) fail("INVALID_FRONTMATTER", "SKILL.md frontmatter 中存在无效块标量");
243
+
244
+ const blockLines: string[] = [];
245
+ let index = startIndex;
246
+ let contentIndent: number | undefined;
247
+ while (index < lines.length) {
248
+ const line = lines[index];
249
+ if (line.trim() !== "" && !/^\s+/.test(line)) break;
250
+ if (line.trim() === "") {
251
+ blockLines.push("");
252
+ index += 1;
253
+ continue;
254
+ }
255
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
256
+ contentIndent ??= indent;
257
+ if (indent < contentIndent) break;
258
+ blockLines.push(line.slice(contentIndent));
259
+ index += 1;
260
+ }
261
+
262
+ if (headerMatch[1] === ">") {
263
+ const folded: string[] = [];
264
+ for (const line of blockLines) {
265
+ if (line === "") {
266
+ folded.push("\n");
267
+ } else if (folded.length > 0 && folded[folded.length - 1] !== "\n") {
268
+ folded.push(" ", line);
269
+ } else {
270
+ folded.push(line);
271
+ }
272
+ }
273
+ return { value: folded.join(""), nextIndex: index };
274
+ }
275
+
276
+ return { value: blockLines.join("\n"), nextIndex: index };
277
+ }
278
+
279
+ function parseFrontmatter(content: string): ParsedSkillContent {
280
+ const normalized = content.replace(/^\uFEFF/, "");
281
+ const lines = normalized.split(/\r?\n/);
282
+ if (lines[0]?.trim() !== "---") {
283
+ fail("INVALID_FRONTMATTER", "SKILL.md 必须以 YAML frontmatter 开始");
284
+ }
285
+
286
+ const fields = new Map<string, FrontmatterValue>();
287
+ let closingIndex = -1;
288
+ let index = 1;
289
+ while (index < lines.length) {
290
+ const line = lines[index];
291
+ const trimmed = line.trim();
292
+ if (trimmed === "---" || trimmed === "...") {
293
+ closingIndex = index;
294
+ break;
295
+ }
296
+ if (trimmed === "" || trimmed.startsWith("#")) {
297
+ index += 1;
298
+ continue;
299
+ }
300
+ if (/^\s/.test(line) || line.includes("\t")) {
301
+ fail("INVALID_FRONTMATTER", "SKILL.md frontmatter 中存在无效缩进");
302
+ }
303
+
304
+ const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/.exec(line);
305
+ if (!match) fail("INVALID_FRONTMATTER", "SKILL.md frontmatter 中存在无效字段");
306
+ const [, key, rawValue] = match;
307
+ if (fields.has(key)) fail("INVALID_FRONTMATTER", `SKILL.md frontmatter 字段重复: ${key}`);
308
+
309
+ const value = stripYamlComment(rawValue);
310
+ if (value === "|" || value === ">" || /^[|>][+-]$/.test(value)) {
311
+ const block = parseBlockValue(lines, index + 1, value);
312
+ fields.set(key, { kind: "scalar", value: block.value });
313
+ index = block.nextIndex;
314
+ continue;
315
+ }
316
+ fields.set(key, parseInlineValue(rawValue));
317
+ index += 1;
318
+ }
319
+
320
+ if (closingIndex < 0) fail("INVALID_FRONTMATTER", "SKILL.md frontmatter 缺少结束标记");
321
+
322
+ const name = fields.get("name");
323
+ if (!name || name.kind !== "scalar" || !name.value.trim()) {
324
+ fail("MISSING_NAME", "SKILL.md frontmatter 必须包含非空 name");
325
+ }
326
+ const description = fields.get("description");
327
+ if (!description || description.kind !== "scalar" || !description.value.trim()) {
328
+ fail("MISSING_DESCRIPTION", "SKILL.md frontmatter 必须包含非空 description");
329
+ }
330
+
331
+ const body = lines.slice(closingIndex + 1).join("\n");
332
+ if (!body.trim()) fail("EMPTY_BODY", "SKILL.md 正文不能为空");
333
+ return { name: name.value.trim(), description: description.value.trim(), body };
334
+ }
335
+
336
+ /** 校验并提取 SKILL.md 的最小必要字段。 */
337
+ export function validateFreeSkillContent(content: unknown): ParsedSkillContent {
338
+ if (typeof content !== "string") fail("INVALID_CONTENT", "SKILL.md 内容必须是字符串");
339
+ return parseFrontmatter(content);
340
+ }
341
+
342
+ function validateSkillName(value: unknown): string {
343
+ if (typeof value !== "string" || value.length === 0 || value !== value.trim()) {
344
+ fail("INVALID_SKILL_NAME", "Skill 名称必须是非空的单级目录名");
345
+ }
346
+ if (
347
+ value === "."
348
+ || value === ".."
349
+ || value.startsWith(".")
350
+ || value.endsWith(".")
351
+ || value.endsWith(" ")
352
+ || path.basename(value) !== value
353
+ || path.win32.basename(value) !== value
354
+ || path.isAbsolute(value)
355
+ || /[\\/\0<>:"|?*\u0000-\u001f\u007f]/u.test(value)
356
+ ) {
357
+ fail("INVALID_SKILL_NAME", `非法的 Skill 名称: ${value}`);
358
+ }
359
+ return value;
360
+ }
361
+
362
+ function serializeMetadata(value: unknown): string | undefined {
363
+ if (value === undefined) return undefined;
364
+ if (!isRecord(value)) fail("INVALID_METADATA", ".meta.json metadata 必须是 JSON 对象");
365
+ try {
366
+ const serialized = JSON.stringify(value, null, 2);
367
+ if (serialized === undefined) fail("INVALID_METADATA", ".meta.json metadata 无法序列化");
368
+ return `${serialized}\n`;
369
+ } catch (error) {
370
+ fail("INVALID_METADATA", `.meta.json metadata 无法序列化: ${asMessage(error)}`);
371
+ }
372
+ }
373
+
374
+ function isResolvedWorkspace(value: unknown): value is FreeSkillWorkspace {
375
+ if (!isRecord(value)) return false;
376
+ return typeof value.workspacePath === "string"
377
+ && typeof value.directoryPath === "string"
378
+ && value.workspaceRelativePath === FREE_SKILL_DIRECTORY_NAME;
379
+ }
380
+
381
+ async function ensurePlatformDirectory(
382
+ workspace: FreeSkillWorkspace,
383
+ io: FreeSkillWriterFileSystem,
384
+ ): Promise<{ workspacePath: string; directoryPath: string }> {
385
+ if (!path.isAbsolute(workspace.workspacePath) || !path.isAbsolute(workspace.directoryPath)) {
386
+ fail("TARGET_ESCAPE", "Skill workspace 路径必须是解析器返回的绝对路径");
387
+ }
388
+
389
+ let workspacePath: string;
390
+ try {
391
+ workspacePath = await io.realpath(path.resolve(workspace.workspacePath));
392
+ const workspaceStat = await io.stat(workspacePath);
393
+ if (!workspaceStat.isDirectory()) fail("WORKSPACE_NOT_DIRECTORY", "Agent workspace 不是目录");
394
+ } catch (error) {
395
+ if (error instanceof FreeSkillWriterError) throw error;
396
+ fail("WORKSPACE_MISSING", `Agent workspace 不存在或不可访问: ${asMessage(error)}`);
397
+ }
398
+
399
+ const directoryPath = path.resolve(workspacePath, FREE_SKILL_DIRECTORY_NAME);
400
+ if (!isWithin(workspacePath, directoryPath) || path.resolve(workspace.directoryPath) !== directoryPath) {
401
+ fail("TARGET_ESCAPE", "自由 Skill 目标目录必须是 workspace/.xg-platform");
402
+ }
403
+
404
+ let entry;
405
+ try {
406
+ entry = await io.lstat(directoryPath);
407
+ } catch (error) {
408
+ if (asErrnoCode(error) !== "ENOENT") {
409
+ fail("TARGET_UNAVAILABLE", `无法检查自由 Skill 目标目录: ${asMessage(error)}`);
410
+ }
411
+ try {
412
+ await io.mkdir(directoryPath, { recursive: false });
413
+ entry = await io.lstat(directoryPath);
414
+ } catch (mkdirError) {
415
+ if (asErrnoCode(mkdirError) !== "EEXIST") {
416
+ fail("ATOMIC_WRITE_FAILED", `创建自由 Skill 目标目录失败: ${asMessage(mkdirError)}`);
417
+ }
418
+ try {
419
+ entry = await io.lstat(directoryPath);
420
+ } catch (statError) {
421
+ fail("TARGET_UNAVAILABLE", `无法检查自由 Skill 目标目录: ${asMessage(statError)}`);
422
+ }
423
+ }
424
+ }
425
+
426
+ if (entry.isSymbolicLink()) fail("TARGET_SYMLINK", "拒绝写入符号链接目标目录");
427
+ if (!entry.isDirectory()) fail("TARGET_NOT_DIRECTORY", "自由 Skill 目标不是目录");
428
+
429
+ let realDirectory: string;
430
+ try {
431
+ realDirectory = await io.realpath(directoryPath);
432
+ } catch (error) {
433
+ fail("TARGET_UNAVAILABLE", `无法解析自由 Skill 目标目录: ${asMessage(error)}`);
434
+ }
435
+ if (realDirectory !== directoryPath || !isWithin(workspacePath, realDirectory)) {
436
+ fail("TARGET_ESCAPE", "自由 Skill 目标目录越出 Agent workspace");
437
+ }
438
+ return { workspacePath, directoryPath };
439
+ }
440
+
441
+ async function resolveWriterWorkspace(
442
+ input: FreeSkillWriteInput,
443
+ suppliedWorkspace: WorkspaceInput | undefined,
444
+ io: FreeSkillWriterFileSystem,
445
+ ): Promise<{ workspacePath: string; directoryPath: string }> {
446
+ let workspace: FreeSkillWorkspace;
447
+ const candidate = suppliedWorkspace ?? input.workspace;
448
+ if (isResolvedWorkspace(candidate)) {
449
+ workspace = candidate;
450
+ } else {
451
+ const context = input.context ?? candidate ?? input;
452
+ try {
453
+ workspace = await resolveFreeSkillWorkspace(context);
454
+ } catch (error) {
455
+ if (error instanceof FreeSkillWorkspaceError) throw error;
456
+ fail("WORKSPACE_MISSING", `无法解析 Agent workspace: ${asMessage(error)}`);
457
+ }
458
+ }
459
+ return ensurePlatformDirectory(workspace, io);
460
+ }
461
+
462
+ function makeTemporaryPath(directoryPath: string, skillName: string, suffix: string): string {
463
+ return path.join(
464
+ directoryPath,
465
+ `.${skillName}.${suffix}-${process.pid}-${Date.now()}-${crypto.randomUUID()}`,
466
+ );
467
+ }
468
+
469
+ async function removePath(io: FreeSkillWriterFileSystem, target: string): Promise<void> {
470
+ await io.rm(target, { recursive: true, force: true }).catch(() => undefined);
471
+ }
472
+
473
+ async function targetAlreadyExists(
474
+ targetPath: string,
475
+ io: FreeSkillWriterFileSystem,
476
+ ): Promise<"symlink" | "exists" | "missing"> {
477
+ try {
478
+ const entry = await io.lstat(targetPath);
479
+ return entry.isSymbolicLink() ? "symlink" : "exists";
480
+ } catch (error) {
481
+ if (asErrnoCode(error) === "ENOENT") return "missing";
482
+ fail("TARGET_UNAVAILABLE", `无法检查 Skill 目标目录: ${asMessage(error)}`);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * 创建自由 Skill。目标根目录始终来自 resolveFreeSkillWorkspace 的结果,
488
+ * 不读取 input 中任何 target/path 字段。
489
+ */
490
+ export async function writeFreeSkill(
491
+ input: FreeSkillWriteInput,
492
+ workspace?: WorkspaceInput,
493
+ dependencies?: { fileSystem?: Partial<FreeSkillWriterFileSystem> },
494
+ ): Promise<FreeSkillWriteResult> {
495
+ const io: FreeSkillWriterFileSystem = {
496
+ ...defaultFileSystem,
497
+ ...dependencies?.fileSystem,
498
+ };
499
+
500
+ try {
501
+ if (!isRecord(input)) fail("INVALID_CONTENT", "Skill 写入请求必须是对象");
502
+ const skillName = validateSkillName(input.skillName ?? input.name);
503
+ const content = input.content ?? input.skillContent;
504
+ validateFreeSkillContent(content);
505
+ const metadata = serializeMetadata(input.metadata);
506
+ const { directoryPath: platformDirectoryPath } = await resolveWriterWorkspace(input, workspace, io);
507
+ const targetDirectoryPath = path.resolve(platformDirectoryPath, skillName);
508
+ if (!isWithin(platformDirectoryPath, targetDirectoryPath)) {
509
+ fail("TARGET_ESCAPE", "Skill 目标目录越出 workspace/.xg-platform");
510
+ }
511
+
512
+ const existing = await targetAlreadyExists(targetDirectoryPath, io);
513
+ if (existing === "symlink") fail("TARGET_SYMLINK", "拒绝写入符号链接 Skill 目录");
514
+ if (existing === "exists") fail("SKILL_EXISTS", `Skill 已存在: ${skillName}`);
515
+
516
+ let createdTargetDirectory = false;
517
+ let skillTempPath: string | undefined;
518
+ let metadataTempPath: string | undefined;
519
+ const skillFilePath = path.join(targetDirectoryPath, SKILL_FILE_NAME);
520
+ const metadataFilePath = metadata ? path.join(targetDirectoryPath, ".meta.json") : undefined;
521
+ try {
522
+ try {
523
+ await io.mkdir(targetDirectoryPath, { recursive: false });
524
+ createdTargetDirectory = true;
525
+ } catch (error) {
526
+ if (asErrnoCode(error) === "EEXIST") {
527
+ const raced = await targetAlreadyExists(targetDirectoryPath, io);
528
+ if (raced === "symlink") fail("TARGET_SYMLINK", "拒绝写入符号链接 Skill 目录");
529
+ fail("SKILL_EXISTS", `Skill 已存在: ${skillName}`);
530
+ }
531
+ fail("ATOMIC_WRITE_FAILED", `创建 Skill 目录失败: ${asMessage(error)}`);
532
+ }
533
+
534
+ skillTempPath = makeTemporaryPath(targetDirectoryPath, skillName, "skill-md-tmp");
535
+ await io.writeFile(skillTempPath, content as string, {
536
+ encoding: "utf8",
537
+ flag: "wx",
538
+ mode: 0o600,
539
+ });
540
+ await io.rename(skillTempPath, skillFilePath);
541
+ skillTempPath = undefined;
542
+
543
+ if (metadata && metadataFilePath) {
544
+ metadataTempPath = makeTemporaryPath(targetDirectoryPath, skillName, "metadata-tmp");
545
+ await io.writeFile(metadataTempPath, metadata, {
546
+ encoding: "utf8",
547
+ flag: "wx",
548
+ mode: 0o600,
549
+ });
550
+ await io.rename(metadataTempPath, metadataFilePath);
551
+ metadataTempPath = undefined;
552
+ }
553
+ } catch (error) {
554
+ if (error instanceof FreeSkillWriterError && error.code === "SKILL_EXISTS") throw error;
555
+ await Promise.all([
556
+ skillTempPath ? removePath(io, skillTempPath) : Promise.resolve(),
557
+ metadataTempPath ? removePath(io, metadataTempPath) : Promise.resolve(),
558
+ ]);
559
+ if (createdTargetDirectory) await removePath(io, targetDirectoryPath);
560
+ if (error instanceof FreeSkillWriterError) throw error;
561
+ fail("ATOMIC_WRITE_FAILED", `自由 Skill 原子写入失败: ${asMessage(error)}`);
562
+ }
563
+
564
+ return {
565
+ success: true,
566
+ skillName,
567
+ directoryPath: targetDirectoryPath,
568
+ skillFilePath,
569
+ ...(metadataFilePath ? { metadataFilePath } : {}),
570
+ };
571
+ } catch (error) {
572
+ if (error instanceof FreeSkillWriterError) {
573
+ return { success: false, code: error.code, message: error.message };
574
+ }
575
+ if (error instanceof FreeSkillWorkspaceError) {
576
+ return { success: false, code: error.code, message: error.message };
577
+ }
578
+ return { success: false, code: "ATOMIC_WRITE_FAILED", message: `自由 Skill 写入失败: ${asMessage(error)}` };
579
+ }
580
+ }
581
+
582
+ /** 需要异常控制流时使用的包装入口;错误 code 与 writeFreeSkill 保持一致。 */
583
+ export async function writeFreeSkillOrThrow(
584
+ input: FreeSkillWriteInput,
585
+ workspace?: WorkspaceInput,
586
+ dependencies?: { fileSystem?: Partial<FreeSkillWriterFileSystem> },
587
+ ): Promise<FreeSkillWriteSuccess> {
588
+ const result = await writeFreeSkill(input, workspace, dependencies);
589
+ if (!result.success) throw new FreeSkillWriterError(result.code, result.message);
590
+ return result;
591
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { GatewayWsClient } from "./ws-client.ts";
27
27
  let wsClient: GatewayWsClient | undefined;
28
28
 
29
29
  import { Hooks, isSkillMdReadPath } from "./hooks.ts";
30
+ import { createFreeSkillToolFactory, FREE_SKILL_TOOL_NAME } from "./free-skill-tool.ts";
30
31
 
31
32
  // 供单测复用(保留历史测试)。
32
33
  export { isSkillMdReadPath };
@@ -38,6 +39,7 @@ type PluginApi = {
38
39
  hookName: string,
39
40
  handler: (event: Record<string, unknown>, ctx: Record<string, unknown>) => any
40
41
  ) => void;
42
+ registerTool?: (tool: unknown, opts?: { name?: string; names?: string[]; optional?: boolean }) => void;
41
43
  };
42
44
 
43
45
  /** 本地扫描对账周期:发现新增 agent/skill(纯本地 I/O),固定 3 分钟。 */
@@ -63,6 +65,9 @@ const definition = {
63
65
  description:
64
66
  "追踪 openclaw skill 内功能点(脚本/命令/工具/HTTP)使用与报错,落本地并批量上报",
65
67
  register(api: PluginApi) {
68
+ if (typeof api.registerTool === "function") {
69
+ api.registerTool(createFreeSkillToolFactory, { name: FREE_SKILL_TOOL_NAME });
70
+ }
66
71
  let pkgVersion = "unknown";
67
72
  try {
68
73
  const dir = path.dirname(fileURLToPath(import.meta.url));