skillhelper 0.4.2

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/bin/lib/atomic-write.mjs +72 -0
  4. package/bin/lib/paths.mjs +65 -0
  5. package/bin/lib/port.mjs +23 -0
  6. package/bin/skillhelper.mjs +720 -0
  7. package/build/build-site.mjs +35 -0
  8. package/build/build-web.mjs +29 -0
  9. package/build/pack-smoke.mjs +195 -0
  10. package/build/prepare-publish.mjs +3 -0
  11. package/build/verify.mjs +157 -0
  12. package/config/sources.example.yaml +126 -0
  13. package/docs/Frontend-Spec.md +1207 -0
  14. package/docs/scan_skills_rules.md +432 -0
  15. package/package.json +95 -0
  16. package/packages/scanner/package.json +15 -0
  17. package/packages/scanner/src/adapters/claude-agents.mjs +134 -0
  18. package/packages/scanner/src/adapters/claude-plugin.mjs +260 -0
  19. package/packages/scanner/src/adapters/codex-plugin.mjs +330 -0
  20. package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
  21. package/packages/scanner/src/adapters/file-docs.mjs +158 -0
  22. package/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
  23. package/packages/scanner/src/adapters/markdown-skill.mjs +229 -0
  24. package/packages/scanner/src/adapters/mcp-config.mjs +190 -0
  25. package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
  26. package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
  27. package/packages/scanner/src/adapters/tier1-editor-skills.mjs +278 -0
  28. package/packages/scanner/src/adapters/tier2-user-skills.mjs +218 -0
  29. package/packages/scanner/src/adapters/tier3-other-skills.mjs +225 -0
  30. package/packages/scanner/src/config/editor-tiers.mjs +191 -0
  31. package/packages/scanner/src/core/atomic-write.mjs +11 -0
  32. package/packages/scanner/src/core/descriptor.mjs +143 -0
  33. package/packages/scanner/src/core/path-hash.mjs +89 -0
  34. package/packages/scanner/src/core/registry.mjs +128 -0
  35. package/packages/scanner/src/hash/path-hash.mjs +8 -0
  36. package/packages/scanner/src/icon/brand-map.mjs +268 -0
  37. package/packages/scanner/src/icon/icon-extractor.mjs +466 -0
  38. package/packages/scanner/src/index.mjs +450 -0
  39. package/packages/scanner/src/types.d.ts +144 -0
  40. package/packages/scanner/src/utils.mjs +194 -0
  41. package/packages/server/package.json +15 -0
  42. package/packages/server/src/index.mjs +1071 -0
  43. package/packages/server/src/labels.mjs +64 -0
  44. package/packages/server/src/langdetect.mjs +34 -0
  45. package/packages/server/src/llm-client.mjs +42 -0
  46. package/packages/server/src/translate-cache.mjs +132 -0
  47. package/packages/server/src/translator.mjs +53 -0
  48. package/packages/web/README.md +36 -0
  49. package/packages/web/dist/assets/index-CGt04vt0.js +304 -0
  50. package/packages/web/dist/assets/index-CLSDoa3H.css +1 -0
  51. package/packages/web/dist/brand-icon.png +0 -0
  52. package/packages/web/dist/brand-logo.png +0 -0
  53. package/packages/web/dist/favicon-16x16.png +0 -0
  54. package/packages/web/dist/favicon-192x192.png +0 -0
  55. package/packages/web/dist/favicon-32x32.png +0 -0
  56. package/packages/web/dist/favicon-512x512.png +0 -0
  57. package/packages/web/dist/favicon-source.svg +5 -0
  58. package/packages/web/dist/favicon.ico +0 -0
  59. package/packages/web/dist/favicon.svg +5 -0
  60. package/packages/web/dist/icons/hermes-128.png +0 -0
  61. package/packages/web/dist/icons/hermes-192.png +0 -0
  62. package/packages/web/dist/icons/hermes-256.png +0 -0
  63. package/packages/web/dist/icons/hermes-32.png +0 -0
  64. package/packages/web/dist/icons/hermes-512.png +0 -0
  65. package/packages/web/dist/icons/hermes-64.png +0 -0
  66. package/packages/web/dist/icons/hermes.png +0 -0
  67. package/packages/web/dist/index.html +37 -0
  68. package/packages/web/dist/robots.txt +3 -0
  69. package/packages/web/dist/site.webmanifest +24 -0
  70. package/packages/web/public/brand-icon.png +0 -0
  71. package/packages/web/public/brand-logo.png +0 -0
  72. package/packages/web/public/favicon-16x16.png +0 -0
  73. package/packages/web/public/favicon-192x192.png +0 -0
  74. package/packages/web/public/favicon-32x32.png +0 -0
  75. package/packages/web/public/favicon-512x512.png +0 -0
  76. package/packages/web/public/favicon-source.svg +5 -0
  77. package/packages/web/public/favicon.ico +0 -0
  78. package/packages/web/public/favicon.svg +5 -0
  79. package/packages/web/public/icons/hermes-128.png +0 -0
  80. package/packages/web/public/icons/hermes-192.png +0 -0
  81. package/packages/web/public/icons/hermes-256.png +0 -0
  82. package/packages/web/public/icons/hermes-32.png +0 -0
  83. package/packages/web/public/icons/hermes-512.png +0 -0
  84. package/packages/web/public/icons/hermes-64.png +0 -0
  85. package/packages/web/public/icons/hermes.png +0 -0
  86. package/packages/web/public/robots.txt +3 -0
  87. package/packages/web/public/site.webmanifest +24 -0
  88. package/scripts/install-and-sync.sh +209 -0
  89. package/scripts/sync-skills.sh +605 -0
@@ -0,0 +1,432 @@
1
+ # 技能扫描规则(Scan Rules)
2
+
3
+ > 本文档定义 SkillHelper 的技能扫描规范,是稳定的工程规则,供扫描器(`packages/scanner`)与服务端(`packages/server`)遵循。
4
+ > 版本:v4.0 · 上次更新:2026-07-02
5
+
6
+ 设计参考开源项目 **Pearcleaner**(macOS 清理工具,Swift/SwiftUI)的「软件扫描逻辑」与「图标获取展示」,在 Node.js + Fastify + React 架构下等价落地。
7
+
8
+ ---
9
+
10
+ ## R0 扫描优先级策略 ⭐⭐⭐
11
+
12
+ 扫描器按以下**三层优先级**递进扫描技能。优先级高的层级命中的技能路径不能与低优先级层级重复,使用 MD5 哈希值标记技能路径实现去重。
13
+
14
+ ### 第1层:编辑器工具全局和项目目录(最高优先级)
15
+
16
+ 定位以下20+编辑器工具的技能/工作流目录,按以下优先级尝试:
17
+
18
+ #### 编辑器工具清单
19
+
20
+ | 排序 | 编辑器工具 | 全局路径 | 项目路径 | 品牌标识 |
21
+ |------|-----------|--------|--------|---------|
22
+ | 1 | **Hermes** | `~/.hermes/skills` | `.hermes/skills` | `hermes` |
23
+ | 2 | **Claude** | `~/.claude/skills` | `.claude/skills` | `claude` |
24
+ | 3 | **Cursor** | `~/.cursor/skills` | `.cursor/skills` | `cursor` |
25
+ | 4 | **VSCode** | `~/.vscode/skills` | `.vscode/skills` | `vscode` |
26
+ | 5 | **Codeium** | `~/.codeium/skills` | `.codeium/skills` | `codeium` |
27
+ | 6 | **Windsurf** | `~/.windsurf/skills` | `.windsurf/skills` | `windsurf` |
28
+ | 7 | **Continue** | `~/.continue/skills` | `.continue/skills` | `continue` |
29
+ | 8 | **Tauri** | `~/.tauri/skills` | `.tauri/skills` | `tauri` |
30
+ | 9 | **Trae** | `~/.trae/skills` | `.trae/skills` | `trae` |
31
+ | 10 | **Trae-CN** | `~/.trae-cn/skills` | `.trae-cn/skills` | `trae-cn` |
32
+ | 11 | **QoDer** | `~/.qoder/skills` | `.qoder/skills` | `qoder` |
33
+ | 12 | **Codex** | `~/.codex/skills` | `.codex/skills` | `codex` |
34
+ | 13 | **Vim** | `~/.vim/skills` | `.vim/skills` | `vim` |
35
+ | 14 | **Neovim** | `~/.config/nvim/skills` | `.nvim/skills` | `neovim` |
36
+ | 15 | **Emacs** | `~/.emacs.d/skills` | `.emacs.d/skills` | `emacs` |
37
+ | 16 | **Sublime** | `~/.config/sublime-text/skills` | `.sublime-text/skills` | `sublime` |
38
+ | 17 | **Jetbrains IDE** | `~/.config/JetBrains/skills` | `.jetbrains/skills` | `jetbrains` |
39
+ | 18 | **Nova** | `~/.nova/skills` | `.nova/skills` | `nova` |
40
+ | 19 | **Zed** | `~/.config/zed/skills` | `.zed/skills` | `zed` |
41
+ | 20 | **GitHub Copilot** | `~/.copilot/skills` | `.copilot/skills` | `copilot` |
42
+ | 21 | **Replit** | `~/.replit/skills` | `.replit/skills` | `replit` |
43
+ | 22 | **Glot.io** | `~/.glot/skills` | `.glot/skills` | `glot` |
44
+
45
+ **扫描规则**:
46
+ - 递归匹配 `**/SKILL.md` 或 `**/skill.md`(文件名**不区分大小写**)
47
+ - 目录名称:所有变体允许(`skills` / `Skills` / `SKILLS` 等,**不区分大小写**)
48
+ - **路径去重**:计算每个扫描到的 SKILL.md 的绝对路径 MD5 哈希值,存储在扫描结果中作为 `pathHash`
49
+
50
+ ### 第2层:用户根目录技能库(中等优先级)
51
+
52
+ 定位用户 HOME 目录下的技能库:
53
+
54
+ - 目录路径:`~/skills` 或任意大小写变体(`~/SKILLS` / `~/Skills` 等)
55
+ - **扫描规则**:递归匹配 `**/SKILL.md` 或 `**/skill.md`(文件名**不区分大小写**)
56
+ - **路径去重**:同样计算 MD5 哈希值,与第1层的 `pathHash` 集合对比,**如果已存在则跳过该技能**
57
+
58
+ ### 第3层:其他所有位置(最低优先级)⭐
59
+
60
+ 扫描范围外或通配位置,仅在开启专项扫描时激活:
61
+
62
+ - **扫描规则**:**仅** 递归匹配 `**/skill.md`(小写,**精确大小写敏感**)
63
+ - **路径去重**:计算 MD5 哈希值,与第1、2层的 `pathHash` 集合对比,**如果已存在则跳过该技能**
64
+ - **扫描范围**(可配置,默认仅包括标准系统路径):
65
+ - `/opt/` 及其子目录(自定义软件安装位置)
66
+ - `/usr/local/` 及其子目录
67
+ - `~/opt/` 及其子目录
68
+ - 其他用户指定的自定义扫描路径
69
+
70
+ **激活条件**:
71
+ - 通过配置文件或 API 参数 `scanTier3=true` 显式启用
72
+ - 默认不扫描第3层,仅返回第1、2层结果
73
+
74
+ ---
75
+
76
+ ## R1 扫描根目录
77
+
78
+ - 支持多个技能根目录:`roots: string[]`,由 R0 的三层优先级策略动态生成。
79
+ - `~` 展开为 `$HOME`。
80
+ - 每个 root 必须存在,否则返回 400。
81
+ - glob 返回精确且无重复的文件列表。
82
+
83
+ ---
84
+
85
+ ## R2 目录和文件名大小写规则 ⭐
86
+
87
+ ### 目录名称(所有层级都不敏感)
88
+
89
+ - 技能目录可能命名为 `skills` / `Skills` / `SKILLS` / `sKiLLs`,扫描器接受所有变体。
90
+ - 按存在性和优先级返回第一个匹配的目录。
91
+
92
+ ### 文件名规则(按层级区分)
93
+
94
+ - **第1层(编辑器工具)& 第2层(用户根目录)**:`SKILL.md` 或 `skill.md` 均匹配(**不区分大小写**)
95
+ - **第3层(其他)**:仅匹配 `skill.md`(小写,**精确大小写敏感**)
96
+
97
+ ---
98
+
99
+ ## R3 文件匹配与递归扫描
100
+
101
+ - 使用 `fast-glob` 递归匹配对应层级的文件名规则(见 R2)。
102
+ - 每个文件仅读取一次;多个 root 命中同一文件时按 `filePath` 去重。
103
+ - 扫描采用分块并发(见 R7),但返回列表按路径字典序稳定排序,保证幂等。
104
+
105
+ ---
106
+
107
+ ## R4 Frontmatter 解析
108
+
109
+ - frontmatter 必须以 `---` 开始和结束;YAML 无效时返回 400。
110
+ - `name` / `description` 必填,缺失时返回 400。
111
+ - 其余字段缺失时用默认值或 null。
112
+
113
+ 支持字段:
114
+
115
+ ```yaml
116
+ ---
117
+ name: string # 必须
118
+ description: string # 必填
119
+ category?: string
120
+ brand?: string # 图标解析主要依据(如 cursor / vs-code / claude-code)
121
+ source?: string # 图标解析次要依据
122
+ tier?: 'tool' | 'directory' | 'other'
123
+ tags?: string[]
124
+ icon?: string # 可选:显式指定图标,优先级最高
125
+ # - "app:com.todesktop.230313mzl4w4u92"(指定 Bundle ID)
126
+ # - "emoji:🤖"(强制 emoji)
127
+ # - "url:https://..."(远程/本地图标)
128
+ ---
129
+ ```
130
+
131
+ ---
132
+
133
+ ## R5 返回数据格式
134
+
135
+ ```typescript
136
+ SkillItem {
137
+ id, name, description, filePath,
138
+ pathHash, // MD5(filePath),用于去重和菜单分层显示
139
+ tier, // 'tier-1' | 'tier-2' | 'tier-3',标记所属层级
140
+ tierId, // v4.0 同 tier,主路径字段名(tier 仅 scanLegacy 降级路径赋值)
141
+ editorBrand, // v4.0 Tier 1 编辑器品牌(cursor/claude/hermes 等),用于分组和图标
142
+ confidence?, // v0.4 检测置信度 'L1'|'L2'|'L3'|'L4',见 R10
143
+ category?, brand?, tier?, tags?,
144
+ iconUrl?, // 真实图标端点,如 "/api/icons/cursor?size=64";无真实图标时为空
145
+ iconFallback?, // emoji 兜底值,如 "🤖"
146
+ createdAt?, updatedAt?
147
+ }
148
+
149
+ ScanResult {
150
+ skills, // 按 tier 分组的技能列表
151
+ total, limit, offset, roots, scannedAt, duration,
152
+ pathHashes, // 已扫描的所有 pathHash 集合,用于去重检测
153
+ tier1Count, tier2Count, tier3Count // 各层级技能数量统计
154
+ }
155
+ ```
156
+
157
+ 排序:按 tier(1 → 2 → 3)优先,同 tier 内按 category 分组后按 name 字典序。
158
+
159
+ ---
160
+
161
+ ## R6 真实工具/编辑器图标获取 ⭐
162
+
163
+ 技能列表展示其所属工具/编辑器的**真实应用图标**,对标 Pearcleaner 的 `AppInfoUtils.fetchAppIcon(...)`。
164
+
165
+ ### R6.1 品牌 → 应用映射表
166
+
167
+ 维护 `brand/source → 候选 Bundle ID + 候选应用名` 的映射表(`packages/scanner/src/icon/brand-map.mjs`),包含 R0 列出的所有22个编辑器工具品牌。无对应 GUI 应用的品牌(如 `hermes`、`mcp`)按 R6.5 回退 emoji。
168
+
169
+ ### R6.2 应用定位
170
+
171
+ 按优先级定位 `.app`:
172
+
173
+ 1. **Bundle ID 精确定位**(最快最准):`mdfind "kMDItemCFBundleIdentifier == '<bundleId>'"`
174
+ 2. **应用名扫描**:遍历 `/Applications`、`~/Applications`、`/System/Applications`,`.app` 目录名大小写不敏感匹配
175
+ 3. 命中多个时取修改时间最新者
176
+
177
+ ### R6.3 图标提取(Node.js 等价 `NSWorkspace.icon(forFile:)`)
178
+
179
+ 对定位到的 `<App>.app`:
180
+
181
+ 1. 读 `Contents/Info.plist` 的 `CFBundleIconFile`(`plutil -extract CFBundleIconFile raw`)
182
+ 2. 拼出 `Contents/Resources/<IconFile>.icns`(缺省兜底:目录下首个 `.icns`,优先含 `icon` 的文件)
183
+ 3. 用系统内置 `sips` 转 PNG:
184
+ ```bash
185
+ sips -s format png -Z 64 "<icns>" --out "<cachePng>"
186
+ ```
187
+ 支持尺寸 `32 / 64 / 128` 三档。
188
+
189
+ ### R6.4 图标缓存
190
+
191
+ - 缓存目录:`~/.config/skillhelper/icon-cache/`(受 `SKILLHELPER_HOME` / `XDG_CONFIG_HOME` 影响)
192
+ - 文件名:`{appSlug}-{size}.png`
193
+ - **失效策略**:源 `.app` 的 mtime 新于缓存 PNG 时重建
194
+ - 进程内维护 `brand → appPath` / `brand:size → pngPath` 内存索引,避免重复 `sips`
195
+
196
+ ### R6.5 图标优先级与降级链
197
+
198
+ ```
199
+ 1. frontmatter.icon 显式指定(app:/emoji:/url:) —— 最高优先级
200
+ 2. 真实应用图标(R6.1–R6.4 成功)
201
+ 3. emoji 映射表(BRAND_ICONS / CATEGORY_ICONS) —— 兜底降级
202
+ 4. 通用占位图标 —— 最终兜底
203
+ ```
204
+
205
+ 找不到 app 时静默降级,不报错。
206
+
207
+ ### R6.6 图标服务 API
208
+
209
+ ```
210
+ GET /api/icons/:brand?size=64
211
+ → 200 image/png (命中真实图标,cache-control: public, max-age=86400)
212
+ → 404 JSON { ok:false, fallback:true, brand } (无对应 app,前端回退 emoji)
213
+ ```
214
+
215
+ `size` 会被 clamp 到 `[32, 128]`,非法值回退 64。
216
+
217
+ ---
218
+
219
+ ## R7 左侧菜单栏分层显示 ⭐⭐⭐
220
+
221
+ 技能在左侧菜单栏中按 tier 分组显示,每个分组有独立的 icon 和分类标签。
222
+
223
+ ### R7.1 菜单栏分组结构
224
+
225
+ 前端左侧菜单栏采用**二级导航**结构:
226
+
227
+ ```
228
+ 左侧菜单栏
229
+
230
+ ├─ 📦 编辑器工具技能(Tier 1)
231
+ │ ├─ Hermes
232
+ │ ├─ Claude
233
+ │ ├─ Cursor
234
+ │ └─ ... (其他编辑器工具)
235
+
236
+ ├─ 👤 我的技能库(Tier 2)
237
+ │ ├─ skill-1
238
+ │ ├─ skill-2
239
+ │ └─ ...
240
+
241
+ └─ 🔍 其他技能(Tier 3)← 仅在 scanTier3=true 时显示
242
+ ├─ skill-x
243
+ ├─ skill-y
244
+ └─ ...
245
+ ```
246
+
247
+ ### R7.2 分组 Icon 设计
248
+
249
+ | 分组 | 显示名称 | 建议 Icon | 说明 |
250
+ |-----|---------|---------|------|
251
+ | **Tier 1** | 编辑器工具技能 | `📦` 或 `⚙️` | 代表工具集合、生态环境 |
252
+ | **Tier 2** | 我的技能库 | `👤` 或 `⭐` | 代表个人、用户私有 |
253
+ | **Tier 3** | 其他技能 | `🔍` 或 `🌐` | 代表外部、探索发现 |
254
+
255
+ **优先选择**:使用 emoji(跨平台一致),或从设计系统中选择对应的 SVG icon。
256
+
257
+ ### R7.3 展开/折叠行为
258
+
259
+ - **Tier 1 和 Tier 2**:默认展开(因为用户常用)
260
+ - **Tier 3**:默认折叠(降低视觉噪音,仅在需要时展开)
261
+ - 用户可手动点击分组 header 切换展开/折叠状态
262
+ - 展开/折叠状态持久化到本地存储(localStorage)
263
+
264
+ ### R7.4 Tier 1 内部结构
265
+
266
+ Tier 1(编辑器工具技能)在菜单栏中进一步按**编辑器品牌分组**:
267
+
268
+ ```
269
+ 编辑器工具技能(Tier 1)
270
+ ├─ 🔷 Hermes
271
+ │ ├─ skill-1
272
+ │ └─ skill-2
273
+ ├─ 🔹 Claude
274
+ │ ├─ skill-3
275
+ │ └─ skill-4
276
+ ├─ 🔶 Cursor
277
+ │ └─ skill-5
278
+ └─ ... (其他编辑器)
279
+ ```
280
+
281
+ 每个编辑器品牌使用 **R6 获取的真实应用 icon**(如 Cursor 的真实图标、VS Code 的真实图标)。
282
+
283
+ ### R7.5 技能计数 Badge
284
+
285
+ 菜单栏分组 header 显示该分组的技能计数:
286
+
287
+ ```
288
+ 编辑器工具技能(📦 125)
289
+ 我的技能库(👤 42)
290
+ 其他技能(🔍 18)
291
+ ```
292
+
293
+ 动态更新,基于 `ScanResult` 的 `tier1Count / tier2Count / tier3Count`。
294
+
295
+ ---
296
+
297
+ ## R8 扫描性能优化 ⭐
298
+
299
+ 对标 Pearcleaner 的「并发扫描 + 两阶段加载 + Spotlight 元数据」。
300
+
301
+ ### R8.1 两阶段加载
302
+
303
+ - **阶段 1(mini,快)**:返回轻量行(`id / name / description / category / brand / tier / iconUrl / iconFallback`),前端立即渲染列表。
304
+ - **阶段 2(full,补全)**:后台补齐 `tags / links / preview / frontmatter 全字段 / 时间戳 / pathHash`,前端合并升级。
305
+
306
+ 服务端通过 `stage=mini|full` 区分;前端先取 mini 快速渲染,再后台取 full 升级。图标 `<img loading="lazy">` 惰性加载。
307
+
308
+ ### R8.2 并发分块扫描
309
+
310
+ - `fast-glob` 拿到文件列表后,用有界并发池(默认 = CPU 核数,范围 `[4, 16]`)并发解析。
311
+ - 每个 worker 顺序读文件、解析 frontmatter,避免一次性打开过多文件句柄。
312
+ - 结果合并后按路径字典序排序(保证 R3 幂等)。
313
+
314
+ ### R8.3 Spotlight 元数据(可选)
315
+
316
+ - 文件时间戳优先用 `mdls -name kMDItemFSCreationDate -name kMDItemFSContentChangeDate`(批量,每批 50)。
317
+ - Spotlight 不可用时回退 `fs.stat` 的 mtime。
318
+ - 默认关闭,通过 `useSpotlight=1` 开启,失败静默回退。
319
+
320
+ ---
321
+
322
+ ## R9 路径去重算法 ⭐⭐
323
+
324
+ 确保第2、3层的技能不与第1层重复,防止同一技能在多个层级中出现。
325
+
326
+ ### R9.1 MD5 哈希计算
327
+
328
+ ```javascript
329
+ // 伪代码
330
+ const crypto = require('crypto');
331
+
332
+ function getPathHash(absolutePath) {
333
+ // 规范化路径:展开 ~ 为 $HOME,统一为绝对路径,规范化 / 符号
334
+ const normalized = path.resolve(path.expanduser(absolutePath));
335
+ // 计算 MD5 哈希
336
+ const hash = crypto.createHash('md5').update(normalized).digest('hex');
337
+ return hash;
338
+ }
339
+ ```
340
+
341
+ ### R9.2 去重流程
342
+
343
+ ```
344
+ Step 1: 扫描第1层(编辑器工具)
345
+ → 收集所有 pathHash,存入集合 tier1Hashes
346
+ → 返回所有第1层技能
347
+
348
+ Step 2: 扫描第2层(用户根目录)
349
+ → 对每个扫描到的技能,计算 pathHash
350
+ → 如果 pathHash ∈ tier1Hashes,则跳过(已在第1层存在)
351
+ → 否则,加入 tier2Hashes,返回该技能
352
+
353
+ Step 3: 扫描第3层(其他,仅当 scanTier3=true)
354
+ → 对每个扫描到的技能,计算 pathHash
355
+ → 如果 pathHash ∈ (tier1Hashes ∪ tier2Hashes),则跳过
356
+ → 否则,返回该技能
357
+
358
+ Return: [tier1Skills, tier2Skills, tier3Skills] + { tier1Hashes, tier2Hashes }
359
+ ```
360
+
361
+ ### R9.3 缓存策略
362
+
363
+ - 扫描结果中包含 `pathHashes: { tier1: Set<string>, tier2: Set<string> }`,方便前端或后续请求快速判断。
364
+ - 内存中维护 LRU 缓存(最多 1000 条),避免重复计算同一路径的哈希值。
365
+
366
+ ---
367
+
368
+ ## R10 扫描规则 Registry 化与置信度分级 ⭐⭐
369
+
370
+ > v0.4 对标 cockpit-tools 报告第一、二建议落地。把散落在各 tier adapter 的检测规则抽成显式 `ScannerDescriptor` registry,并引入四级检测置信度。性质是工程加固,不改扫描产出。
371
+
372
+ ### R10.1 ScannerDescriptor registry
373
+
374
+ 检测规则(目录候选、glob 模式、ignore、deep、品牌 key)显式登记成 `ScannerDescriptor`,集中在 `packages/scanner/src/core/descriptor.mjs` 定义类型、`core/registry.mjs` 派生注册。
375
+
376
+ ```
377
+ ScannerDescriptor {
378
+ id: 'tier1:claude' // 唯一标识
379
+ tier: 'tier-1' // 所属层级
380
+ brand: 'claude' // 品牌 key(用于图标映射)
381
+ label: 'Claude' // 人类可读名称
382
+ editorName: 'Claude' // SkillItem.editor 字段值
383
+ detect: {
384
+ globalPath: '~/.claude/skills'
385
+ projectPath: '.claude/skills'
386
+ globPatterns: ['**/SKILL.md', ...] // 相对模式,adapter 拼 basePath
387
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**']
388
+ deep: 10
389
+ }
390
+ }
391
+ ```
392
+
393
+ registry 从 `config/editor-tiers.mjs` 现有配置派生,配置单一来源不变。tier1/2/3 adapter 改为从 registry 读 descriptor 的 glob/ignore/deep,替代硬编码。
394
+
395
+ 注册总数:26 个 descriptor(22 tier1 + 1 tier2 + 3 tier3)。
396
+
397
+ ### R10.2 检测置信度四级
398
+
399
+ 对标 cockpit-tools 报告第二建议,把"只发现目录"与"已确认可解析完整技能"区分开,避免 UI 把候选目录误显示成已确认实例。
400
+
401
+ | 级别 | 含义 | 触发条件 |
402
+ |------|------|----------|
403
+ | **L1** | 只发现目录 | `detect.globalPath` 存在但未命中技能文件 |
404
+ | **L2** | 命中技能文件 | glob 匹配到至少 1 个 SKILL.md |
405
+ | **L3** | 解析出有效技能 | frontmatter 解析成功,name 非空 |
406
+ | **L4** | 完整元数据 | name + description 齐全 |
407
+
408
+ `confidence` 作为可选字段注入 SkillItem(向后兼容,不破坏 IR)。目录不存在时不注入(返回 null)。
409
+
410
+ ### R10.3 渐进迁移原则
411
+
412
+ registry 与现有 `editor-tiers.mjs` 配置并存,adapter 逐个迁移。descriptor 只管"去哪扫、用什么 glob",不管"怎么解析"(解析逻辑仍由各 adapter 自持,避免行为变化)。
413
+
414
+ ---
415
+
416
+ ## 系统依赖与兼容性
417
+
418
+ - 依赖 macOS 内置命令:`sips` / `mdfind` / `plutil` / `mdls`(均无需额外安装)。
419
+ - 非 macOS 环境:图标提取直接按 R6.5 降级为 emoji,扫描功能不受影响。
420
+ - `iconUrl` / `iconFallback` 为可选字段,旧客户端不受影响;无真实图标时行为等同 emoji 模式。
421
+
422
+ ---
423
+
424
+ ## 附录:v3.0 规则原文(存档)
425
+
426
+ 改版前 v3.0 的 R1–R5 定义,语义已升级为 v4.0:
427
+
428
+ - **R1 扫描根目录**:支持多个技能根目录,`~` 展开为 `$HOME`,每个 root 必须存在(否则 400),glob 返回精确且无重复的文件列表。
429
+ - **R2 目录名称大小写不敏感**:`Skills` / `SKILLS` / `skills` / `sKiLLs` 均可匹配,按优先级返回第一个存在的目录;文件名 `SKILL.md` 大小写敏感。
430
+ - **R3 文件匹配与递归扫描**:`fast-glob` 递归匹配 `**/SKILL.md`,每个文件仅读一次,返回按路径字典序排序。
431
+ - **R4 Frontmatter 解析**:frontmatter 以 `---` 开始和结束;YAML 无效时 400;`name`/`description` 必填。
432
+ - **R5 返回数据格式**:`SkillItem { id, name, description, filePath, category?, brand?, tier?, tags?, createdAt?, updatedAt? }`;`ScanResult { skills, total, limit, offset, roots, scannedAt, duration }`。
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "skillhelper",
3
+ "version": "0.4.2",
4
+ "private": false,
5
+ "description": "SkillHelper:本地技能 / 插件 / MCP 聚合中心。",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/aquarkgn/SkillsHelper#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/aquarkgn/SkillsHelper.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/aquarkgn/SkillsHelper/issues"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=20.0.0"
18
+ },
19
+ "bin": {
20
+ "skillhelper": "bin/skillhelper.mjs"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "build/",
25
+ "config/",
26
+ "docs/",
27
+ "packages/scanner/package.json",
28
+ "packages/scanner/src/",
29
+ "packages/server/package.json",
30
+ "packages/server/src/",
31
+ "packages/web/dist/",
32
+ "packages/web/public/",
33
+ "scripts/install-and-sync.sh",
34
+ "scripts/sync-skills.sh",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "start": "node bin/skillhelper.mjs start",
40
+ "stop": "node bin/skillhelper.mjs stop",
41
+ "restart": "node bin/skillhelper.mjs restart",
42
+ "scan": "node bin/skillhelper.mjs scan",
43
+ "stats": "node bin/skillhelper.mjs stats",
44
+ "duplicates": "node bin/skillhelper.mjs duplicates",
45
+ "init": "node bin/skillhelper.mjs init",
46
+ "purge": "node bin/skillhelper.mjs purge",
47
+ "uninstall": "node bin/skillhelper.mjs uninstall",
48
+ "dev": "node bin/skillhelper.mjs restart && cd packages/web && npm run dev",
49
+ "dev-bg": "bash scripts/start-dev.sh",
50
+ "sync": "node bin/skillhelper.mjs sync",
51
+ "build:web": "node build/build-web.mjs",
52
+ "build:site": "node build/build-site.mjs",
53
+ "dev:site": "node build/build-site.mjs --dev",
54
+ "typecheck:web": "tsc --noEmit -p packages/web/tsconfig.json",
55
+ "typecheck:site": "tsc --noEmit -p packages/site/tsconfig.json",
56
+ "test:web": "vitest run --root packages/web",
57
+ "prepack": "node build/build-web.mjs",
58
+ "pack:smoke": "node build/pack-smoke.mjs",
59
+ "test": "node --test packages/scanner/test/*.test.mjs packages/server/test/*.test.mjs",
60
+ "verify": "node build/verify.mjs",
61
+ "release": "npm version patch && git push"
62
+ },
63
+ "dependencies": {
64
+ "chokidar": "^4.0.3",
65
+ "fast-glob": "^3.3.2",
66
+ "fastify": "^5.0.0",
67
+ "google-translate-api-x": "^10.7.3",
68
+ "undici": "^6.27.0",
69
+ "yaml": "^2.6.0"
70
+ },
71
+ "devDependencies": {
72
+ "@testing-library/dom": "^10.4.1",
73
+ "@testing-library/jest-dom": "^6.9.1",
74
+ "@testing-library/react": "^16.3.2",
75
+ "@types/markdown-it": "^14.1.2",
76
+ "@types/react": "^18.3.12",
77
+ "@types/react-dom": "^18.3.1",
78
+ "@vitejs/plugin-react": "^4.3.4",
79
+ "autoprefixer": "^10.4.20",
80
+ "class-variance-authority": "^0.7.1",
81
+ "clsx": "^2.1.1",
82
+ "fuse.js": "^7.0.0",
83
+ "jsdom": "^29.1.1",
84
+ "lucide-react": "^0.460.0",
85
+ "markdown-it": "^14.1.0",
86
+ "postcss": "^8.4.49",
87
+ "react": "^18.3.1",
88
+ "react-dom": "^18.3.1",
89
+ "tailwind-merge": "^2.5.5",
90
+ "tailwindcss": "^3.4.15",
91
+ "typescript": "^5.6.3",
92
+ "vite": "^6.0.0",
93
+ "vitest": "^2.1.9"
94
+ }
95
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@skillhelper/scanner",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./types": "./src/types.d.ts"
10
+ },
11
+ "dependencies": {
12
+ "yaml": "^2.6.0",
13
+ "fast-glob": "^3.3.2"
14
+ }
15
+ }
@@ -0,0 +1,134 @@
1
+ // Claude Code sub-agents adapter — scans ~/.claude/agents/*.md and ./.claude/agents/*.md
2
+ // Claude Code stores subagent definitions as Markdown with YAML frontmatter
3
+ // (fields: name, description, tools, model). See claude code docs.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import fg from 'fast-glob';
8
+ import YAML from 'yaml';
9
+ import {
10
+ classifyRoot,
11
+ deriveDescription,
12
+ expandRoots,
13
+ makePreview,
14
+ readFileSafe,
15
+ sha1Id,
16
+ } from '../utils.mjs';
17
+
18
+ const AGENT_GLOBS = ['**/agents/**/*.md', '**/agents/*.md'];
19
+ const IGNORE = ['**/node_modules/**', '**/.git/**', '**/dist/**'];
20
+ const MAX_DEPTH = 4;
21
+
22
+ export async function scanClaudeAgents(opts) {
23
+ const {
24
+ source = 'claude-agents',
25
+ editor = 'Claude Code',
26
+ roots = [],
27
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
28
+ } = opts;
29
+
30
+ const expanded = await expandRoots(roots || []);
31
+ const files = new Set();
32
+ for (const root of expanded) {
33
+ if (!fs.existsSync(root)) continue;
34
+ const found = await fg(AGENT_GLOBS, {
35
+ cwd: root,
36
+ absolute: true,
37
+ onlyFiles: true,
38
+ dot: true,
39
+ followSymbolicLinks: false,
40
+ deep: MAX_DEPTH,
41
+ ignore: IGNORE,
42
+ });
43
+ for (const f of found) files.add(f);
44
+ }
45
+
46
+ const items = [];
47
+ for (const abs of files) {
48
+ items.push(parseAgentFile({ abs, source, editor, limits }));
49
+ if (items.length >= limits.maxFiles) break;
50
+ }
51
+ return { items, stats: { source, available: true, files: items.length, roots: expanded } };
52
+ }
53
+
54
+ function parseAgentFile({ abs, source, editor, limits }) {
55
+ const rel = path.basename(abs, path.extname(abs));
56
+ const id = sha1Id(abs);
57
+ const read = readFileSafe(abs, limits.maxFileBytes);
58
+ const rootKind = classifyRoot(abs);
59
+
60
+ if (read.error || read.truncated) {
61
+ const item = {
62
+ id,
63
+ kind: 'skill',
64
+ source,
65
+ editor,
66
+ name: rel,
67
+ title: rel,
68
+ description: read.error || `agent file > ${limits.maxFileBytes} bytes, skipped`,
69
+ paths: { abs, rel, rootKind },
70
+ preview: '',
71
+ raw: '',
72
+ updatedAt: new Date(read.mtime || 0).toISOString(),
73
+ };
74
+ item.brand = 'claude';
75
+ item.editorBrand = 'claude';
76
+ if (read.error) item.parseError = read.error;
77
+ return item;
78
+ }
79
+
80
+ let frontmatter = {};
81
+ let body = read.text;
82
+ let parseError = undefined;
83
+ if (body.startsWith('---\n')) {
84
+ const endIdx = body.indexOf('\n---\n', 4);
85
+ if (endIdx > 0) {
86
+ try {
87
+ frontmatter = YAML.parse(body.slice(4, endIdx)) || {};
88
+ } catch (e) {
89
+ parseError = `Frontmatter parse error: ${e.message}`;
90
+ }
91
+ body = body.slice(endIdx + 5);
92
+ }
93
+ }
94
+
95
+ const name = String(frontmatter.name || rel).trim();
96
+ const description = typeof frontmatter.description === 'string'
97
+ ? frontmatter.description.trim()
98
+ : deriveDescription(frontmatter, body);
99
+
100
+ const tools = Array.isArray(frontmatter.tools)
101
+ ? frontmatter.tools.map((t) => String(t).trim()).filter(Boolean)
102
+ : [];
103
+ const model = typeof frontmatter.model === 'string' ? frontmatter.model.trim() : undefined;
104
+
105
+ const rawHeader = [
106
+ `# ${name}`,
107
+ description ? `\n${description}` : '',
108
+ tools.length ? `\n\n## 工具 / tools\n\n${tools.map((t) => `- ${t}`).join('\n')}` : '',
109
+ model ? `\n\n## 模型 / model\n\n\`${model}\`` : '',
110
+ body ? `\n\n## 系统提示\n\n${body.trim()}` : '',
111
+ ].join('');
112
+
113
+ const item = {
114
+ id,
115
+ kind: 'skill',
116
+ source,
117
+ editor,
118
+ name,
119
+ title: name,
120
+ description,
121
+ paths: { abs, rel, rootKind },
122
+ preview: makePreview(body || description, 600),
123
+ raw: rawHeader,
124
+ tags: ['agent', 'subagent', ...(tools.length ? ['tools'] : [])],
125
+ links: typeof frontmatter.homepage === 'string'
126
+ ? [{ label: 'homepage', url: frontmatter.homepage }]
127
+ : undefined,
128
+ updatedAt: new Date(read.mtime || 0).toISOString(),
129
+ };
130
+ item.brand = 'claude';
131
+ item.editorBrand = 'claude';
132
+ if (parseError) item.parseError = parseError;
133
+ return item;
134
+ }