cs-bots 0.1.0 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +533 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/library.js CHANGED
@@ -23,8 +23,9 @@
23
23
  * 本文件不 import 任何 `@deepseek-ai/*` 或 npm 包(零依赖 CI 检查 1 覆盖)。
24
24
  */
25
25
 
26
- import { existsSync, readFileSync } from 'node:fs'
27
- import { resolve } from 'node:path'
26
+ import { accessSync, constants, existsSync, realpathSync, readdirSync, readFileSync, statSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
28
29
 
29
30
  import { ensureDir, statePath, writeJsonAtomic } from './store.js'
30
31
 
@@ -34,6 +35,24 @@ export const SKILLS_FILE = 'library-skills.json'
34
35
  /** 工作流库文件名。 */
35
36
  export const WORKFLOWS_FILE = 'library-workflows.json'
36
37
 
38
+ /** 自定义技能目录清单文件名(与库文件同目录、同原子写风格)。 */
39
+ export const SKILL_DIRS_FILE = 'library-skill-dirs.json'
40
+
41
+ /**
42
+ * 技能来源分层(对标记忆进化插件的技能管理器)。
43
+ *
44
+ * 为什么要分层:用户需要一眼看出「这条技能是哪来的」——
45
+ * - bundled:宿主/插件随附(升级会被覆盖,用户不该在上面改)
46
+ * - user: 用户自己攒的全局技能(跨项目复用)
47
+ * - project:某个项目工作目录里带的技能(换项目就没了)
48
+ * - custom: 从别处导入/手工粘贴的(来源不明,缺省值)
49
+ * 分层本身不改变技能行为,只用于 UI 分组与批量操作时的取舍。
50
+ */
51
+ export const SKILL_SOURCES = Object.freeze(['bundled', 'user', 'custom', 'project'])
52
+
53
+ /** `browseSkillDir` 单层最多返回的条目数(防超大目录把页面撑爆)。 */
54
+ const BROWSE_LIMIT = 500
55
+
37
56
  /** 落盘格式版本(结构不兼容时改它,让读取端显式吵起来)。 */
38
57
  export const LIBRARY_VERSION = 1
39
58
 
@@ -214,12 +233,142 @@ function requireBody(value, where) {
214
233
  return value
215
234
  }
216
235
 
236
+ // ── 技能管理:来源 / 启用状态 / 目录安全(模块级纯工具)──────────────────────
237
+
238
+ /**
239
+ * 归一技能来源。缺省 `custom`(从别处导入/手工粘贴的来源不明,最保守的归类);
240
+ * 取值不在白名单内一律抛错 —— 悄悄改写成 custom 会让用户的分类习惯失效。
241
+ */
242
+ function normalizeSource(value, where) {
243
+ if (value === undefined || value === null) return 'custom'
244
+ if (typeof value !== 'string') {
245
+ throw new LibraryError(`${where}:source 必须是字符串`, 'LIB_BAD_FIELD', { field: 'source', value })
246
+ }
247
+ const source = value.trim().toLowerCase()
248
+ if (!SKILL_SOURCES.includes(source)) {
249
+ throw new LibraryError(
250
+ `${where}:source 只支持 ${SKILL_SOURCES.join(' / ')},收到 ${JSON.stringify(value)}`,
251
+ 'LIB_BAD_FIELD',
252
+ { field: 'source', source: value, allowed: SKILL_SOURCES },
253
+ )
254
+ }
255
+ return source
256
+ }
257
+
258
+ /** 归一启用开关。缺省 `true`(老数据没有这个字段,语义上一直是「启用」)。 */
259
+ function normalizeEnabledFlag(value, where) {
260
+ if (value === undefined || value === null) return true
261
+ if (typeof value !== 'boolean') {
262
+ throw new LibraryError(`${where}:enabled 必须是布尔值`, 'LIB_BAD_FIELD', { field: 'enabled', value })
263
+ }
264
+ return value
265
+ }
266
+
267
+ /**
268
+ * 把磁盘上的技能记录补成当前模型(向后兼容的唯一入口)。
269
+ *
270
+ * 为什么只在读取时补、不立刻回写磁盘:读取可能发生在只读场景(列表/回放),
271
+ * 一次查列表就触发写盘是意外的副作用。等下一次真实写操作 commit 时,
272
+ * 归一后的记录自然落盘。
273
+ *
274
+ * 注意「缺字段」与「字段值非法」要区别对待:缺字段=老数据(补缺省),
275
+ * 值非法=文件被改坏(抛 LIB_STORE_CORRUPT,绝不猜)。
276
+ *
277
+ * @param {object} item
278
+ * @param {string} file
279
+ * @returns {object}
280
+ */
281
+ function normalizeSkillRecord(item, file) {
282
+ const out = { ...item }
283
+ if (out.source === undefined || out.source === null) {
284
+ out.source = 'custom'
285
+ } else if (typeof out.source !== 'string' || !SKILL_SOURCES.includes(out.source)) {
286
+ throw new LibraryError(
287
+ `技能库文件结构非法:${file} 里技能 ${out.id} 的 source=${JSON.stringify(out.source)} 不在白名单(${SKILL_SOURCES.join('/')})`,
288
+ 'LIB_STORE_CORRUPT',
289
+ { file, id: out.id, source: out.source },
290
+ )
291
+ }
292
+ if (out.enabled === undefined || out.enabled === null) {
293
+ out.enabled = true
294
+ } else if (typeof out.enabled !== 'boolean') {
295
+ throw new LibraryError(
296
+ `技能库文件结构非法:${file} 里技能 ${out.id} 的 enabled=${JSON.stringify(out.enabled)} 不是布尔值`,
297
+ 'LIB_STORE_CORRUPT',
298
+ { file, id: out.id, enabled: out.enabled },
299
+ )
300
+ }
301
+ return out
302
+ }
303
+
304
+ /** `child` 是否等于 `parent` 或位于其下(**按路径分段**比较,避免 `/a/bc` 误判在 `/a/b` 里)。 */
305
+ function isInside(child, parent) {
306
+ if (typeof child !== 'string' || typeof parent !== 'string') return false
307
+ if (child === parent) return true
308
+ const base = parent.endsWith(sep) ? parent : parent + sep
309
+ return child.startsWith(base)
310
+ }
311
+
312
+ /** realpath 失败返回 null(目录可能已删除/断链),调用方决定怎么报错。 */
313
+ function realpathOrNull(target) {
314
+ try {
315
+ return realpathSync(target)
316
+ } catch {
317
+ return null
318
+ }
319
+ }
320
+
321
+ /**
322
+ * 受保护目录白名单(挡住「手滑把密钥目录当技能目录挂进来」)。
323
+ *
324
+ * 为什么按白名单硬拦而不是给个警告:`~/.ssh`、`~/.dsh` 里是凭据与运行状态,
325
+ * 一旦被登记成技能目录,后续的「扫描→导入」会把里面的文件当技能读取,
326
+ * 甚至让 UI 浏览/编辑它们 —— 这不是「提醒一下」能兜住的,必须拒绝。
327
+ *
328
+ * 判定在 **realpath 之后**做:`ln -s ~/.ssh /tmp/skills` 这种绕过必须一起拦。
329
+ */
330
+ const SYSTEM_SENSITIVE_DIRS = Object.freeze([
331
+ '/etc', '/proc', '/sys', '/dev', '/boot', '/root', '/run',
332
+ '/bin', '/sbin', '/lib', '/lib64', '/usr', '/var', '/opt', '/srv',
333
+ ])
334
+
335
+ /** 用户主目录下受保护的子目录(名字,相对主目录)。 */
336
+ const HOME_SENSITIVE_NAMES = Object.freeze([
337
+ '.dsh', '.ssh', '.gnupg', '.aws', '.kube', '.docker', '.password-store',
338
+ ])
339
+
340
+ /**
341
+ * 判断一个**已 realpath 规范化**的目录是否属于受保护路径。
342
+ * @returns {string|null} 拒绝原因(null = 允许登记)
343
+ */
344
+ function describeProtectedDir(canonical) {
345
+ if (canonical === '/') return '不能把根目录登记为技能目录'
346
+ for (const protectedDir of SYSTEM_SENSITIVE_DIRS) {
347
+ if (isInside(canonical, protectedDir) || isInside(protectedDir, canonical)) {
348
+ return `目录位于受保护的系统路径 ${protectedDir} 附近,拒绝登记(防止误挂系统/凭据目录)`
349
+ }
350
+ }
351
+ let home
352
+ try { home = homedir() } catch { home = undefined }
353
+ if (home) {
354
+ if (canonical === home) return '不能把用户主目录本身登记为技能目录'
355
+ for (const name of HOME_SENSITIVE_NAMES) {
356
+ const protectedPath = join(home, name)
357
+ if (isInside(canonical, protectedPath) || isInside(protectedPath, canonical)) {
358
+ return `目录与受保护路径 ${protectedPath} 重叠,拒绝登记(含凭据/运行状态,不能当技能目录)`
359
+ }
360
+ }
361
+ // 主目录的上级(如 /home)也不许登记:会把别人的目录一起纳入浏览范围
362
+ if (isInside(home, canonical)) return `不能把用户主目录的上级目录 ${canonical} 登记为技能目录`
363
+ }
364
+ return null
365
+ }
366
+
217
367
  /**
218
368
  * 工作流步骤校验:非空数组、≤50 步、每步 title 必填。
219
369
  * 只取认识的字段(title / detail / ownerHint / done),其余忽略 —— 与
220
370
  * `botprofile.normalizeProfile` 的口径一致:多给的键属于调用方的自由,不是数据。
221
- */
222
- function normalizeSteps(value, where) {
371
+ */function normalizeSteps(value, where) {
223
372
  if (!Array.isArray(value)) {
224
373
  throw new LibraryError(`${where}:steps 必须是数组`, 'LIB_BAD_FIELD', { field: 'steps' })
225
374
  }
@@ -260,6 +409,304 @@ function normalizeSteps(value, where) {
260
409
  })
261
410
  }
262
411
 
412
+ // ── SKILL.md 解析 / 目录扫描(纯函数,不碰库状态)────────────────────────────
413
+ //
414
+ // 为什么不引 yaml:① 零第三方依赖是 cs-bots 铁律(CI 检查 2 机械拦截);
415
+ // ② 我们只需要 name/description/tags 三个标量,frontmatter 的实际写法极窄,
416
+ // 引一个 yaml 解析器换来的复杂度(别名、多文档、锚点)远大于收益。
417
+ // 因此这里自己做**行级**解析:`key: value`,遇到不认识的键跳过(真实世界的
418
+ // SKILL.md 常带 allowed-tools/license 等扩展键,硬拒会让扫描真实目录全军覆没),
419
+ // 但语法坏(未闭合 fence、不是 key: value、引号不闭合)必须抛错,不静默吞。
420
+
421
+ /** 合法 kebab-case:小写字母/数字分段,段间单个 `-`。 */
422
+ const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
423
+
424
+ /** frontmatter 的 fence(官方约定就是三个短横线)。 */
425
+ const FENCE = '---'
426
+
427
+ /**
428
+ * 导出多技能时的分隔标记。
429
+ *
430
+ * 为什么要有显式分隔:`exportSkills` 返回的是一段 markdown,导入方(人或另一个
431
+ * 实例)必须能把「一份 SKILL.md」切出来才能逐条 parse。用 `---` 当分隔会和
432
+ * frontmatter 的 fence 撞车,所以用一条 HTML 注释当分隔符(Markdown 渲染时不可见)。
433
+ */
434
+ export const EXPORT_SEPARATOR = '\n<!-- cs-bots:skill-break -->\n'
435
+
436
+ /** 构造 LIB_BAD_SKILL_MD(解析失败的唯一错误码,HTTP 层按它映射 400)。 */
437
+ function badSkillMd(message, detail) {
438
+ return new LibraryError(message, 'LIB_BAD_SKILL_MD', detail)
439
+ }
440
+
441
+ /**
442
+ * 从文件名兜底推技能名:
443
+ * - `<dir>/<name>/SKILL.md` → 取**目录名**(官方技能标准布局);
444
+ * - `<dir>/<name>.md` → 取文件名去 `.md`。
445
+ * 为什么要兜底:手写 SKILL.md 常漏 frontmatter,而目录名已经表达了技能名;
446
+ * 此时因为「没写 name」整份文件作废,对用户是纯损失。
447
+ */
448
+ function nameFromFilename(filename) {
449
+ if (typeof filename !== 'string' || filename.trim() === '') return undefined
450
+ let stem
451
+ try {
452
+ stem = basename(filename.trim()).replace(/\.md$/i, '')
453
+ if (stem.toLowerCase() === 'skill') {
454
+ const parent = basename(dirname(filename.trim()))
455
+ stem = parent && parent !== '.' && parent !== '..' ? parent : ''
456
+ }
457
+ } catch {
458
+ return undefined
459
+ }
460
+ return stem || undefined
461
+ }
462
+
463
+ /** 去掉值两侧引号:支持 JSON 双引号(含转义)与单引号;引号没闭合=语法坏。 */
464
+ function unquote(raw, where) {
465
+ const value = String(raw ?? '').trim()
466
+ if (value === '') return ''
467
+ if (value.startsWith('"')) {
468
+ if (!value.endsWith('"') || value.length < 2) {
469
+ throw badSkillMd(`${where}:frontmatter 值引号未闭合:${JSON.stringify(value)}`, { value })
470
+ }
471
+ try {
472
+ return JSON.parse(value)
473
+ } catch {
474
+ throw badSkillMd(`${where}:frontmatter 双引号值不是合法 JSON 字符串:${JSON.stringify(value)}`, { value })
475
+ }
476
+ }
477
+ if (value.startsWith("'")) {
478
+ if (!value.endsWith("'") || value.length < 2) {
479
+ throw badSkillMd(`${where}:frontmatter 值引号未闭合:${JSON.stringify(value)}`, { value })
480
+ }
481
+ return value.slice(1, -1)
482
+ }
483
+ return value
484
+ }
485
+
486
+ /**
487
+ * 按逗号切分 tags,**尊重引号**。
488
+ *
489
+ * 为什么不能裸 `split(',')`:标签本身可以合法地含有逗号(例如 `["a,b"]`),
490
+ * 裸切会把一个 tag 劈成两个,属于静默数据破坏。这里用一个极小的状态机。
491
+ */
492
+ function splitTagList(inner, where) {
493
+ /** @type {string[]} */
494
+ const parts = []
495
+ let current = ''
496
+ let quote = null
497
+ for (let i = 0; i < inner.length; i += 1) {
498
+ const ch = inner[i]
499
+ if (quote) {
500
+ // 双引号里允许反斜杠转义,原样带下去交给 unquote 的 JSON.parse 处理
501
+ if (ch === '\\' && quote === '"' && i + 1 < inner.length) {
502
+ current += ch + inner[i + 1]
503
+ i += 1
504
+ continue
505
+ }
506
+ if (ch === quote) quote = null
507
+ current += ch
508
+ continue
509
+ }
510
+ if (ch === '"' || ch === "'") {
511
+ quote = ch
512
+ current += ch
513
+ continue
514
+ }
515
+ if (ch === ',') {
516
+ parts.push(current)
517
+ current = ''
518
+ continue
519
+ }
520
+ current += ch
521
+ }
522
+ if (quote) throw badSkillMd(`${where}:tags 里的引号未闭合:${JSON.stringify(inner)}`, { inner })
523
+ parts.push(current)
524
+ return parts.map((part) => unquote(part, where)).filter((tag) => tag !== '')
525
+ }
526
+
527
+ /**
528
+ * 解析一行 `tags:` 的值。支持两种真实写法:
529
+ * - `tags: [review, quality]`(方括号数组)
530
+ * - `tags: review, quality`(裸逗号分隔)
531
+ * 不支持 YAML 对象形态(`tags: {a: b}`)—— 我们不是 YAML 解析器,遇到就明确报错。
532
+ */
533
+ function parseTagsValue(raw, where) {
534
+ const value = String(raw ?? '').trim()
535
+ if (value === '') return []
536
+ if (value.startsWith('{')) {
537
+ throw badSkillMd(`${where}:tags 不支持对象写法(只支持 [a, b] 或逗号分隔):${JSON.stringify(value)}`, { value })
538
+ }
539
+ const bracketOpen = value.startsWith('[')
540
+ const bracketClose = value.endsWith(']')
541
+ if (bracketOpen !== bracketClose) {
542
+ throw badSkillMd(`${where}:tags 的方括号不配对:${JSON.stringify(value)}`, { value })
543
+ }
544
+ const inner = bracketOpen ? value.slice(1, -1) : value
545
+ return splitTagList(inner, where)
546
+ }
547
+
548
+ /**
549
+ * 解析 SKILL.md(`--- frontmatter ---` + 正文)。
550
+ *
551
+ * 返回四字段固定结构(调用方据此落库)。**本函数只做提取**:
552
+ * name 是否为 kebab-case、body 是否超长这类「业务校验」留给 importSkills ——
553
+ * 因为导入契约要求非法条目进 `errors` 而不是让整批炸掉。
554
+ *
555
+ * @param {{ text: string, filename?: string }} input
556
+ * @returns {{ name: string, description: string, body: string, tags: string[] }}
557
+ */
558
+ export function parseSkillMarkdown(input = {}) {
559
+ const text = input?.text
560
+ if (typeof text !== 'string') {
561
+ throw new LibraryError('parseSkillMarkdown:text 必填(SKILL.md 全文)', 'LIB_BAD_FIELD', { field: 'text' })
562
+ }
563
+ const filename = input?.filename
564
+ const where = typeof filename === 'string' && filename ? `parseSkillMarkdown(${filename})` : 'parseSkillMarkdown'
565
+
566
+ // Windows 换行与 BOM 都要兼容:自己下载/复制的技能文件常带 BOM
567
+ const source = text.replace(/^\uFEFF/, '')
568
+ const lines = source.split(/\r?\n/)
569
+
570
+ // 允许 frontmatter 之前有空行(有宿主会在文件头插空行)
571
+ let cursor = 0
572
+ while (cursor < lines.length && lines[cursor].trim() === '') cursor += 1
573
+
574
+ let name
575
+ let description = ''
576
+ let tags = []
577
+ let bodyStart = 0
578
+
579
+ if (cursor < lines.length && lines[cursor].trim() === FENCE) {
580
+ let end = -1
581
+ for (let i = cursor + 1; i < lines.length; i += 1) {
582
+ if (lines[i].trim() === FENCE) { end = i; break }
583
+ }
584
+ if (end < 0) {
585
+ throw badSkillMd(`${where}:frontmatter 未闭合(找不到结束的 ${FENCE})`, { filename })
586
+ }
587
+ const rows = lines.slice(cursor + 1, end)
588
+ for (const [offset, row] of rows.entries()) {
589
+ const trimmed = row.trim()
590
+ if (trimmed === '' || trimmed.startsWith('#')) continue
591
+ const colon = row.indexOf(':')
592
+ if (colon < 0) {
593
+ throw badSkillMd(
594
+ `${where}:frontmatter 第 ${offset + 1} 行不是 key: value 形式:${JSON.stringify(row)}`,
595
+ { filename, line: offset + 1 },
596
+ )
597
+ }
598
+ const key = row.slice(0, colon).trim().toLowerCase()
599
+ const value = row.slice(colon + 1)
600
+ if (key === 'name') name = unquote(value, where)
601
+ else if (key === 'description') description = unquote(value, where)
602
+ else if (key === 'tags') tags = parseTagsValue(value, where)
603
+ // 其余键(allowed-tools / license / version …)忽略:不是本库的数据,拒绝反而会
604
+ // 让真实世界的技能文件整批导入失败。
605
+ }
606
+ bodyStart = end + 1
607
+ }
608
+
609
+ // 名字缺失时退到文件名/目录名;仍然没有才报错(此时无法确定它叫什么)
610
+ if (typeof name !== 'string' || name.trim() === '') {
611
+ const fallback = nameFromFilename(filename)
612
+ if (!fallback) {
613
+ throw badSkillMd(`${where}:缺少 name(frontmatter 没写,文件路径也推不出来)`, { filename })
614
+ }
615
+ name = fallback
616
+ }
617
+ name = name.trim()
618
+ if (/[\u0000-\u001f]/.test(name)) {
619
+ throw badSkillMd(`${where}:name 含控制字符:${JSON.stringify(name)}`, { filename, name })
620
+ }
621
+
622
+ // 正文:去掉 frontmatter 后的分隔空行与尾部空白。末尾空白一律裁掉 ——
623
+ // Markdown 结构里它不承载语义,留着只会在往返导出时制造噪声 diff。
624
+ const body = lines.slice(bodyStart).join('\n').replace(/^[\r\n]+/, '').replace(/\s+$/, '')
625
+
626
+ return { name, description: String(description ?? '').trim(), body, tags }
627
+ }
628
+
629
+ /**
630
+ * 扫描一个技能根目录(纯读取,不写库、不落盘)。
631
+ *
632
+ * 兼容两种布局:
633
+ * - `<dir>/<name>/SKILL.md`(官方标准)
634
+ * - `<dir>/<name>.md`(单文件技能,常见于手工导出)
635
+ *
636
+ * 为什么要显式排序:`readdirSync` 的顺序由文件系统决定(ext4 与 APFS 不同),
637
+ * 同一目录在两台机器上得到不同顺序会让「扫描→导入」的结果不可复现。
638
+ *
639
+ * @param {{ dir: string }} input
640
+ * @returns {{ items: object[], errors: { path: string, reason: string }[] }}
641
+ */
642
+ export function scanSkillsDir(input = {}) {
643
+ const rawDir = input?.dir
644
+ if (typeof rawDir !== 'string' || rawDir.trim() === '') {
645
+ throw new LibraryError('scanSkillsDir:dir 必填(技能根目录,如 <cwd>/.agents/skills)', 'LIB_BAD_DIR', { dir: rawDir })
646
+ }
647
+ const dir = resolve(rawDir.trim())
648
+ try {
649
+ if (!statSync(dir).isDirectory()) {
650
+ throw new LibraryError(`scanSkillsDir:不是目录:${dir}`, 'LIB_BAD_DIR', { dir })
651
+ }
652
+ } catch (error) {
653
+ if (error instanceof LibraryError) throw error
654
+ throw new LibraryError(`scanSkillsDir:目录不存在或不可访问:${dir}`, 'LIB_BAD_DIR', { dir, cause: String(error?.message ?? error) })
655
+ }
656
+
657
+ let names
658
+ try {
659
+ // 只取名字,类型用 statSync 再判:这样**符号链接**(把共享技能仓 link 进来的
660
+ // 常见做法)也能被正确识别 —— Dirent 的 isDirectory() 对符号链接一律 false,
661
+ // 会导致「目录明明在、扫出来却是空的」。
662
+ names = readdirSync(dir)
663
+ } catch (error) {
664
+ throw new LibraryError(`scanSkillsDir:读取目录失败:${dir}`, 'LIB_BAD_DIR', { dir, cause: String(error?.message ?? error) })
665
+ }
666
+ names.sort()
667
+
668
+ const items = []
669
+ const errors = []
670
+ for (const name of names) {
671
+ // 跳过隐藏项(.git / .DS_Store / .tmp-*):它们不是技能
672
+ if (name.startsWith('.')) continue
673
+ const full = join(dir, name)
674
+ let stat
675
+ try {
676
+ stat = statSync(full)
677
+ } catch {
678
+ continue // 断链符号链接之类:不是可读技能,跳过
679
+ }
680
+ let file
681
+ if (stat.isDirectory()) {
682
+ file = join(full, 'SKILL.md')
683
+ let hasFile = false
684
+ try { hasFile = statSync(file).isFile() } catch { hasFile = false }
685
+ // 没有 SKILL.md 的子目录不是技能目录(技能根下常有 notes/、assets/),
686
+ // 静默跳过而不是记 error —— 记 error 会把正常目录噪音化成失败。
687
+ if (!hasFile) continue
688
+ } else if (stat.isFile() && /\.md$/i.test(name)) {
689
+ file = full
690
+ } else {
691
+ continue
692
+ }
693
+
694
+ let text
695
+ try {
696
+ text = readFileSync(file, 'utf8')
697
+ } catch (error) {
698
+ errors.push({ path: file, reason: `LIB_SKILL_READ_FAILED:${error?.message ?? error}` })
699
+ continue
700
+ }
701
+ try {
702
+ items.push({ ...parseSkillMarkdown({ text, filename: file }), sourcePath: file })
703
+ } catch (error) {
704
+ errors.push({ path: file, reason: `${error?.code ?? 'ERROR'}:${error?.message ?? error}` })
705
+ }
706
+ }
707
+ return { items, errors }
708
+ }
709
+
263
710
  /**
264
711
  * 读一个库文件。**缺文件=空库;坏 JSON=抛错**(绝不静默重置)。
265
712
  * @param {string} file
@@ -352,7 +799,8 @@ export function createLibrary(ctx, config = {}) {
352
799
 
353
800
  const skillsFile = statePath(stateDir, SKILLS_FILE)
354
801
  const workflowsFile = statePath(stateDir, WORKFLOWS_FILE)
355
- const paths = Object.freeze({ stateDir, skillsFile, workflowsFile })
802
+ const skillDirsFile = statePath(stateDir, SKILL_DIRS_FILE)
803
+ const paths = Object.freeze({ stateDir, skillsFile, workflowsFile, skillDirsFile })
356
804
 
357
805
  const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
358
806
  const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* 同上 */ } }
@@ -361,6 +809,11 @@ export function createLibrary(ctx, config = {}) {
361
809
  let skills = new Map()
362
810
  /** @type {Map<string, object>} 工作流 id → 工作流条目 */
363
811
  let workflows = new Map()
812
+ /**
813
+ * 已登记的自定义技能目录:id = 规范化后的绝对路径(目录本身唯一,无需再生成 id)。
814
+ * @type {Map<string, {id: string, dir: string, label: string, addedAt: number, addedBy: string}>}
815
+ */
816
+ let skillDirs = new Map()
364
817
  let disposed = false
365
818
 
366
819
  // ── 生命周期与持久化 ──────────────────────────────────────────────────────
@@ -390,6 +843,21 @@ export function createLibrary(ctx, config = {}) {
390
843
  workflows = next
391
844
  }
392
845
 
846
+ /**
847
+ * 技能目录清单提交:先落盘、成功后再换内存(同 commitSkills 顺序)。
848
+ * 排序口径:登记时间 → 目录路径,保证同一个状态目录在不同机器上落盘字节一致。
849
+ */
850
+ function commitSkillDirs(next) {
851
+ const items = [...next.values()].sort((a, b) => {
852
+ const at = Number(a.addedAt) || 0
853
+ const bt = Number(b.addedAt) || 0
854
+ if (at !== bt) return at - bt
855
+ return String(a.dir) < String(b.dir) ? -1 : String(a.dir) > String(b.dir) ? 1 : 0
856
+ })
857
+ persist(skillDirsFile, items, '技能目录清单')
858
+ skillDirs = next
859
+ }
860
+
393
861
  /** 从磁盘重建内存态(构造与 `replay()` 都用它)。坏文件在这里抛,不在读取时偷偷吞掉。 */
394
862
  function hydrate() {
395
863
  const nextSkills = new Map()
@@ -397,7 +865,9 @@ export function createLibrary(ctx, config = {}) {
397
865
  if (nextSkills.has(item.id)) {
398
866
  throw new LibraryError(`技能库文件里有重复 id:${item.id}(${skillsFile})`, 'LIB_STORE_CORRUPT', { file: skillsFile, id: item.id })
399
867
  }
400
- nextSkills.set(item.id, item)
868
+ // 老数据没有 source/enabled:读取时补缺省值(enable=true / source=custom),
869
+ // 不在这里回写磁盘(读取不该有写副作用),下次真实写操作时自然归一。
870
+ nextSkills.set(item.id, normalizeSkillRecord(item, skillsFile))
401
871
  }
402
872
  const nextWorkflows = new Map()
403
873
  for (const item of readStore(workflowsFile)) {
@@ -406,8 +876,24 @@ export function createLibrary(ctx, config = {}) {
406
876
  }
407
877
  nextWorkflows.set(item.id, item)
408
878
  }
879
+ const nextSkillDirs = new Map()
880
+ for (const item of readStore(skillDirsFile)) {
881
+ // id 必须等于 dir(登记时就是这样写的);不一致说明文件被手改坏,拒绝猜
882
+ if (item.id !== item.dir) {
883
+ throw new LibraryError(
884
+ `技能目录清单结构非法:${skillDirsFile} 里 id 与 dir 不一致(${JSON.stringify(item.id)} vs ${JSON.stringify(item.dir)})`,
885
+ 'LIB_STORE_CORRUPT',
886
+ { file: skillDirsFile, id: item.id, dir: item.dir },
887
+ )
888
+ }
889
+ if (nextSkillDirs.has(item.id)) {
890
+ throw new LibraryError(`技能目录清单里有重复目录:${item.id}(${skillDirsFile})`, 'LIB_STORE_CORRUPT', { file: skillDirsFile, dir: item.id })
891
+ }
892
+ nextSkillDirs.set(item.id, item)
893
+ }
409
894
  skills = nextSkills
410
895
  workflows = nextWorkflows
896
+ skillDirs = nextSkillDirs
411
897
  }
412
898
 
413
899
  // ── 内部小工具 ────────────────────────────────────────────────────────────
@@ -477,7 +963,8 @@ export function createLibrary(ctx, config = {}) {
477
963
 
478
964
  /**
479
965
  * 新建技能条目。
480
- * @param {{ name: string, description?: string, body: string, tags?: string[], by: string, now?: number }} input
966
+ * `source` 缺省 `custom`、`enabled` 缺省 `true`(见 SKILL_SOURCES / normalizeEnabledFlag)。
967
+ * @param {{ name: string, description?: string, body: string, tags?: string[], source?: string, enabled?: boolean, by: string, now?: number }} input
481
968
  * @returns {object}
482
969
  */
483
970
  function createSkill(input = {}) {
@@ -489,6 +976,8 @@ export function createLibrary(ctx, config = {}) {
489
976
  const description = normalizeText(input.description, 'description', LIMITS.description, 'createSkill') ?? ''
490
977
  const body = requireBody(input.body, 'createSkill')
491
978
  const tags = normalizeTags(input.tags, 'createSkill') ?? []
979
+ const source = normalizeSource(input.source, 'createSkill')
980
+ const enabled = normalizeEnabledFlag(input.enabled, 'createSkill')
492
981
 
493
982
  const item = {
494
983
  id: nextId('sk', skills),
@@ -496,6 +985,8 @@ export function createLibrary(ctx, config = {}) {
496
985
  description,
497
986
  body,
498
987
  tags,
988
+ source,
989
+ enabled,
499
990
  createdAt: at,
500
991
  updatedAt: at,
501
992
  rev: 1,
@@ -503,13 +994,14 @@ export function createLibrary(ctx, config = {}) {
503
994
  const next = new Map(skills)
504
995
  next.set(item.id, item)
505
996
  commitSkills(next)
506
- info('[cs-bots] library.createSkill', { id: item.id, name })
997
+ info('[cs-bots] library.createSkill', { id: item.id, name, source, enabled })
507
998
  return clone(item)
508
999
  }
509
1000
 
510
1001
  /**
511
1002
  * 按 `expectedRev` 做 CAS 更新。**rev 不匹配即抛错,绝不静默覆盖。**
512
- * @param {{ id: string, expectedRev: number, name?: string, description?: string, body?: string, tags?: string[], by: string, now?: number }} input
1003
+ * `source`/`enabled` 也可在这里改(可选);专门的开关/来源入口见 setSkillEnabled / setSkillSource。
1004
+ * @param {{ id: string, expectedRev: number, name?: string, description?: string, body?: string, tags?: string[], source?: string, enabled?: boolean, by: string, now?: number }} input
513
1005
  * @returns {object}
514
1006
  */
515
1007
  function updateSkill(input = {}) {
@@ -538,6 +1030,9 @@ export function createLibrary(ctx, config = {}) {
538
1030
  }
539
1031
  if (input.body !== undefined) next.body = requireBody(input.body, 'updateSkill')
540
1032
  if (input.tags !== undefined) next.tags = normalizeTags(input.tags, 'updateSkill') ?? []
1033
+ // source/enabled 只在显式传了才改:不传保持原值(老调用方无感知)
1034
+ if (input.source !== undefined) next.source = normalizeSource(input.source, 'updateSkill')
1035
+ if (input.enabled !== undefined) next.enabled = normalizeEnabledFlag(input.enabled, 'updateSkill')
541
1036
  next.rev = current.rev + 1
542
1037
  next.updatedAt = at
543
1038
 
@@ -575,12 +1070,716 @@ export function createLibrary(ctx, config = {}) {
575
1070
  }
576
1071
 
577
1072
  /**
578
- * @param {{ tag?: string }} [query]
1073
+ * 列技能(支持按 tag / 来源 / 启用状态 / 关键词过滤 + 条数上限)。
1074
+ *
1075
+ * 缺省**不返回禁用技能**(禁用=「先别用」,不是「删了」;列表里不该出现);
1076
+ * 要连禁用一起看,显式传 `enabled:false`(只看禁用)或 `all:true`(全要)。
1077
+ *
1078
+ * @param {{ tag?: string, source?: string, enabled?: boolean, all?: boolean, query?: string, limit?: number }} [query]
579
1079
  * @returns {object[]}
580
1080
  */
581
1081
  function listSkills(query = {}) {
582
1082
  assertAlive('listSkills')
583
- return filterByTag(skillsList(), query?.tag)
1083
+ const q = query ?? {}
1084
+ const all = q.all === true
1085
+ if (q.all !== undefined && typeof q.all !== 'boolean') {
1086
+ throw new LibraryError('listSkills:all 必须是布尔值', 'LIB_BAD_FIELD', { field: 'all', all: q.all })
1087
+ }
1088
+
1089
+ let items = filterByTag(skillsList(), q.tag)
1090
+ if (q.enabled !== undefined && q.enabled !== null) {
1091
+ if (typeof q.enabled !== 'boolean') {
1092
+ throw new LibraryError('listSkills:enabled 必须是布尔值', 'LIB_BAD_FIELD', { field: 'enabled', enabled: q.enabled })
1093
+ }
1094
+ items = items.filter((item) => (item.enabled !== false) === q.enabled)
1095
+ } else if (!all) {
1096
+ // 缺省视图:只看启用中的
1097
+ items = items.filter((item) => item.enabled !== false)
1098
+ }
1099
+
1100
+ if (q.source !== undefined && q.source !== null) {
1101
+ if (typeof q.source !== 'string') {
1102
+ throw new LibraryError('listSkills:source 必须是字符串', 'LIB_BAD_FIELD', { field: 'source', source: q.source })
1103
+ }
1104
+ const source = q.source.trim().toLowerCase()
1105
+ if (source && !SKILL_SOURCES.includes(source)) {
1106
+ throw new LibraryError(
1107
+ `listSkills:source 只支持 ${SKILL_SOURCES.join(' / ')},收到 ${JSON.stringify(q.source)}`,
1108
+ 'LIB_BAD_FIELD',
1109
+ { field: 'source', source: q.source, allowed: SKILL_SOURCES },
1110
+ )
1111
+ }
1112
+ if (source) items = items.filter((item) => item.source === source)
1113
+ }
1114
+
1115
+ if (q.query !== undefined && q.query !== null) {
1116
+ if (typeof q.query !== 'string') {
1117
+ throw new LibraryError('listSkills:query 必须是字符串', 'LIB_BAD_FIELD', { field: 'query', query: q.query })
1118
+ }
1119
+ const key = q.query.trim().toLowerCase()
1120
+ if (key) {
1121
+ // 搜名字/说明/标签/正文:UI 只有一个搜索框,用户不会先想「我要搜哪个字段」
1122
+ items = items.filter((item) => [item.name, item.description, ...(item.tags ?? []), item.body]
1123
+ .some((value) => String(value ?? '').toLowerCase().includes(key)))
1124
+ }
1125
+ }
1126
+
1127
+ if (q.limit !== undefined && q.limit !== null) {
1128
+ if (typeof q.limit !== 'number' || !Number.isInteger(q.limit) || q.limit < 1) {
1129
+ throw new LibraryError('listSkills:limit 必须是正整数', 'LIB_BAD_FIELD', { field: 'limit', limit: q.limit })
1130
+ }
1131
+ items = items.slice(0, q.limit)
1132
+ }
1133
+ return items
1134
+ }
1135
+
1136
+ /**
1137
+ * CAS 补丁:把 `patch` 应用到指定技能上(rev+1、updatedAt=now)。
1138
+ * 单独抽出来是因为「启用开关」和「改来源」除了改的字段不同,其余校验/落盘完全一样,
1139
+ * 复制两遍必然有一处将来漏改(例如忘了 CAS)。
1140
+ */
1141
+ function patchSkill(input, where, patch) {
1142
+ assertAlive(where)
1143
+ requireBy(input.by, where)
1144
+ const at = normalizeNow(input.now, where)
1145
+ const id = requireString(input.id, 'id', where)
1146
+ const expectedRev = requireRev(input.expectedRev, where)
1147
+ const current = findById(skills, id, 'LIB_SKILL_NOT_FOUND', where)
1148
+ if (current.rev !== expectedRev) {
1149
+ throw new LibraryError(
1150
+ `${where}:rev 冲突(期望 ${expectedRev},实际 ${current.rev})—— 请重新读取后再改`,
1151
+ 'LIB_CAS_CONFLICT',
1152
+ { id, expectedRev, actualRev: current.rev },
1153
+ )
1154
+ }
1155
+ const next = { ...current, ...patch(current), rev: current.rev + 1, updatedAt: at }
1156
+ const map = new Map(skills)
1157
+ map.set(next.id, next)
1158
+ commitSkills(map)
1159
+ return clone(next)
1160
+ }
1161
+
1162
+ /**
1163
+ * 启用/停用技能(不删除就能停用)。重复设成同一个值也会 rev+1 —— 保持
1164
+ * 「每次调用都是一次写入」的简单语义,UI 靠 rev 判断是否有变化。
1165
+ * @param {{ id: string, enabled: boolean, by: string, expectedRev: number, now?: number }} input
1166
+ * @returns {object}
1167
+ */
1168
+ function setSkillEnabled(input = {}) {
1169
+ const enabled = normalizeEnabledFlag(input?.enabled, 'setSkillEnabled')
1170
+ if (input?.enabled === undefined || input?.enabled === null) {
1171
+ throw new LibraryError('setSkillEnabled:enabled 必填(布尔值)', 'LIB_BAD_FIELD', { field: 'enabled' })
1172
+ }
1173
+ const skill = patchSkill(input, 'setSkillEnabled', () => ({ enabled }))
1174
+ info('[cs-bots] library.setSkillEnabled', { id: skill.id, enabled })
1175
+ return skill
1176
+ }
1177
+
1178
+ /**
1179
+ * 改技能来源分层(只改分类,不动内容)。
1180
+ * @param {{ id: string, source: string, by: string, expectedRev: number, now?: number }} input
1181
+ * @returns {object}
1182
+ */
1183
+ function setSkillSource(input = {}) {
1184
+ if (input?.source === undefined || input?.source === null) {
1185
+ throw new LibraryError('setSkillSource:source 必填(bundled / user / custom / project)', 'LIB_BAD_FIELD', { field: 'source' })
1186
+ }
1187
+ const source = normalizeSource(input.source, 'setSkillSource')
1188
+ const skill = patchSkill(input, 'setSkillSource', () => ({ source }))
1189
+ info('[cs-bots] library.setSkillSource', { id: skill.id, source })
1190
+ return skill
1191
+ }
1192
+
1193
+ // ── 技能批量导入 / 导出 ───────────────────────────────────────────────────
1194
+ //
1195
+ // 导入的纪律:**单条非法只记进 errors,绝不中断整批**。
1196
+ // 依据需求场景 —— 用户从别处拖进来 30 个技能,其中 2 个名字不合法;
1197
+ // 若整批抛错,用户得自己一个个试,28 个合法条目也白等了。
1198
+ // 但「存储写失败」是环境故障,不能当成单条错误吞掉(否则每条都失败一遍、
1199
+ // 返回一堆 errors 让人以为只是数据问题)—— 这类错误一律原样抛出。
1200
+
1201
+ /** 导入模式白名单。 */
1202
+ const IMPORT_MODES = new Set(['skip', 'overwrite', 'rename'])
1203
+
1204
+ /** 这些错误码属于「环境/存储」故障,导入时不可降级成单条 errors。 */
1205
+ const FATAL_IMPORT_CODES = new Set([
1206
+ 'LIB_STORE_WRITE_FAILED',
1207
+ 'LIB_STORE_READ_FAILED',
1208
+ 'LIB_STORE_CORRUPT',
1209
+ 'LIB_STORE_VERSION',
1210
+ 'LIB_BAD_STATE_DIR',
1211
+ 'LIB_DISPOSED',
1212
+ ])
1213
+
1214
+ /** 判断一个错误是否该中断整批(存储故障/实例已释放)。 */
1215
+ function isFatalLibraryError(error) {
1216
+ return error instanceof LibraryError && FATAL_IMPORT_CODES.has(error.code)
1217
+ }
1218
+
1219
+ /** 按名字(大小写不敏感)找已有技能。 */
1220
+ function findSkillByNameKey(name) {
1221
+ const key = nameKey(name)
1222
+ for (const item of skills.values()) {
1223
+ if (nameKey(item.name) === key) return item
1224
+ }
1225
+ return undefined
1226
+ }
1227
+
1228
+ /**
1229
+ * 为 `rename` 模式生成一个空位名字:`base-2`、`base-3`…
1230
+ *
1231
+ * 长度保护:技能名上限 64,`base` 刚好 64 时直接拼 `-2` 会超限甚至被
1232
+ * requireName 拒掉,所以先裁 `base` 再拼;裁完去掉尾部连字符,保持 kebab 合法。
1233
+ */
1234
+ function nextFreeSkillName(base) {
1235
+ for (let n = 2; n <= 100; n += 1) {
1236
+ const suffix = `-${n}`
1237
+ let stem = base
1238
+ if (stem.length + suffix.length > LIMITS.name) stem = stem.slice(0, Math.max(1, LIMITS.name - suffix.length))
1239
+ stem = stem.replace(/-+$/, '') || 'skill'
1240
+ const candidate = `${stem}${suffix}`
1241
+ if (!KEBAB_RE.test(candidate)) continue
1242
+ if (!findSkillByNameKey(candidate)) return candidate
1243
+ }
1244
+ throw new LibraryError(
1245
+ `importSkills:改名失败,${JSON.stringify(base)} 的 -2 … -100 后缀全部被占用`,
1246
+ 'LIB_SKILL_NAME_TAKEN',
1247
+ { name: base },
1248
+ )
1249
+ }
1250
+
1251
+ /**
1252
+ * 把一条 import 输入归一成 `{ name, description, body, tags, source?, enabled? }`。
1253
+ * 支持三种输入(都为了「少让调用方写转换代码」):
1254
+ * - 完整对象 `{ name, description, body, tags }`(parseSkillMarkdown 的输出)
1255
+ * - 带原文的对象 `{ text, filename }`(直接从文件读出来的内容)
1256
+ * - 纯字符串(一段 SKILL.md 全文,名字从 frontmatter 取)
1257
+ * source/enabled 只在对象输入里透传(SKILL.md 的 frontmatter 契约只认
1258
+ * name/description/tags,不在这里夹带私有字段,否则导出给别的工具会变脏)。
1259
+ */
1260
+ function normalizeImportItem(raw, index) {
1261
+ const where = `importSkills.items[${index}]`
1262
+ let doc
1263
+ let source
1264
+ let enabled
1265
+ if (typeof raw === 'string') {
1266
+ doc = parseSkillMarkdown({ text: raw })
1267
+ } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1268
+ doc = typeof raw.text === 'string'
1269
+ ? parseSkillMarkdown({ text: raw.text, filename: raw.filename })
1270
+ : { name: raw.name, description: raw.description, body: raw.body, tags: raw.tags }
1271
+ source = raw.source
1272
+ enabled = raw.enabled
1273
+ } else {
1274
+ throw new LibraryError(`${where}:条目必须是对象或 SKILL.md 字符串`, 'LIB_BAD_FIELD', { index })
1275
+ }
1276
+ return {
1277
+ name: typeof doc.name === 'string' ? doc.name.trim() : '',
1278
+ description: doc.description === undefined ? '' : doc.description,
1279
+ body: doc.body,
1280
+ tags: doc.tags,
1281
+ source,
1282
+ enabled,
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * 批量导入技能(去重规则见 DESIGN-v2 特性 ⑤)。
1288
+ *
1289
+ * @param {{
1290
+ * items: (object|string)[],
1291
+ * by: string,
1292
+ * mode?: 'skip'|'overwrite'|'rename',
1293
+ * now?: number,
1294
+ * }} input
1295
+ * @returns {{
1296
+ * created: object[],
1297
+ * updated: object[],
1298
+ * skipped: { name: string, id: string }[],
1299
+ * errors: { name: string, reason: string }[],
1300
+ * }}
1301
+ */
1302
+ function importSkills(input = {}) {
1303
+ assertAlive('importSkills')
1304
+ const by = requireBy(input.by, 'importSkills')
1305
+ const at = normalizeNow(input.now, 'importSkills')
1306
+ const mode = input.mode === undefined || input.mode === null ? 'skip' : input.mode
1307
+ if (!IMPORT_MODES.has(mode)) {
1308
+ throw new LibraryError(
1309
+ `importSkills:mode 只支持 skip / overwrite / rename,收到 ${JSON.stringify(input.mode)}`,
1310
+ 'LIB_BAD_FIELD',
1311
+ { field: 'mode', mode: input.mode },
1312
+ )
1313
+ }
1314
+ const rawItems = input.items
1315
+ if (!Array.isArray(rawItems)) {
1316
+ throw new LibraryError('importSkills:items 必须是数组', 'LIB_BAD_FIELD', { field: 'items' })
1317
+ }
1318
+
1319
+ /** @type {object[]} */ const created = []
1320
+ /** @type {object[]} */ const updated = []
1321
+ /** @type {{name: string, id: string}[]} */ const skipped = []
1322
+ /** @type {{name: string, reason: string}[]} */ const errors = []
1323
+
1324
+ for (const [index, raw] of rawItems.entries()) {
1325
+ let spec
1326
+ try {
1327
+ spec = normalizeImportItem(raw, index)
1328
+ } catch (error) {
1329
+ if (isFatalLibraryError(error)) throw error
1330
+ errors.push({ name: `#${index}`, reason: `${error?.code ?? 'ERROR'}:${error?.message ?? error}` })
1331
+ continue
1332
+ }
1333
+
1334
+ const label = spec.name || `#${index}`
1335
+ try {
1336
+ // kebab-case 硬校验:导入是外部数据的入口,这里比库内 NAME_RE 更严 ——
1337
+ // 目标是「一处写、处处能当目录名」,大写/下划线/点一律不收。
1338
+ if (!KEBAB_RE.test(spec.name)) {
1339
+ throw new LibraryError(
1340
+ `name 不是 kebab-case:${JSON.stringify(spec.name)}(只允许小写字母/数字,段间单个 -)`,
1341
+ 'LIB_BAD_NAME',
1342
+ { name: spec.name },
1343
+ )
1344
+ }
1345
+ // 正文必填前置校验:否则 overwrite 时 updateSkill 拿到 undefined body 会
1346
+ // 「什么都不改」还返回成功 —— 那是静默失败。
1347
+ requireBody(spec.body, 'importSkills')
1348
+
1349
+ const existing = findSkillByNameKey(spec.name)
1350
+ if (!existing) {
1351
+ created.push(createSkill({
1352
+ name: spec.name,
1353
+ description: spec.description,
1354
+ body: spec.body,
1355
+ tags: spec.tags,
1356
+ source: spec.source,
1357
+ enabled: spec.enabled,
1358
+ by,
1359
+ now: at,
1360
+ }))
1361
+ continue
1362
+ }
1363
+ if (mode === 'skip') {
1364
+ // 跳过只看名字:同名即视为「已经有了」,不做内容比较(内容比较会把
1365
+ // 一次导入变成一次 diff 服务,且「一样但仍写一次」也无意义)。
1366
+ skipped.push({ name: existing.name, id: existing.id })
1367
+ continue
1368
+ }
1369
+ if (mode === 'overwrite') {
1370
+ updated.push(updateSkill({
1371
+ id: existing.id,
1372
+ expectedRev: existing.rev,
1373
+ name: spec.name,
1374
+ description: spec.description ?? '',
1375
+ body: spec.body,
1376
+ tags: spec.tags ?? [],
1377
+ // source/enabled 未给(undefined)时 updateSkill 保持原值:
1378
+ // 「覆盖正文」不该顺手把用户设的停用状态/来源分类重置掉
1379
+ source: spec.source,
1380
+ enabled: spec.enabled,
1381
+ by,
1382
+ now: at,
1383
+ }))
1384
+ continue
1385
+ }
1386
+ // rename:目标名被占,换一个不冲突的后缀
1387
+ const target = nextFreeSkillName(spec.name)
1388
+ created.push(createSkill({
1389
+ name: target,
1390
+ description: spec.description,
1391
+ body: spec.body,
1392
+ tags: spec.tags,
1393
+ source: spec.source,
1394
+ enabled: spec.enabled,
1395
+ by,
1396
+ now: at,
1397
+ }))
1398
+ } catch (error) {
1399
+ if (isFatalLibraryError(error)) throw error
1400
+ errors.push({ name: label, reason: `${error?.code ?? 'ERROR'}:${error?.message ?? error}` })
1401
+ }
1402
+ }
1403
+
1404
+ info('[cs-bots] library.importSkills', {
1405
+ mode,
1406
+ created: created.length,
1407
+ updated: updated.length,
1408
+ skipped: skipped.length,
1409
+ errors: errors.length,
1410
+ })
1411
+ return { created, updated, skipped, errors }
1412
+ }
1413
+
1414
+ /** 渲染单条技能为 SKILL.md 文本(frontmatter + 正文)。 */
1415
+ function renderSkillMarkdown(item) {
1416
+ const head = [FENCE, `name: ${item.name}`]
1417
+ if (item.description) head.push(`description: ${JSON.stringify(item.description)}`)
1418
+ if (Array.isArray(item.tags) && item.tags.length > 0) {
1419
+ // 含逗号/方括号/引号的 tag 必须加引号,否则 parse 时会被逗号切开
1420
+ const rendered = item.tags.map((tag) => (/[,[\]"']/.test(tag) ? JSON.stringify(tag) : tag))
1421
+ head.push(`tags: [${rendered.join(', ')}]`)
1422
+ }
1423
+ head.push(FENCE, '')
1424
+ return `${head.join('\n')}${String(item.body ?? '').trimEnd()}\n`
1425
+ }
1426
+
1427
+ /**
1428
+ * 导出技能为 markdown(多份之间用 `EXPORT_SEPARATOR` 分隔)。
1429
+ *
1430
+ * `ids` 省略 = 导出全部(备份/迁移场景的默认期望);显式给 id 时**未知 id 抛错**,
1431
+ * 不静默丢弃 —— 迁移少一条技能,事后没人能发现。
1432
+ *
1433
+ * **禁用的技能不导出**:导出是为了「拿去别处用」,把一个用户明确停用的技能
1434
+ * 带到新机器上并默认启用,等于绕过他的决定。想导出请先启用。
1435
+ *
1436
+ * @param {{ ids?: string[] }} [input]
1437
+ * @returns {{ markdown: string, count: number }}
1438
+ */
1439
+ function exportSkills(input = {}) {
1440
+ assertAlive('exportSkills')
1441
+ const rawIds = input?.ids
1442
+ let items
1443
+ if (rawIds === undefined || rawIds === null) {
1444
+ items = skillsList().filter((item) => item.enabled !== false)
1445
+ } else {
1446
+ if (!Array.isArray(rawIds)) {
1447
+ throw new LibraryError('exportSkills:ids 必须是技能 id 数组', 'LIB_BAD_FIELD', { field: 'ids' })
1448
+ }
1449
+ /** @type {Set<string>} */ const seen = new Set()
1450
+ items = []
1451
+ for (const rawId of rawIds) {
1452
+ const id = requireString(rawId, 'ids 里的 id', 'exportSkills')
1453
+ const item = findById(skills, id, 'LIB_SKILL_NOT_FOUND', 'exportSkills')
1454
+ if (seen.has(item.id)) continue // 重复 id 只导一份,count 才可信
1455
+ seen.add(item.id)
1456
+ if (item.enabled === false) continue // 禁用项跳过(见上面的注释)
1457
+ items.push(clone(item))
1458
+ }
1459
+ }
1460
+ const markdown = items.map(renderSkillMarkdown).join(EXPORT_SEPARATOR)
1461
+ return { markdown, count: items.length }
1462
+ }
1463
+
1464
+ /** 实例方法版 parseSkillMarkdown(只是加 dispose 检查,逻辑全在模块级纯函数里)。 */
1465
+ function skillMdParser(input) {
1466
+ assertAlive('parseSkillMarkdown')
1467
+ return parseSkillMarkdown(input)
1468
+ }
1469
+
1470
+ /** 实例方法版 scanSkillsDir(同上)。 */
1471
+ function skillDirScanner(input) {
1472
+ assertAlive('scanSkillsDir')
1473
+ return scanSkillsDir(input)
1474
+ }
1475
+
1476
+ // ── 技能管理器:自定义目录 + 目录树浏览(特性⑩)──────────────────────────
1477
+ //
1478
+ // 设计取舍:**登记的是「目录」,不是「目录里的技能」**。
1479
+ // 技能内容留在磁盘上(那是真相源,用户可能用 git 管理),库里只存
1480
+ // 「哪些目录值得扫/浏览/导入」。这样不会出现「库里的技能副本与磁盘文件
1481
+ // 谁为准」的第二真相源问题。
1482
+
1483
+ /** 目录标签:缺省用目录名(UI 需要一个短名字,不能让用户看到一长串路径)。 */
1484
+ function normalizeDirLabel(value, dir, where) {
1485
+ if (value === undefined || value === null) {
1486
+ const base = basename(dir)
1487
+ return base || dir
1488
+ }
1489
+ if (typeof value !== 'string') {
1490
+ throw new LibraryError(`${where}:label 必须是字符串`, 'LIB_BAD_FIELD', { field: 'label' })
1491
+ }
1492
+ const label = value.trim()
1493
+ if (label === '') {
1494
+ throw new LibraryError(`${where}:label 不能是空字符串(不需要就干脆不传)`, 'LIB_BAD_FIELD', { field: 'label' })
1495
+ }
1496
+ if (label.length > LIMITS.name * 2) {
1497
+ throw new LibraryError(`${where}:label 超长 ${label.length} > ${LIMITS.name * 2} 字符`, 'LIB_TOO_LONG', { field: 'label', length: label.length })
1498
+ }
1499
+ if (/[\u0000-\u001f]/.test(label)) {
1500
+ throw new LibraryError(`${where}:label 不能含控制字符`, 'LIB_BAD_FIELD', { field: 'label' })
1501
+ }
1502
+ return label
1503
+ }
1504
+
1505
+ /** 登记顺序稳定(addedAt → dir),保证 listSkillDirs 输出可复现。 */
1506
+ function sortedSkillDirRecords() {
1507
+ return [...skillDirs.values()].sort((a, b) => {
1508
+ const at = Number(a.addedAt) || 0
1509
+ const bt = Number(b.addedAt) || 0
1510
+ if (at !== bt) return at - bt
1511
+ return String(a.dir) < String(b.dir) ? -1 : String(a.dir) > String(b.dir) ? 1 : 0
1512
+ })
1513
+ }
1514
+
1515
+ /**
1516
+ * 扫描单个**已登记**目录(listSkillDirs 与 scanAllSkillDirs 共用,避免一次调用扫两遍)。
1517
+ * 目录被删/被移走返回 exists:false,不抛错 —— 「列清单」这个动作本身不该因为
1518
+ * 某个目录消失就整页失败。
1519
+ */
1520
+ function scanRegisteredDir(record) {
1521
+ let stat
1522
+ try {
1523
+ stat = statSync(record.dir)
1524
+ } catch {
1525
+ return { exists: false, skillCount: 0, items: [], errors: [] }
1526
+ }
1527
+ if (!stat.isDirectory()) return { exists: false, skillCount: 0, items: [], errors: [] }
1528
+ try {
1529
+ const scanned = scanSkillsDir({ dir: record.dir })
1530
+ return { exists: true, skillCount: scanned.items.length, items: scanned.items, errors: scanned.errors }
1531
+ } catch (error) {
1532
+ return {
1533
+ exists: true,
1534
+ skillCount: 0,
1535
+ items: [],
1536
+ errors: [{ path: record.dir, reason: `${error?.code ?? 'ERROR'}:${error?.message ?? error}` }],
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ /**
1542
+ * 列出已登记的自定义技能目录。
1543
+ * `exists` / `skillCount` 是**实时探测**的(磁盘是真相源),不落盘缓存:
1544
+ * 目录可能随时被移动/删除,缓存只会让 UI 显示过期信息。
1545
+ *
1546
+ * @returns {{ dir: string, label: string, addedAt: number, exists: boolean, skillCount: number }[]}
1547
+ */
1548
+ function listSkillDirs() {
1549
+ assertAlive('listSkillDirs')
1550
+ return sortedSkillDirRecords().map((record) => {
1551
+ const scanned = scanRegisteredDir(record)
1552
+ return {
1553
+ dir: record.dir,
1554
+ label: record.label,
1555
+ addedAt: record.addedAt,
1556
+ exists: scanned.exists,
1557
+ skillCount: scanned.skillCount,
1558
+ }
1559
+ })
1560
+ }
1561
+
1562
+ /**
1563
+ * 登记一个自定义技能目录(用户可以把别处的技能目录挂进来)。
1564
+ *
1565
+ * 安全检查顺序很关键:**先 realpath、再判敏感目录、最后才落库**。
1566
+ * 先 realpath 是为了挡住 `ln -s ~/.ssh /tmp/skills` 这种绕过;
1567
+ * 敏感目录一律 `LIB_BAD_DIR` 且说明原因(用户手滑把密钥目录挂进来是真事)。
1568
+ *
1569
+ * @param {{ dir: string, label?: string, by: string, now?: number }} input
1570
+ * @returns {{ dirs: object[], added: string }}
1571
+ */
1572
+ function addSkillDir(input = {}) {
1573
+ assertAlive('addSkillDir')
1574
+ const by = requireBy(input.by, 'addSkillDir')
1575
+ const at = normalizeNow(input.now, 'addSkillDir')
1576
+ const raw = input.dir
1577
+ if (typeof raw !== 'string' || raw.trim() === '') {
1578
+ throw new LibraryError('addSkillDir:dir 必填(绝对路径,如 ~/my-skills)', 'LIB_BAD_DIR', { dir: raw })
1579
+ }
1580
+ const lexical = raw.trim()
1581
+ if (!isAbsolute(lexical)) {
1582
+ throw new LibraryError(`addSkillDir:dir 必须是绝对路径,收到 ${JSON.stringify(lexical)}`, 'LIB_BAD_DIR', { dir: lexical })
1583
+ }
1584
+ // 规范化到真实路径:同一目录的不同写法(尾斜杠/符号链接)只算一个
1585
+ const canonical = realpathOrNull(resolve(lexical))
1586
+ if (!canonical) {
1587
+ throw new LibraryError(`addSkillDir:目录不存在或不可访问:${lexical}`, 'LIB_BAD_DIR', { dir: lexical })
1588
+ }
1589
+ let stat
1590
+ try {
1591
+ stat = statSync(canonical)
1592
+ } catch (error) {
1593
+ throw new LibraryError(`addSkillDir:无法读取目录信息:${canonical}`, 'LIB_BAD_DIR', { dir: canonical, cause: String(error?.message ?? error) })
1594
+ }
1595
+ if (!stat.isDirectory()) {
1596
+ throw new LibraryError(`addSkillDir:不是目录:${canonical}`, 'LIB_BAD_DIR', { dir: canonical })
1597
+ }
1598
+ const protectedReason = describeProtectedDir(canonical)
1599
+ if (protectedReason) {
1600
+ throw new LibraryError(`addSkillDir:${protectedReason}(${canonical})`, 'LIB_BAD_DIR', { dir: canonical, reason: protectedReason })
1601
+ }
1602
+ if (skillDirs.has(canonical)) {
1603
+ throw new LibraryError(`addSkillDir:该目录已登记:${canonical}`, 'LIB_DIR_EXISTS', { dir: canonical })
1604
+ }
1605
+
1606
+ const record = {
1607
+ id: canonical,
1608
+ dir: canonical,
1609
+ label: normalizeDirLabel(input.label, canonical, 'addSkillDir'),
1610
+ addedAt: at,
1611
+ addedBy: by,
1612
+ }
1613
+ const next = new Map(skillDirs)
1614
+ next.set(record.id, record)
1615
+ commitSkillDirs(next)
1616
+ info('[cs-bots] library.addSkillDir', { dir: record.dir, label: record.label })
1617
+ return { dirs: listSkillDirs(), added: record.dir }
1618
+ }
1619
+
1620
+ /**
1621
+ * 取消登记(只从清单里移除,**绝不动磁盘上的目录/文件**)。
1622
+ * @param {{ dir: string, by: string }} input
1623
+ * @returns {{ dirs: object[] }}
1624
+ */
1625
+ function removeSkillDir(input = {}) {
1626
+ assertAlive('removeSkillDir')
1627
+ requireBy(input.by, 'removeSkillDir')
1628
+ const raw = input.dir
1629
+ if (typeof raw !== 'string' || raw.trim() === '') {
1630
+ throw new LibraryError('removeSkillDir:dir 必填', 'LIB_BAD_DIR', { dir: raw })
1631
+ }
1632
+ const lexical = resolve(raw.trim())
1633
+ // 目录可能已被删除:realpath 不出来时退回词法路径再查(否则「删不掉的登记」会卡住用户)
1634
+ const canonical = realpathOrNull(lexical) ?? lexical
1635
+ if (!skillDirs.has(canonical)) {
1636
+ throw new LibraryError(`removeSkillDir:该目录未登记:${canonical}`, 'LIB_DIR_NOT_FOUND', { dir: canonical })
1637
+ }
1638
+ const next = new Map(skillDirs)
1639
+ next.delete(canonical)
1640
+ commitSkillDirs(next)
1641
+ warn('[cs-bots] library.removeSkillDir', { dir: canonical })
1642
+ return { dirs: listSkillDirs() }
1643
+ }
1644
+
1645
+ /**
1646
+ * 扫描全部已登记目录,返回**可导入条目**(不自动入库 —— 导入与否由人决定)。
1647
+ * 条目标注了 `dir` / `dirLabel`,UI 可以按来源目录分组显示。
1648
+ *
1649
+ * @returns {{ items: object[], errors: object[], dirs: object[] }}
1650
+ */
1651
+ function scanAllSkillDirs() {
1652
+ assertAlive('scanAllSkillDirs')
1653
+ const items = []
1654
+ const errors = []
1655
+ const dirs = []
1656
+ for (const record of sortedSkillDirRecords()) {
1657
+ const scanned = scanRegisteredDir(record)
1658
+ dirs.push({
1659
+ dir: record.dir,
1660
+ label: record.label,
1661
+ addedAt: record.addedAt,
1662
+ exists: scanned.exists,
1663
+ skillCount: scanned.skillCount,
1664
+ })
1665
+ if (!scanned.exists) {
1666
+ errors.push({ dir: record.dir, path: record.dir, reason: 'LIB_BAD_DIR:目录已不存在(可能被移动或删除)' })
1667
+ continue
1668
+ }
1669
+ for (const item of scanned.items) items.push({ ...item, dir: record.dir, dirLabel: record.label })
1670
+ for (const error of scanned.errors) errors.push({ ...error, dir: record.dir })
1671
+ }
1672
+ return { items, errors, dirs }
1673
+ }
1674
+
1675
+ /**
1676
+ * 浏览已登记目录的下一层(给 UI 的只读目录树,不递归)。
1677
+ *
1678
+ * 安全边界:只允许**已登记目录及其子目录**;`sub` 必须是相对路径,
1679
+ * 词法与 realpath 两道都校验(第二道专门挡「目录里有个符号链接指向外部」
1680
+ * 这种越界,只查字符串前缀是挡不住的)。
1681
+ *
1682
+ * @param {{ dir: string, sub?: string }} input
1683
+ * @returns {{ path: string, entries: {name: string, type: 'dir'|'file', size: number, mtime: number, readable: boolean}[], truncated: boolean }}
1684
+ */
1685
+ function browseSkillDir(input = {}) {
1686
+ assertAlive('browseSkillDir')
1687
+ const rawDir = input?.dir
1688
+ if (typeof rawDir !== 'string' || rawDir.trim() === '') {
1689
+ throw new LibraryError('browseSkillDir:dir 必填(已登记的技能目录)', 'LIB_BAD_DIR', { dir: rawDir })
1690
+ }
1691
+ const lexicalBase = resolve(rawDir.trim())
1692
+ const base = realpathOrNull(lexicalBase)
1693
+ if (!base) {
1694
+ throw new LibraryError(`browseSkillDir:目录不存在或不可访问:${lexicalBase}`, 'LIB_BAD_DIR', { dir: lexicalBase })
1695
+ }
1696
+ if (!skillDirs.has(base)) {
1697
+ throw new LibraryError(
1698
+ `browseSkillDir:只能浏览已登记的技能目录(${base} 未登记)`,
1699
+ 'LIB_BAD_DIR',
1700
+ { dir: base },
1701
+ )
1702
+ }
1703
+
1704
+ const rawSub = input?.sub ?? ''
1705
+ if (typeof rawSub !== 'string') {
1706
+ throw new LibraryError('browseSkillDir:sub 必须是相对路径字符串', 'LIB_BAD_FIELD', { field: 'sub', sub: rawSub })
1707
+ }
1708
+ const sub = rawSub.trim()
1709
+ if (sub !== '' && isAbsolute(sub)) {
1710
+ throw new LibraryError(`browseSkillDir:sub 必须是相对路径,收到绝对路径 ${JSON.stringify(sub)}`, 'LIB_BAD_DIR', { dir: base, sub })
1711
+ }
1712
+ const target = sub === '' || sub === '.' ? base : resolve(base, sub)
1713
+ if (!isInside(target, base)) {
1714
+ throw new LibraryError(`browseSkillDir:访问越界(sub=${JSON.stringify(sub)} 指向登记目录之外)`, 'LIB_BAD_DIR', { dir: base, sub })
1715
+ }
1716
+ const targetReal = realpathOrNull(target)
1717
+ if (!targetReal) {
1718
+ throw new LibraryError(`browseSkillDir:目录不存在:${target}`, 'LIB_BAD_DIR', { dir: base, sub })
1719
+ }
1720
+ if (!isInside(targetReal, base)) {
1721
+ throw new LibraryError(
1722
+ `browseSkillDir:访问越界(sub=${JSON.stringify(sub)} 经符号链接指向登记目录之外)`,
1723
+ 'LIB_BAD_DIR',
1724
+ { dir: base, sub, resolved: targetReal },
1725
+ )
1726
+ }
1727
+ let stat
1728
+ try {
1729
+ stat = statSync(targetReal)
1730
+ } catch (error) {
1731
+ throw new LibraryError(`browseSkillDir:无法读取目录信息:${targetReal}`, 'LIB_BAD_DIR', { dir: targetReal, cause: String(error?.message ?? error) })
1732
+ }
1733
+ if (!stat.isDirectory()) {
1734
+ throw new LibraryError(`browseSkillDir:目标不是目录:${targetReal}`, 'LIB_BAD_DIR', { dir: targetReal })
1735
+ }
1736
+
1737
+ let names
1738
+ try {
1739
+ names = readdirSync(targetReal)
1740
+ } catch (error) {
1741
+ throw new LibraryError(`browseSkillDir:读取目录失败:${targetReal}`, 'LIB_BAD_DIR', { dir: targetReal, cause: String(error?.message ?? error) })
1742
+ }
1743
+ names.sort()
1744
+
1745
+ /** @type {{name: string, type: 'dir'|'file', size: number, mtime: number, readable: boolean}[]} */
1746
+ const entries = []
1747
+ for (const name of names) {
1748
+ const full = join(targetReal, name)
1749
+ let entryStat = null
1750
+ try {
1751
+ entryStat = statSync(full)
1752
+ } catch {
1753
+ entryStat = null // 断链符号链接等:下面按不可读处理,不抛
1754
+ }
1755
+ let readable = true
1756
+ try {
1757
+ // 用 access 而不是真的读文件:浏览目录不该把几 MB 的技能正文读进内存,
1758
+ // 更不能因为没有读权限就让整页崩掉。一个坏文件只标 readable:false。
1759
+ accessSync(full, constants.R_OK)
1760
+ } catch {
1761
+ readable = false
1762
+ }
1763
+ entries.push({
1764
+ name,
1765
+ type: entryStat?.isDirectory() ? 'dir' : 'file',
1766
+ size: entryStat ? entryStat.size : 0,
1767
+ mtime: entryStat ? Math.floor(entryStat.mtimeMs) : 0,
1768
+ readable,
1769
+ })
1770
+ }
1771
+ // 目录在前、文件在后,各自按名字 —— 文件系统顺序不可复现,必须自己定序
1772
+ entries.sort((a, b) => {
1773
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
1774
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
1775
+ })
1776
+
1777
+ const truncated = entries.length > BROWSE_LIMIT
1778
+ return {
1779
+ path: targetReal,
1780
+ entries: truncated ? entries.slice(0, BROWSE_LIMIT) : entries,
1781
+ truncated,
1782
+ }
584
1783
  }
585
1784
 
586
1785
  // ── 工作流库 ──────────────────────────────────────────────────────────────
@@ -713,6 +1912,7 @@ export function createLibrary(ctx, config = {}) {
713
1912
  function dispose() {
714
1913
  skills = new Map()
715
1914
  workflows = new Map()
1915
+ skillDirs = new Map()
716
1916
  disposed = true
717
1917
  }
718
1918
 
@@ -726,6 +1926,19 @@ export function createLibrary(ctx, config = {}) {
726
1926
  removeSkill,
727
1927
  getSkill,
728
1928
  listSkills,
1929
+ // 特性 ⑤:技能导入/导出(签名见 DESIGN-v2,别改)
1930
+ parseSkillMarkdown: skillMdParser,
1931
+ importSkills,
1932
+ scanSkillsDir: skillDirScanner,
1933
+ exportSkills,
1934
+ // 特性 ⑩:技能管理器(来源分层 / 启停 / 自定义目录 / 目录树浏览)
1935
+ setSkillEnabled,
1936
+ setSkillSource,
1937
+ addSkillDir,
1938
+ removeSkillDir,
1939
+ listSkillDirs,
1940
+ scanAllSkillDirs,
1941
+ browseSkillDir,
729
1942
  createWorkflow,
730
1943
  updateWorkflow,
731
1944
  removeWorkflow,