dsh-comfyui 0.3.0-beta.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,774 @@
1
+ /**
2
+ * Per-workflow skill packs: a small on-disk bundle (SKILL.md plus optional
3
+ * `references/`, `scripts/`, `assets/`) that documents one runnable workflow.
4
+ *
5
+ * Why a directory instead of another field in workflows.json: a workflow with
6
+ * real pitfalls needs more than one page — style collections, per-stage notes,
7
+ * helper scripts — and the agent should pull them one at a time. The bundle is
8
+ * the third rung of a disclosure ladder that starts in `comfyui_workflow`:
9
+ * `action: list` shows only a one-line summary, `action: skill` returns
10
+ * SKILL.md plus this directory's absolute path, and the model opens individual
11
+ * reference files itself only when the body names one. Nothing here is
12
+ * registered with the host `ctx.skills` registry: that registry publishes every
13
+ * model-invocable skill into a durable always-on catalog, which is exactly the
14
+ * cost this design avoids.
15
+ *
16
+ * The layout (four fixed buckets, one level deep) matches the bundle shape the
17
+ * host's own filesystem skill provider recognizes, so a pack stays portable.
18
+ *
19
+ * Every path in this module arrives from the browser or from workflows.json and
20
+ * is therefore untrusted: names are matched against a strict grammar, buckets
21
+ * against a whitelist, and the resolved path is re-checked with `relative()`
22
+ * before any read, write, or unlink — the same rule the asset deletion path
23
+ * follows.
24
+ */
25
+ import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
26
+ import { isAbsolute, join, relative, resolve } from 'node:path';
27
+ /**
28
+ * Sub-directories offered by default. These are suggestions, not a whitelist:
29
+ * a pack may hold any directory whose name matches {@link DIR_NAME}, so a user
30
+ * can follow whatever layout their other tooling expects. The list covers the
31
+ * conventions this plugin's packs are most likely to meet — the host skill
32
+ * bundle shape (`references` / `scripts` / `assets`) plus the names agent and
33
+ * prompt tooling commonly use.
34
+ */
35
+ export const SKILL_PRESET_DIRS = [
36
+ 'references', 'scripts', 'assets', 'templates', 'agents',
37
+ 'examples', 'prompts', 'commands', 'docs', 'data',
38
+ ];
39
+ /** The main document; it is the body `action: skill` returns and cannot be renamed or deleted. */
40
+ export const SKILL_MAIN = 'SKILL.md';
41
+ /** Text extensions accepted in the root, `references/`, and `scripts/`. */
42
+ const TEXT_EXTENSIONS = ['.md', '.txt', '.json', '.yaml', '.yml', '.mjs', '.js', '.ts', '.py', '.sh'];
43
+ /** Extensions that default into `scripts/` when imported. */
44
+ const SCRIPT_EXTENSIONS = ['.mjs', '.js', '.ts', '.py', '.sh'];
45
+ /** Extra extensions accepted in `assets/` — images and data the editor cannot type. */
46
+ const ASSET_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.csv'];
47
+ /** One text file may not exceed this: SKILL.md is injected into the model context whole. */
48
+ export const MAX_FILE_BYTES = 256 * 1024;
49
+ /** Imported binaries live in `assets/` and never enter the prompt as text, so
50
+ * they get a larger budget than the documents the model reads verbatim. */
51
+ export const MAX_ASSET_BYTES = 4 * 1024 * 1024;
52
+ /** A pack is documentation, not storage — these caps keep it that way. */
53
+ export const MAX_PACK_FILES = 100;
54
+ export const MAX_PACK_BYTES = 20 * 1024 * 1024;
55
+ /** File names: letters, digits, CJK, dot, dash, underscore. No separators, no leading dot. */
56
+ const FILE_NAME = /^[A-Za-z0-9_一-龥][A-Za-z0-9._一-龥-]{0,63}$/;
57
+ /** Directory names this module generates and later trusts only after re-validation. */
58
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
59
+ /** Sub-directory names inside a pack: same shape as a file name, no dots. */
60
+ const DIR_NAME = /^[A-Za-z0-9_一-龥][A-Za-z0-9_一-龥-]{0,39}$/;
61
+ function fail(error) {
62
+ return { ok: false, error };
63
+ }
64
+ /** Content types for serving pack assets back to the panel preview. */
65
+ const CONTENT_TYPES = {
66
+ '.png': 'image/png',
67
+ '.jpg': 'image/jpeg',
68
+ '.jpeg': 'image/jpeg',
69
+ '.webp': 'image/webp',
70
+ '.gif': 'image/gif',
71
+ '.svg': 'image/svg+xml',
72
+ '.csv': 'text/csv; charset=utf-8',
73
+ '.md': 'text/markdown; charset=utf-8',
74
+ '.txt': 'text/plain; charset=utf-8',
75
+ '.json': 'application/json; charset=utf-8',
76
+ '.yaml': 'text/yaml; charset=utf-8',
77
+ '.yml': 'text/yaml; charset=utf-8',
78
+ };
79
+ /** The MIME type used when serving one pack file. */
80
+ export function contentTypeOf(path) {
81
+ return CONTENT_TYPES[extensionOf(path)] ?? 'application/octet-stream';
82
+ }
83
+ /**
84
+ * Where an imported file belongs when the user did not pick a bucket.
85
+ *
86
+ * Host-side on purpose: the panel sends a file name and gets the resolved path
87
+ * back, so the extension→bucket rule has exactly one definition.
88
+ * @param file - the imported file's base name.
89
+ * @returns the bucket to place it in, or undefined when the type is not accepted.
90
+ */
91
+ export function defaultBucketFor(file) {
92
+ const extension = extensionOf(file);
93
+ if (SCRIPT_EXTENSIONS.includes(extension))
94
+ return 'scripts';
95
+ if (ASSET_EXTENSIONS.includes(extension))
96
+ return 'assets';
97
+ if (TEXT_EXTENSIONS.includes(extension))
98
+ return 'references';
99
+ return undefined;
100
+ }
101
+ function extensionOf(name) {
102
+ const dot = name.lastIndexOf('.');
103
+ return dot === -1 ? '' : name.slice(dot).toLowerCase();
104
+ }
105
+ /** The workflow-id half of a pack slug; the same rule finds an existing pack. */
106
+ function idSuffix(id) {
107
+ return id.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase();
108
+ }
109
+ /**
110
+ * Build a stable directory name for a workflow's pack.
111
+ *
112
+ * The readable half is for whoever opens the folder in an editor; the id suffix
113
+ * makes it unique and, more importantly, keeps the directory put when the user
114
+ * renames the workflow — a pack that moved on every rename would strand the
115
+ * absolute path already handed to a running agent.
116
+ * @param name - the workflow's display name.
117
+ * @param id - the workflow id.
118
+ * @returns a slug matching the {@link SLUG} grammar.
119
+ */
120
+ export function skillSlug(name, id) {
121
+ const ascii = name
122
+ .toLowerCase()
123
+ .replace(/[^a-z0-9]+/g, '-')
124
+ .replace(/^-+|-+$/g, '')
125
+ .slice(0, 40);
126
+ const suffix = idSuffix(id);
127
+ const base = ascii === '' ? 'workflow' : ascii;
128
+ const slug = `${base}-${suffix === '' ? 'pack' : suffix}`;
129
+ return SLUG.test(slug) ? slug : `workflow-${suffix === '' ? 'pack' : suffix}`;
130
+ }
131
+ /**
132
+ * Split and validate a pack-relative path.
133
+ * @param path - candidate relative path such as `references/styles.md`.
134
+ * @returns the bucket and file name, or an error message.
135
+ */
136
+ export function parseSkillPath(path) {
137
+ const normalized = path.replace(/\\/g, '/').trim();
138
+ if (normalized === '')
139
+ return fail('路径为空');
140
+ if (normalized.includes('..'))
141
+ return fail('路径不合法');
142
+ const parts = normalized.split('/').filter((part) => part !== '');
143
+ if (parts.length === 1) {
144
+ const file = parts[0];
145
+ if (!FILE_NAME.test(file))
146
+ return fail(`文件名不合法:${file}`);
147
+ return { ok: true, value: { bucket: '', file } };
148
+ }
149
+ if (parts.length !== 2)
150
+ return fail('技能包只有一层子目录');
151
+ const bucket = parts[0];
152
+ const file = parts[1];
153
+ if (!DIR_NAME.test(bucket))
154
+ return fail(`子目录名不合法:${bucket}`);
155
+ if (!FILE_NAME.test(file))
156
+ return fail(`文件名不合法:${file}`);
157
+ // The accepted types are the same in every directory; only the size cap
158
+ // differs (see writeBytes), because that depends on whether the model reads
159
+ // the file as text, not on where it sits.
160
+ const extension = extensionOf(file);
161
+ if (![...TEXT_EXTENSIONS, ...ASSET_EXTENSIONS].includes(extension)) {
162
+ return fail(`不支持的文件类型:${extension === '' ? '(无扩展名)' : extension}`);
163
+ }
164
+ return { ok: true, value: { bucket, file } };
165
+ }
166
+ /**
167
+ * Validate one sub-directory name from the browser or a tool call.
168
+ * @param name - candidate directory name.
169
+ * @returns the name, or why it was refused.
170
+ */
171
+ export function parseSkillDir(name) {
172
+ const clean = name.replace(/[\\/]/g, '').trim();
173
+ if (clean === '')
174
+ return fail('目录名为空');
175
+ if (!DIR_NAME.test(clean))
176
+ return fail(`目录名不合法:${name}(字母/数字/汉字/下划线/连字符,最多 40 字符)`);
177
+ return { ok: true, value: clean };
178
+ }
179
+ /** Root-level files: SKILL.md alone. Everything else belongs in a bucket. */
180
+ function checkRootFile(file) {
181
+ if (file !== SKILL_MAIN)
182
+ return fail(`技能包根目录只允许 ${SKILL_MAIN},其他文件请放进 references / scripts / assets`);
183
+ return { ok: true, value: true };
184
+ }
185
+ /**
186
+ * Strip a leading `---` frontmatter block and read its `summary` key.
187
+ *
188
+ * Deliberately not a YAML parser: the only key this plugin interprets is
189
+ * `summary`, and a dependency for one line of text is not worth it. Unknown
190
+ * keys survive untouched in the file for whoever edits it by hand.
191
+ * @param text - the raw SKILL.md contents.
192
+ * @returns the body without frontmatter plus the summary when present.
193
+ */
194
+ export function splitFrontmatter(text) {
195
+ if (!text.startsWith('---'))
196
+ return { body: text, summary: '' };
197
+ const end = text.indexOf('\n---', 3);
198
+ if (end === -1)
199
+ return { body: text, summary: '' };
200
+ const head = text.slice(3, end);
201
+ const rest = text.slice(end + 4).replace(/^[^\n]*\n?/, '');
202
+ let summary = '';
203
+ for (const line of head.split('\n')) {
204
+ const match = /^\s*summary\s*:\s*(.*)$/.exec(line);
205
+ if (match === null)
206
+ continue;
207
+ summary = (match[1] ?? '').trim().replace(/^['"]|['"]$/g, '');
208
+ break;
209
+ }
210
+ return { body: rest, summary };
211
+ }
212
+ /** Compose SKILL.md text from a summary and a body, keeping the frontmatter canonical. */
213
+ export function joinFrontmatter(summary, body) {
214
+ const clean = summary.replace(/[\r\n]+/g, ' ').trim();
215
+ if (clean === '')
216
+ return body;
217
+ return `---\nsummary: ${clean}\n---\n\n${body.replace(/^\n+/, '')}`;
218
+ }
219
+ /** First heading or first non-empty line, used when SKILL.md carries no summary. */
220
+ function fallbackSummary(body) {
221
+ for (const line of body.split('\n')) {
222
+ const text = line.replace(/^#+\s*/, '').trim();
223
+ if (text !== '')
224
+ return text.slice(0, 60);
225
+ }
226
+ return '';
227
+ }
228
+ /**
229
+ * The skill-pack directory tree under the plugin data directory.
230
+ *
231
+ * One instance per plugin fiber; every method takes the pack slug so a single
232
+ * store serves the whole library.
233
+ */
234
+ export class SkillPackStore {
235
+ resolveRoot;
236
+ /**
237
+ * @param root - the pack directory tree, or a getter when it follows a config
238
+ * value the settings page can change while the plugin runs.
239
+ */
240
+ constructor(root) {
241
+ this.resolveRoot = typeof root === 'function' ? root : () => root;
242
+ }
243
+ /** The current pack root (re-read per call: `skillsDir` is hot-configurable). */
244
+ get root() {
245
+ return this.resolveRoot();
246
+ }
247
+ /** Absolute path of one pack, after re-validating the slug from workflows.json. */
248
+ dirOf(slug) {
249
+ if (!SLUG.test(slug))
250
+ return fail(`技能包目录名不合法:${slug}`);
251
+ const root = this.root;
252
+ const dir = resolve(join(root, slug));
253
+ const rel = relative(resolve(root), dir);
254
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
255
+ return fail('技能包路径越界');
256
+ return { ok: true, value: dir };
257
+ }
258
+ /** Absolute path of one file inside a pack, guarded twice: grammar, then containment. */
259
+ fileOf(slug, path) {
260
+ const dir = this.dirOf(slug);
261
+ if (!dir.ok)
262
+ return dir;
263
+ const parsed = parseSkillPath(path);
264
+ if (!parsed.ok)
265
+ return parsed;
266
+ if (parsed.value.bucket === '') {
267
+ const rootCheck = checkRootFile(parsed.value.file);
268
+ if (!rootCheck.ok)
269
+ return rootCheck;
270
+ }
271
+ const target = resolve(join(dir.value, parsed.value.bucket, parsed.value.file));
272
+ const rel = relative(dir.value, target);
273
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
274
+ return fail('技能包路径越界');
275
+ return { ok: true, value: target };
276
+ }
277
+ /** Create the pack directory and seed SKILL.md when it does not exist yet. */
278
+ async create(slug, seed) {
279
+ const dir = this.dirOf(slug);
280
+ if (!dir.ok)
281
+ return dir;
282
+ await mkdir(dir.value, { recursive: true });
283
+ const main = join(dir.value, SKILL_MAIN);
284
+ try {
285
+ await stat(main);
286
+ }
287
+ catch {
288
+ await writeFile(main, seed, 'utf8');
289
+ }
290
+ return this.info(slug);
291
+ }
292
+ /**
293
+ * Find a pack directory belonging to a workflow id, whatever its readable
294
+ * half says. Detaching a pack keeps the directory, and the workflow may have
295
+ * been renamed since: without this, re-enabling would mint a new slug and
296
+ * strand the user's hand-written notes in the old directory.
297
+ * @param id - the workflow id whose suffix the directory carries.
298
+ * @returns the matching slug, or undefined.
299
+ */
300
+ async findBySuffix(id) {
301
+ const suffix = idSuffix(id);
302
+ if (suffix === '')
303
+ return undefined;
304
+ let entries;
305
+ try {
306
+ entries = await readdir(this.root);
307
+ }
308
+ catch {
309
+ return undefined;
310
+ }
311
+ for (const entry of entries.sort()) {
312
+ if (!SLUG.test(entry) || !entry.endsWith(`-${suffix}`))
313
+ continue;
314
+ const dir = this.dirOf(entry);
315
+ if (!dir.ok)
316
+ continue;
317
+ try {
318
+ const stats = await stat(dir.value);
319
+ if (stats.isDirectory())
320
+ return entry;
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ }
326
+ return undefined;
327
+ }
328
+ /** Whether the pack directory exists on disk. */
329
+ async exists(slug) {
330
+ const dir = this.dirOf(slug);
331
+ if (!dir.ok)
332
+ return false;
333
+ try {
334
+ const stats = await stat(dir.value);
335
+ return stats.isDirectory();
336
+ }
337
+ catch {
338
+ return false;
339
+ }
340
+ }
341
+ /** List a pack: SKILL.md plus one level of each bucket, with its derived summary. */
342
+ async info(slug) {
343
+ const dir = this.dirOf(slug);
344
+ if (!dir.ok)
345
+ return dir;
346
+ const files = [];
347
+ let totalBytes = 0;
348
+ // The sub-directories are whatever is on disk: a pack may use the preset
349
+ // names, its own, or none at all. Empty ones are still reported so the
350
+ // panel can show a folder the user just created.
351
+ const dirs = [];
352
+ try {
353
+ for (const entry of (await readdir(dir.value, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
354
+ if (entry.isDirectory() && DIR_NAME.test(entry.name))
355
+ dirs.push(entry.name);
356
+ }
357
+ }
358
+ catch {
359
+ // A pack whose directory vanished lists as empty rather than throwing.
360
+ }
361
+ for (const bucket of ['', ...dirs]) {
362
+ const bucketDir = bucket === '' ? dir.value : join(dir.value, bucket);
363
+ let entries;
364
+ try {
365
+ entries = await readdir(bucketDir);
366
+ }
367
+ catch {
368
+ continue;
369
+ }
370
+ for (const entry of entries.sort()) {
371
+ if (!FILE_NAME.test(entry))
372
+ continue;
373
+ const full = join(bucketDir, entry);
374
+ let stats;
375
+ try {
376
+ stats = await stat(full);
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ if (!stats.isFile())
382
+ continue;
383
+ if (bucket === '' && entry !== SKILL_MAIN)
384
+ continue;
385
+ totalBytes += stats.size;
386
+ files.push({
387
+ path: bucket === '' ? entry : `${bucket}/${entry}`,
388
+ size: stats.size,
389
+ updatedAt: new Date(stats.mtimeMs).toISOString(),
390
+ });
391
+ }
392
+ }
393
+ let summary = '';
394
+ const main = files.find((file) => file.path === SKILL_MAIN);
395
+ if (main !== undefined) {
396
+ try {
397
+ const text = await readFile(join(dir.value, SKILL_MAIN), 'utf8');
398
+ const split = splitFrontmatter(text);
399
+ summary = split.summary !== '' ? split.summary : fallbackSummary(split.body);
400
+ }
401
+ catch {
402
+ summary = '';
403
+ }
404
+ }
405
+ return { ok: true, value: { slug, dir: dir.value, summary, files, dirs, totalBytes } };
406
+ }
407
+ /** Create one sub-directory. Empty directories are legal — a user may lay a
408
+ * pack out before filling it in. */
409
+ async makeDir(slug, name) {
410
+ const dir = this.dirOf(slug);
411
+ if (!dir.ok)
412
+ return dir;
413
+ const parsed = parseSkillDir(name);
414
+ if (!parsed.ok)
415
+ return parsed;
416
+ const target = resolve(join(dir.value, parsed.value));
417
+ const rel = relative(dir.value, target);
418
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
419
+ return fail('技能包路径越界');
420
+ await mkdir(target, { recursive: true });
421
+ return this.info(slug);
422
+ }
423
+ /** Read one file as text. */
424
+ async read(slug, path) {
425
+ const target = this.fileOf(slug, path);
426
+ if (!target.ok)
427
+ return target;
428
+ try {
429
+ return { ok: true, value: await readFile(target.value, 'utf8') };
430
+ }
431
+ catch {
432
+ return fail(`文件不存在:${path}`);
433
+ }
434
+ }
435
+ /** SKILL.md body with frontmatter stripped — the text handed to the model. */
436
+ async body(slug) {
437
+ const dir = this.dirOf(slug);
438
+ if (!dir.ok)
439
+ return dir;
440
+ const raw = await this.read(slug, SKILL_MAIN);
441
+ if (!raw.ok)
442
+ return raw;
443
+ const split = splitFrontmatter(raw.value);
444
+ return {
445
+ ok: true,
446
+ value: {
447
+ body: split.body.trim(),
448
+ summary: split.summary !== '' ? split.summary : fallbackSummary(split.body),
449
+ dir: dir.value,
450
+ },
451
+ };
452
+ }
453
+ /** Create or overwrite one text file. */
454
+ async write(slug, path, content) {
455
+ return this.writeBytes(slug, path, Buffer.from(content, 'utf8'));
456
+ }
457
+ /**
458
+ * Create or overwrite one file from raw bytes, enforcing the per-file and
459
+ * per-pack caps. The text and import paths share this so an imported file
460
+ * can never bypass a limit the editor honours.
461
+ */
462
+ async writeBytes(slug, path, bytes) {
463
+ const target = this.fileOf(slug, path);
464
+ if (!target.ok)
465
+ return target;
466
+ const parsed = parseSkillPath(path);
467
+ if (!parsed.ok)
468
+ return parsed;
469
+ // The cap follows the file type, not the directory: binaries never enter
470
+ // the prompt as text, so they get the larger budget wherever they live.
471
+ const limit = ASSET_EXTENSIONS.includes(extensionOf(parsed.value.file))
472
+ ? MAX_ASSET_BYTES
473
+ : MAX_FILE_BYTES;
474
+ if (bytes.length > limit) {
475
+ return fail(`单个文件不能超过 ${Math.floor(limit / 1024)} KB(当前 ${Math.ceil(bytes.length / 1024)} KB)`);
476
+ }
477
+ const before = await this.info(slug);
478
+ if (!before.ok)
479
+ return before;
480
+ const existing = before.value.files.find((file) => file.path === path);
481
+ if (existing === undefined && before.value.files.length >= MAX_PACK_FILES) {
482
+ return fail(`一个技能包最多 ${MAX_PACK_FILES} 个文件`);
483
+ }
484
+ const projected = before.value.totalBytes - (existing?.size ?? 0) + bytes.length;
485
+ if (projected > MAX_PACK_BYTES) {
486
+ return fail(`一个技能包最多 ${Math.floor(MAX_PACK_BYTES / 1024 / 1024)} MB`);
487
+ }
488
+ if (parsed.value.bucket !== '')
489
+ await mkdir(join(before.value.dir, parsed.value.bucket), { recursive: true });
490
+ await writeFile(target.value, bytes);
491
+ return this.info(slug);
492
+ }
493
+ /** Read one file as raw bytes (the panel's image preview and downloads). */
494
+ async readBytes(slug, path) {
495
+ const target = this.fileOf(slug, path);
496
+ if (!target.ok)
497
+ return target;
498
+ try {
499
+ return { ok: true, value: await readFile(target.value) };
500
+ }
501
+ catch {
502
+ return fail(`文件不存在:${path}`);
503
+ }
504
+ }
505
+ /** Rename one file; SKILL.md is fixed and both sides are validated. */
506
+ async rename(slug, from, to) {
507
+ if (from === SKILL_MAIN)
508
+ return fail(`${SKILL_MAIN} 不能重命名`);
509
+ const source = this.fileOf(slug, from);
510
+ if (!source.ok)
511
+ return source;
512
+ const target = this.fileOf(slug, to);
513
+ if (!target.ok)
514
+ return target;
515
+ if (to === SKILL_MAIN)
516
+ return fail(`${SKILL_MAIN} 已存在,请换一个名字`);
517
+ try {
518
+ await stat(target.value);
519
+ return fail(`目标已存在:${to}`);
520
+ }
521
+ catch {
522
+ // Absent target is the expected case.
523
+ }
524
+ const parsed = parseSkillPath(to);
525
+ if (!parsed.ok)
526
+ return parsed;
527
+ const dir = this.dirOf(slug);
528
+ if (!dir.ok)
529
+ return dir;
530
+ if (parsed.value.bucket !== '')
531
+ await mkdir(join(dir.value, parsed.value.bucket), { recursive: true });
532
+ try {
533
+ await rename(source.value, target.value);
534
+ }
535
+ catch {
536
+ return fail(`重命名失败:${from}`);
537
+ }
538
+ return this.info(slug);
539
+ }
540
+ /** Delete one file; SKILL.md is fixed. */
541
+ async remove(slug, path) {
542
+ if (path === SKILL_MAIN)
543
+ return fail(`${SKILL_MAIN} 不能删除,如需移除整个技能包请用"销毁技能包"`);
544
+ const target = this.fileOf(slug, path);
545
+ if (!target.ok)
546
+ return target;
547
+ try {
548
+ await unlink(target.value);
549
+ }
550
+ catch {
551
+ return fail(`文件不存在:${path}`);
552
+ }
553
+ return this.info(slug);
554
+ }
555
+ /** Delete the whole pack directory. Only the explicit destroy gesture calls this. */
556
+ async destroy(slug) {
557
+ const dir = this.dirOf(slug);
558
+ if (!dir.ok)
559
+ return dir;
560
+ await rm(dir.value, { recursive: true, force: true });
561
+ return { ok: true, value: true };
562
+ }
563
+ }
564
+ /**
565
+ * The SKILL.md a freshly enabled pack starts from.
566
+ *
567
+ * The seed is a table of contents on purpose: the body is what every
568
+ * `action: skill` call pays for, so it should point at reference files rather
569
+ * than hold their content.
570
+ * @param workflowName - the workflow this pack documents.
571
+ * @param parameters - adjustable parameter names, listed as a starting point.
572
+ * @returns SKILL.md text including frontmatter.
573
+ */
574
+ export function seedSkillDocument(workflowName, parameters) {
575
+ const paramLines = parameters.length > 0
576
+ ? parameters.map((name) => `- \`${name}\`:`).join('\n')
577
+ : '- (运行 action: list 可看到完整参数清单)';
578
+ return joinFrontmatter(`${workflowName} 的使用说明`, [
579
+ `# ${workflowName}`,
580
+ '',
581
+ '## 什么时候用这个工作流',
582
+ '',
583
+ '(写清适用场景,Agent 靠它决定选不选这个工作流)',
584
+ '',
585
+ '## 关键参数',
586
+ '',
587
+ paramLines,
588
+ '',
589
+ '## 注意事项',
590
+ '',
591
+ '(容易翻车的环节写在这里)',
592
+ '',
593
+ '## 参考文档',
594
+ '',
595
+ '(把成体系的内容拆进 references/,在这里列出来,Agent 需要时才会去读)',
596
+ '',
597
+ ].join('\n'));
598
+ }
599
+ /**
600
+ * Bind the pack directory tree to the workflow library.
601
+ * @param host - the workflow store (its data dir owns the pack root).
602
+ * @returns the operations the tools and routes call.
603
+ */
604
+ export function createWorkflowSkillPacks(host) {
605
+ const packs = new SkillPackStore(host.skillsRoot);
606
+ const resolvePack = async (id) => {
607
+ const workflow = await host.getWorkflow(id);
608
+ if (workflow === undefined)
609
+ return fail(`workflow "${id}" not found`);
610
+ const slug = workflow.skillDir;
611
+ if (slug === undefined || slug === '')
612
+ return fail(`工作流 "${workflow.name}" 没有技能包`);
613
+ return { ok: true, value: { workflow, slug } };
614
+ };
615
+ const decorate = async (workflow, slug) => {
616
+ const info = await packs.info(slug);
617
+ if (!info.ok)
618
+ return info;
619
+ return {
620
+ ok: true,
621
+ value: {
622
+ ...info.value,
623
+ workflowId: workflow.id,
624
+ workflowName: workflow.name,
625
+ required: workflow.requireSkill === true,
626
+ },
627
+ };
628
+ };
629
+ const afterMutation = async (id, operation) => {
630
+ const resolved = await resolvePack(id);
631
+ if (!resolved.ok)
632
+ return resolved;
633
+ const result = await operation;
634
+ if (!result.ok)
635
+ return result;
636
+ return decorate(resolved.value.workflow, resolved.value.slug);
637
+ };
638
+ return {
639
+ get root() {
640
+ return packs.root;
641
+ },
642
+ async info(id) {
643
+ const resolved = await resolvePack(id);
644
+ if (!resolved.ok)
645
+ return undefined;
646
+ const decorated = await decorate(resolved.value.workflow, resolved.value.slug);
647
+ return decorated.ok ? decorated.value : undefined;
648
+ },
649
+ async infoFor(workflow) {
650
+ const slug = workflow.skillDir;
651
+ if (slug === undefined || slug === '')
652
+ return undefined;
653
+ const decorated = await decorate(workflow, slug);
654
+ return decorated.ok ? decorated.value : undefined;
655
+ },
656
+ async enable(id) {
657
+ const workflow = await host.getWorkflow(id);
658
+ if (workflow === undefined)
659
+ return fail(`workflow "${id}" not found`);
660
+ const slug = workflow.skillDir !== undefined && workflow.skillDir !== ''
661
+ ? workflow.skillDir
662
+ : (await packs.findBySuffix(workflow.id)) ?? skillSlug(workflow.name, workflow.id);
663
+ const seed = seedSkillDocument(workflow.name, (workflow.parameters ?? []).map((parameter) => parameter.name));
664
+ const created = await packs.create(slug, seed);
665
+ if (!created.ok)
666
+ return created;
667
+ const updated = await host.updateWorkflowSkill(id, { skillDir: slug });
668
+ if (updated === undefined)
669
+ return fail(`workflow "${id}" not found`);
670
+ return decorate(updated, slug);
671
+ },
672
+ async disable(id) {
673
+ const updated = await host.updateWorkflowSkill(id, { skillDir: null });
674
+ if (updated === undefined)
675
+ return fail(`workflow "${id}" not found`);
676
+ return { ok: true, value: true };
677
+ },
678
+ async destroy(id) {
679
+ const resolved = await resolvePack(id);
680
+ if (!resolved.ok)
681
+ return resolved;
682
+ const removed = await packs.destroy(resolved.value.slug);
683
+ if (!removed.ok)
684
+ return removed;
685
+ await host.updateWorkflowSkill(id, { skillDir: null });
686
+ return { ok: true, value: true };
687
+ },
688
+ async setRequired(id, required) {
689
+ const resolved = await resolvePack(id);
690
+ if (!resolved.ok)
691
+ return resolved;
692
+ const updated = await host.updateWorkflowSkill(id, { requireSkill: required });
693
+ if (updated === undefined)
694
+ return fail(`workflow "${id}" not found`);
695
+ return decorate(updated, resolved.value.slug);
696
+ },
697
+ async makeDir(id, name) {
698
+ const resolved = await resolvePack(id);
699
+ if (!resolved.ok)
700
+ return resolved;
701
+ return afterMutation(id, packs.makeDir(resolved.value.slug, name));
702
+ },
703
+ async readFile(id, path) {
704
+ const resolved = await resolvePack(id);
705
+ if (!resolved.ok)
706
+ return resolved;
707
+ return packs.read(resolved.value.slug, path);
708
+ },
709
+ async readRaw(id, path) {
710
+ const resolved = await resolvePack(id);
711
+ if (!resolved.ok)
712
+ return resolved;
713
+ const bytes = await packs.readBytes(resolved.value.slug, path);
714
+ if (!bytes.ok)
715
+ return bytes;
716
+ return { ok: true, value: { bytes: bytes.value, contentType: contentTypeOf(path) } };
717
+ },
718
+ async importFile(id, file, bytes, bucket) {
719
+ const resolved = await resolvePack(id);
720
+ if (!resolved.ok)
721
+ return resolved;
722
+ const base = file.replace(/^.*[\\/]/, '').trim();
723
+ const target = bucket !== undefined && bucket !== '' ? bucket : defaultBucketFor(base);
724
+ if (target === undefined)
725
+ return fail(`不支持导入这种文件类型:${base}`);
726
+ const path = `${target}/${base}`;
727
+ const written = await packs.writeBytes(resolved.value.slug, path, bytes);
728
+ if (!written.ok)
729
+ return written;
730
+ const pack = await decorate(resolved.value.workflow, resolved.value.slug);
731
+ if (!pack.ok)
732
+ return pack;
733
+ return { ok: true, value: { path, pack: pack.value } };
734
+ },
735
+ async writeFile(id, path, content) {
736
+ const resolved = await resolvePack(id);
737
+ if (!resolved.ok)
738
+ return resolved;
739
+ return afterMutation(id, packs.write(resolved.value.slug, path, content));
740
+ },
741
+ async renameFile(id, from, to) {
742
+ const resolved = await resolvePack(id);
743
+ if (!resolved.ok)
744
+ return resolved;
745
+ return afterMutation(id, packs.rename(resolved.value.slug, from, to));
746
+ },
747
+ async deleteFile(id, path) {
748
+ const resolved = await resolvePack(id);
749
+ if (!resolved.ok)
750
+ return resolved;
751
+ return afterMutation(id, packs.remove(resolved.value.slug, path));
752
+ },
753
+ async load(id) {
754
+ const resolved = await resolvePack(id);
755
+ if (!resolved.ok)
756
+ return resolved;
757
+ const body = await packs.body(resolved.value.slug);
758
+ if (!body.ok)
759
+ return body;
760
+ const info = await packs.info(resolved.value.slug);
761
+ return {
762
+ ok: true,
763
+ value: {
764
+ workflowId: resolved.value.workflow.id,
765
+ workflowName: resolved.value.workflow.name,
766
+ summary: body.value.summary,
767
+ body: body.value.body,
768
+ resourceBase: body.value.dir,
769
+ files: info.ok ? info.value.files.map((file) => file.path) : [SKILL_MAIN],
770
+ },
771
+ };
772
+ },
773
+ };
774
+ }