oh-skillhub 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/docs/superpowers/specs/2026-05-25-skill-cleaner-design.md +411 -0
- package/package.json +1 -1
- package/src/cleaner.js +239 -0
- package/src/cli.js +157 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ npx oh-skillhub@latest
|
|
|
9
9
|
npx oh-skillhub list
|
|
10
10
|
npx oh-skillhub install --domain arkui --agent codex
|
|
11
11
|
npx oh-skillhub install --domain arkui --agent all --scope user
|
|
12
|
+
npx oh-skillhub clean --agent claude --scope user --dry-run
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
Running without arguments starts a TUI selector. Choose the install target first (`Codex`, `Claude`, `OpenCode`, or `All`), then choose the skill groups to install.
|
|
@@ -35,6 +36,8 @@ printf "1\n3 9\n" | npx oh-skillhub@latest
|
|
|
35
36
|
- Support interactive target selection for `Codex`, `Claude`, `OpenCode`, and `All`.
|
|
36
37
|
- Support `--scope user|project`.
|
|
37
38
|
- Support `--dry-run` install plans.
|
|
39
|
+
- Scan installed skills and clean selected skills with `clean`.
|
|
40
|
+
- Move cleaned skills to `.oh-skillhub/trash` by default, with `--purge` for permanent deletion.
|
|
38
41
|
- Run a TUI matching the `skills/common/*` and `skills/domain/*` repository layout.
|
|
39
42
|
- Keep anonymous telemetry events in a local retry queue.
|
|
40
43
|
|
|
@@ -45,6 +48,7 @@ The installer uses sparse Git checkout so it downloads only the selected skill d
|
|
|
45
48
|
```bash
|
|
46
49
|
oh-skillhub list [--domain <name>] [--stage <name>]
|
|
47
50
|
oh-skillhub install [skill...] [--domain <name>] [--preset <name>] [--agent codex|claude|opencode|all]
|
|
51
|
+
oh-skillhub clean [skill...] [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--purge]
|
|
48
52
|
oh-skillhub doctor [--agent codex|claude|opencode|all]
|
|
49
53
|
oh-skillhub telemetry status
|
|
50
54
|
```
|
|
@@ -63,6 +67,8 @@ Examples:
|
|
|
63
67
|
npx oh-skillhub install --domain arkui --agent claude --scope user
|
|
64
68
|
npx oh-skillhub install --preset app-dev --agent opencode --scope project
|
|
65
69
|
npx oh-skillhub install --domain security --agent all --dry-run
|
|
70
|
+
npx oh-skillhub clean --agent claude --scope user --dry-run
|
|
71
|
+
npx oh-skillhub clean --agent claude --scope user ohos-test-capi-xts-generation
|
|
66
72
|
```
|
|
67
73
|
|
|
68
74
|
## Telemetry
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# Skill 扫描与清理能力设计方案
|
|
2
|
+
|
|
3
|
+
## 1. 背景与目标
|
|
4
|
+
|
|
5
|
+
`oh-skillhub` 当前已经支持通过 `npx oh-skillhub@latest` 为 Codex、Claude Code、OpenCode 安装 OpenHarmony skills。随着安装次数增加,用户会遇到几个管理问题:
|
|
6
|
+
|
|
7
|
+
- 不知道当前 agent 下已经安装了哪些 skills。
|
|
8
|
+
- 不知道哪些 skills 是 `oh-skillhub` 安装的,哪些是用户手动放进去的。
|
|
9
|
+
- 旧版安装过的残缺 skill、过期 skill 或不再使用的 skill 需要清理。
|
|
10
|
+
- 清理时需要避免误删用户自定义 skill。
|
|
11
|
+
|
|
12
|
+
本方案设计一个 **Skill 管理/清理模式**,用于扫描当前 agent 已有的 skills,并在用户确认后清理选中的 skill。
|
|
13
|
+
|
|
14
|
+
目标:
|
|
15
|
+
|
|
16
|
+
- 支持扫描 Codex、Claude Code、OpenCode 的 user/project skill 目录。
|
|
17
|
+
- 在 TUI 中展示当前已安装 skill 列表。
|
|
18
|
+
- 区分 `managed`、`unmanaged`、`broken` 三类状态。
|
|
19
|
+
- 支持用户多选后清理。
|
|
20
|
+
- 默认采用安全删除,先移动到 `.oh-skillhub/trash`。
|
|
21
|
+
- 支持 `--dry-run` 预览和 `--purge` 永久删除。
|
|
22
|
+
- 清理后同步更新 `.oh-skillhub/installed.json`。
|
|
23
|
+
|
|
24
|
+
非目标:
|
|
25
|
+
|
|
26
|
+
- 第一版不实现 restore 恢复命令。
|
|
27
|
+
- 第一版不做远端 manifest 实时刷新。
|
|
28
|
+
- 第一版不自动判断“当前正在使用的 agent”。
|
|
29
|
+
- 第一版不清理 agent 自身配置文件,只处理 skill 目录。
|
|
30
|
+
|
|
31
|
+
## 2. 命令设计
|
|
32
|
+
|
|
33
|
+
新增命令:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx oh-skillhub@latest clean
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
支持参数:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx oh-skillhub clean [--agent codex|claude|opencode|all] [--scope user|project]
|
|
43
|
+
npx oh-skillhub clean --agent claude --scope user
|
|
44
|
+
npx oh-skillhub clean --agent opencode --scope project --dry-run
|
|
45
|
+
npx oh-skillhub clean --agent codex --scope user --purge
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
参数含义:
|
|
49
|
+
|
|
50
|
+
- `--agent`:指定扫描目标。默认进入 TUI 选择。
|
|
51
|
+
- `--scope`:扫描 user 或 project scope。默认 `user`。
|
|
52
|
+
- `--dry-run`:只展示将清理的内容,不执行移动或删除。
|
|
53
|
+
- `--purge`:永久删除 skill 目录,不移动到 trash。
|
|
54
|
+
- `--target`:复用现有安装逻辑中的自定义目录能力,作为高级参数保留。
|
|
55
|
+
|
|
56
|
+
## 3. Agent 目录规则
|
|
57
|
+
|
|
58
|
+
沿用现有 `resolveAgentTargets()` 规则:
|
|
59
|
+
|
|
60
|
+
| Agent | User scope | Project scope |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| Codex | `$CODEX_HOME/skills`,否则 `~/.codex/skills` | `<cwd>/.codex/skills` |
|
|
63
|
+
| Claude Code | `~/.claude/skills` | `<cwd>/.claude/skills` |
|
|
64
|
+
| OpenCode | `~/.config/opencode/skill` | `<cwd>/.opencode/skill` |
|
|
65
|
+
|
|
66
|
+
当 `--agent all` 时,扫描三个 agent 的目标目录,并按 agent 分组展示。
|
|
67
|
+
|
|
68
|
+
## 4. 扫描规则
|
|
69
|
+
|
|
70
|
+
扫描目标目录的一级子目录。
|
|
71
|
+
|
|
72
|
+
识别为 skill 的条件:
|
|
73
|
+
|
|
74
|
+
- 子目录下存在 `SKILL.md`。
|
|
75
|
+
- 子目录名不是 `.oh-skillhub`。
|
|
76
|
+
- 子目录不是 trash、cache、临时目录。
|
|
77
|
+
|
|
78
|
+
扫描结果字段:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"name": "ohos-test-capi-xts-generation",
|
|
83
|
+
"agent": "claude",
|
|
84
|
+
"scope": "user",
|
|
85
|
+
"status": "managed",
|
|
86
|
+
"path": "C:\\Users\\Geralt\\.claude\\skills\\ohos-test-capi-xts-generation",
|
|
87
|
+
"sourcePath": "skills/common/testing/ohos-test-capi-xts-generation",
|
|
88
|
+
"domain": "capi",
|
|
89
|
+
"stage": "testing",
|
|
90
|
+
"description": "Use this skill when generating C API XTS tests."
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
状态分类:
|
|
95
|
+
|
|
96
|
+
- `managed`:存在于 `.oh-skillhub/installed.json` 中,并且目录下存在 `SKILL.md`。
|
|
97
|
+
- `unmanaged`:目录下存在 `SKILL.md`,但不在 `.oh-skillhub/installed.json` 中。
|
|
98
|
+
- `broken`:安装记录中存在,但目录不存在或缺失 `SKILL.md`。
|
|
99
|
+
|
|
100
|
+
对于 `unmanaged` skill,优先从 `SKILL.md` front matter 解析 `name`、`description`、`metadata.domain`、`metadata.stage`。解析失败时使用目录名,并将 domain/stage 标记为 `unknown`。
|
|
101
|
+
|
|
102
|
+
## 5. TUI 交互设计
|
|
103
|
+
|
|
104
|
+
### 5.1 入口流程
|
|
105
|
+
|
|
106
|
+
当用户运行:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx oh-skillhub@latest clean
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
交互步骤:
|
|
113
|
+
|
|
114
|
+
1. 选择安装目标:`Codex / Claude / OpenCode / All`
|
|
115
|
+
2. 选择 scope:`user / project`
|
|
116
|
+
3. 扫描目标目录。
|
|
117
|
+
4. 展示可清理 skill 列表。
|
|
118
|
+
5. 用户用空格选择要清理的 skill。
|
|
119
|
+
6. Enter 后进入二次确认。
|
|
120
|
+
7. 确认后执行清理。
|
|
121
|
+
|
|
122
|
+
### 5.2 列表界面
|
|
123
|
+
|
|
124
|
+
示例:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
OH SkillHub
|
|
128
|
+
Clean Installed Skills
|
|
129
|
+
|
|
130
|
+
Target
|
|
131
|
+
Agent: claude
|
|
132
|
+
Scope: user
|
|
133
|
+
Path: C:\Users\Geralt\.claude\skills
|
|
134
|
+
|
|
135
|
+
Detected skills
|
|
136
|
+
> [ ] ohos-test-arkts-xts-generation managed common/testing
|
|
137
|
+
[ ] ohos-test-capi-xts-generation managed common/testing
|
|
138
|
+
[ ] ohos-test-fuzz-generation managed common/testing
|
|
139
|
+
[ ] my-local-skill unmanaged unknown
|
|
140
|
+
|
|
141
|
+
Space: select Enter: clean a: select all m: managed only Esc: cancel
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
交互按键:
|
|
145
|
+
|
|
146
|
+
- `Up/Down` 或 `j/k`:移动光标。
|
|
147
|
+
- `Space`:选择或取消选择。
|
|
148
|
+
- `a`:全选或取消全选。
|
|
149
|
+
- `m`:只选择 managed skills。
|
|
150
|
+
- `Enter`:进入确认。
|
|
151
|
+
- `Esc` 或 `Ctrl+C`:取消。
|
|
152
|
+
|
|
153
|
+
### 5.3 二次确认
|
|
154
|
+
|
|
155
|
+
示例:
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
Remove 3 selected skill(s) from claude:user?
|
|
159
|
+
|
|
160
|
+
The selected directories will be moved to:
|
|
161
|
+
C:\Users\Geralt\.claude\skills\.oh-skillhub\trash\20260525-213000
|
|
162
|
+
|
|
163
|
+
Enter: confirm Esc: cancel
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
如果使用 `--purge`:
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
Permanently delete 3 selected skill(s) from claude:user?
|
|
170
|
+
This cannot be undone.
|
|
171
|
+
|
|
172
|
+
Enter: confirm Esc: cancel
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## 6. 非 TTY 与命令行模式
|
|
176
|
+
|
|
177
|
+
第一版支持 TUI 为主,同时保留非 TTY 能力。
|
|
178
|
+
|
|
179
|
+
建议增加:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npx oh-skillhub clean --agent claude --scope user --dry-run
|
|
183
|
+
npx oh-skillhub clean --agent claude --scope user ohos-test-capi-xts-generation
|
|
184
|
+
npx oh-skillhub clean --agent claude --scope user ohos-test-capi-xts-generation --purge
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
如果命令行直接传入 skill 名称:
|
|
188
|
+
|
|
189
|
+
- 不进入选择列表。
|
|
190
|
+
- 仍然需要确认,除非未来增加 `--yes`。
|
|
191
|
+
- `--dry-run` 时只输出计划。
|
|
192
|
+
|
|
193
|
+
第一版不建议加入 `--yes`,避免误删。
|
|
194
|
+
|
|
195
|
+
## 7. 清理策略
|
|
196
|
+
|
|
197
|
+
默认清理方式:移动到 trash。
|
|
198
|
+
|
|
199
|
+
trash 目录:
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
<skill-root>/.oh-skillhub/trash/<timestamp>/<skill-name>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
例如:
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
C:\Users\Geralt\.claude\skills\.oh-skillhub\trash\20260525-213000\ohos-test-capi-xts-generation
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
执行清理:
|
|
212
|
+
|
|
213
|
+
1. 创建 trash 目录。
|
|
214
|
+
2. 将选中的 skill 目录移动到 trash。
|
|
215
|
+
3. 更新 `.oh-skillhub/installed.json`。
|
|
216
|
+
4. 输出清理结果。
|
|
217
|
+
|
|
218
|
+
如果目标 trash 已存在同名目录:
|
|
219
|
+
|
|
220
|
+
- 追加短随机后缀,例如 `ohos-test-capi-xts-generation-7f3a`。
|
|
221
|
+
- 不覆盖已有 trash 内容。
|
|
222
|
+
|
|
223
|
+
永久删除:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
npx oh-skillhub clean --purge
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
执行时直接递归删除 skill 目录,不进入 trash。
|
|
230
|
+
|
|
231
|
+
## 8. 安装记录更新
|
|
232
|
+
|
|
233
|
+
当前安装记录:
|
|
234
|
+
|
|
235
|
+
```text
|
|
236
|
+
<skill-root>/.oh-skillhub/installed.json
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
清理 managed skill 时:
|
|
240
|
+
|
|
241
|
+
- 从 `skills` map 中删除该 skill。
|
|
242
|
+
- 追加 `history` 记录。
|
|
243
|
+
|
|
244
|
+
建议结构:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"installedAt": "2026-05-25T12:00:00.000Z",
|
|
249
|
+
"source": "https://gitcode.com/openharmonyinsight/openharmony-skills",
|
|
250
|
+
"ref": "release",
|
|
251
|
+
"skills": {},
|
|
252
|
+
"history": [
|
|
253
|
+
{
|
|
254
|
+
"event": "skill_removed",
|
|
255
|
+
"removedAt": "2026-05-25T13:20:00.000Z",
|
|
256
|
+
"skill": "ohos-test-capi-xts-generation",
|
|
257
|
+
"agent": "claude",
|
|
258
|
+
"scope": "user",
|
|
259
|
+
"action": "trash",
|
|
260
|
+
"from": "C:\\Users\\Geralt\\.claude\\skills\\ohos-test-capi-xts-generation",
|
|
261
|
+
"to": "C:\\Users\\Geralt\\.claude\\skills\\.oh-skillhub\\trash\\20260525-213000\\ohos-test-capi-xts-generation"
|
|
262
|
+
}
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
清理 unmanaged skill 时:
|
|
268
|
+
|
|
269
|
+
- 不从 `skills` map 删除。
|
|
270
|
+
- 仍然追加 `history` 记录,标记 `managed: false`。
|
|
271
|
+
|
|
272
|
+
## 9. 输出设计
|
|
273
|
+
|
|
274
|
+
清理前 dry-run:
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
Clean dry run
|
|
278
|
+
TRASH ohos-test-capi-xts-generation -> claude:user C:\Users\Geralt\.claude\skills\.oh-skillhub\trash\...
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
清理完成:
|
|
282
|
+
|
|
283
|
+
```text
|
|
284
|
+
Clean summary
|
|
285
|
+
TRASH ohos-test-capi-xts-generation -> claude:user C:\Users\Geralt\.claude\skills\.oh-skillhub\trash\...
|
|
286
|
+
|
|
287
|
+
Done. Removed 1 skill(s) from claude:user.
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
未发现 skill:
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
No skills found under claude:user.
|
|
294
|
+
Path: C:\Users\Geralt\.claude\skills
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
用户取消:
|
|
298
|
+
|
|
299
|
+
```text
|
|
300
|
+
Cancelled. No skills were removed.
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## 10. 模块设计
|
|
304
|
+
|
|
305
|
+
新增模块:
|
|
306
|
+
|
|
307
|
+
```text
|
|
308
|
+
src/cleaner.js
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
职责:
|
|
312
|
+
|
|
313
|
+
- `scanInstalledSkills(targets, options)`
|
|
314
|
+
- `planClean(skills, options)`
|
|
315
|
+
- `applyCleanPlan(plan, options)`
|
|
316
|
+
- `readInstallRecord(targetDir)`
|
|
317
|
+
- `writeInstallRecord(targetDir, record)`
|
|
318
|
+
- `parseSkillMetadata(skillDir)`
|
|
319
|
+
|
|
320
|
+
CLI 侧新增:
|
|
321
|
+
|
|
322
|
+
- `clean` 命令解析。
|
|
323
|
+
- TUI agent/scope 选择。
|
|
324
|
+
- 清理列表渲染。
|
|
325
|
+
- 二次确认渲染。
|
|
326
|
+
|
|
327
|
+
测试:
|
|
328
|
+
|
|
329
|
+
```text
|
|
330
|
+
test/cleaner.test.js
|
|
331
|
+
test/cli-clean.test.js
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## 11. 安全边界
|
|
335
|
+
|
|
336
|
+
清理能力涉及删除文件,需要更保守:
|
|
337
|
+
|
|
338
|
+
- 只允许删除 target skill root 下的一级 skill 目录。
|
|
339
|
+
- 禁止删除 `.oh-skillhub`。
|
|
340
|
+
- 禁止删除 target root 本身。
|
|
341
|
+
- 删除前必须 resolve path,并确认目标路径在 skill root 内。
|
|
342
|
+
- 默认移动到 trash,不永久删除。
|
|
343
|
+
- 永久删除必须显式传 `--purge`。
|
|
344
|
+
- TUI 下必须二次确认。
|
|
345
|
+
- `--agent all` 时按 agent 分组确认,避免用户看错目标。
|
|
346
|
+
|
|
347
|
+
## 12. 推荐第一版范围
|
|
348
|
+
|
|
349
|
+
第一版实现:
|
|
350
|
+
|
|
351
|
+
- `clean` 命令。
|
|
352
|
+
- TUI 选择 agent。
|
|
353
|
+
- TUI 选择 user/project scope。
|
|
354
|
+
- 扫描 existing skills。
|
|
355
|
+
- 标记 managed/unmanaged/broken。
|
|
356
|
+
- 多选清理。
|
|
357
|
+
- 二次确认。
|
|
358
|
+
- 默认 trash。
|
|
359
|
+
- `--dry-run`。
|
|
360
|
+
- `--purge`。
|
|
361
|
+
- 更新 installed.json。
|
|
362
|
+
|
|
363
|
+
第一版暂不实现:
|
|
364
|
+
|
|
365
|
+
- `restore`。
|
|
366
|
+
- `--yes`。
|
|
367
|
+
- 按 domain/stage 过滤。
|
|
368
|
+
- 远端 manifest 对照检查。
|
|
369
|
+
- trash 自动过期清理。
|
|
370
|
+
|
|
371
|
+
## 13. 后续扩展
|
|
372
|
+
|
|
373
|
+
后续可以增加:
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
npx oh-skillhub restore
|
|
377
|
+
npx oh-skillhub clean --managed-only
|
|
378
|
+
npx oh-skillhub clean --domain testing
|
|
379
|
+
npx oh-skillhub clean --older-than 30d
|
|
380
|
+
npx oh-skillhub clean --trash-status
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
也可以把扫描能力抽象为:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
npx oh-skillhub status
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
用于只查看当前 agent 已安装 skills,而不进入清理流程。
|
|
390
|
+
|
|
391
|
+
## 14. 实施建议
|
|
392
|
+
|
|
393
|
+
建议按以下顺序实现:
|
|
394
|
+
|
|
395
|
+
1. 新增 `src/cleaner.js`,先完成扫描和计划生成。
|
|
396
|
+
2. 增加 `clean --dry-run`,验证非破坏性输出。
|
|
397
|
+
3. 增加 trash 移动和 installed.json 更新。
|
|
398
|
+
4. 增加 TUI 列表选择。
|
|
399
|
+
5. 增加二次确认。
|
|
400
|
+
6. 增加 `--purge`。
|
|
401
|
+
7. 增加真实 Codex/Claude/OpenCode 路径烟测。
|
|
402
|
+
|
|
403
|
+
验收标准:
|
|
404
|
+
|
|
405
|
+
- 能扫描出当前 agent 目录下所有含 `SKILL.md` 的 skill。
|
|
406
|
+
- 能区分 managed/unmanaged/broken。
|
|
407
|
+
- 默认清理后 skill 进入 trash。
|
|
408
|
+
- `--purge` 才会永久删除。
|
|
409
|
+
- 清理 managed skill 后 installed.json 中对应记录被删除。
|
|
410
|
+
- TUI 取消时不改动任何文件。
|
|
411
|
+
- 全量测试通过。
|
package/package.json
CHANGED
package/src/cleaner.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
function scanInstalledSkills(targets) {
|
|
5
|
+
const results = [];
|
|
6
|
+
for (const target of targets) {
|
|
7
|
+
const record = readInstallRecord(target.dir);
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
if (fs.existsSync(target.dir)) {
|
|
10
|
+
for (const entry of fs.readdirSync(target.dir, { withFileTypes: true })) {
|
|
11
|
+
if (!entry.isDirectory() || shouldIgnoreEntry(entry.name)) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const skillDir = path.join(target.dir, entry.name);
|
|
15
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
16
|
+
if (!fs.existsSync(skillPath)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const metadata = parseSkillMetadata(skillDir);
|
|
20
|
+
const managed = Boolean(record.skills[entry.name] && record.skills[entry.name].agent === target.agent);
|
|
21
|
+
results.push({
|
|
22
|
+
name: metadata.name || entry.name,
|
|
23
|
+
directoryName: entry.name,
|
|
24
|
+
description: metadata.description || "",
|
|
25
|
+
domain: metadata.domain || "unknown",
|
|
26
|
+
stage: metadata.stage || "unknown",
|
|
27
|
+
status: managed ? "managed" : "unmanaged",
|
|
28
|
+
agent: target.agent,
|
|
29
|
+
scope: target.scope,
|
|
30
|
+
rootDir: target.dir,
|
|
31
|
+
path: skillDir,
|
|
32
|
+
sourcePath: record.skills[entry.name] ? record.skills[entry.name].sourcePath || null : null,
|
|
33
|
+
});
|
|
34
|
+
seen.add(entry.name);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const [name, item] of Object.entries(record.skills)) {
|
|
38
|
+
if (item.agent !== target.agent || seen.has(name)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
results.push({
|
|
42
|
+
name,
|
|
43
|
+
directoryName: name,
|
|
44
|
+
description: "",
|
|
45
|
+
domain: "unknown",
|
|
46
|
+
stage: "unknown",
|
|
47
|
+
status: "broken",
|
|
48
|
+
agent: target.agent,
|
|
49
|
+
scope: target.scope,
|
|
50
|
+
rootDir: target.dir,
|
|
51
|
+
path: path.join(target.dir, name),
|
|
52
|
+
sourcePath: item.sourcePath || null,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return results.sort((left, right) => {
|
|
57
|
+
const agent = left.agent.localeCompare(right.agent);
|
|
58
|
+
if (agent) return agent;
|
|
59
|
+
return left.name.localeCompare(right.name);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function planClean(skills, options = {}) {
|
|
64
|
+
const mode = options.mode === "purge" ? "purge" : "trash";
|
|
65
|
+
const timestamp = options.timestamp || timestampId(new Date());
|
|
66
|
+
const operations = skills.map((skill) => {
|
|
67
|
+
const action = mode;
|
|
68
|
+
const trashDir =
|
|
69
|
+
mode === "trash"
|
|
70
|
+
? path.join(skill.rootDir, ".oh-skillhub", "trash", timestamp, skill.directoryName || skill.name)
|
|
71
|
+
: null;
|
|
72
|
+
return {
|
|
73
|
+
action,
|
|
74
|
+
agent: skill.agent,
|
|
75
|
+
scope: skill.scope,
|
|
76
|
+
skill,
|
|
77
|
+
rootDir: skill.rootDir,
|
|
78
|
+
source: skill.path,
|
|
79
|
+
destination: trashDir,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
return { operations };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function applyCleanPlan(plan) {
|
|
86
|
+
const applied = [];
|
|
87
|
+
for (const operation of plan.operations) {
|
|
88
|
+
assertCleanPath(operation.rootDir, operation.source);
|
|
89
|
+
if (operation.destination) {
|
|
90
|
+
assertCleanPath(operation.rootDir, operation.destination);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (operation.skill.status !== "broken" && fs.existsSync(operation.source)) {
|
|
94
|
+
if (operation.action === "trash") {
|
|
95
|
+
const destination = uniqueDestination(operation.destination);
|
|
96
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
97
|
+
fs.renameSync(operation.source, destination);
|
|
98
|
+
operation.destination = destination;
|
|
99
|
+
} else {
|
|
100
|
+
fs.rmSync(operation.source, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
updateInstallRecordAfterClean(operation);
|
|
104
|
+
applied.push(operation);
|
|
105
|
+
}
|
|
106
|
+
return applied;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readInstallRecord(targetDir) {
|
|
110
|
+
const recordPath = path.join(targetDir, ".oh-skillhub", "installed.json");
|
|
111
|
+
const empty = { skills: {}, history: [] };
|
|
112
|
+
if (!fs.existsSync(recordPath)) {
|
|
113
|
+
return empty;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(stripBom(fs.readFileSync(recordPath, "utf8")));
|
|
117
|
+
return {
|
|
118
|
+
...parsed,
|
|
119
|
+
skills: parsed.skills || {},
|
|
120
|
+
history: parsed.history || [],
|
|
121
|
+
};
|
|
122
|
+
} catch {
|
|
123
|
+
return empty;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeInstallRecord(targetDir, record) {
|
|
128
|
+
const recordDir = path.join(targetDir, ".oh-skillhub");
|
|
129
|
+
fs.mkdirSync(recordDir, { recursive: true });
|
|
130
|
+
fs.writeFileSync(path.join(recordDir, "installed.json"), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updateInstallRecordAfterClean(operation) {
|
|
134
|
+
const record = readInstallRecord(operation.rootDir);
|
|
135
|
+
const key = operation.skill.directoryName || operation.skill.name;
|
|
136
|
+
const managed = Boolean(record.skills[key]);
|
|
137
|
+
delete record.skills[key];
|
|
138
|
+
record.history = record.history || [];
|
|
139
|
+
record.history.push({
|
|
140
|
+
event: "skill_removed",
|
|
141
|
+
removedAt: new Date().toISOString(),
|
|
142
|
+
skill: operation.skill.name,
|
|
143
|
+
agent: operation.agent,
|
|
144
|
+
scope: operation.scope,
|
|
145
|
+
action: operation.action,
|
|
146
|
+
managed,
|
|
147
|
+
from: operation.source,
|
|
148
|
+
to: operation.destination || null,
|
|
149
|
+
});
|
|
150
|
+
writeInstallRecord(operation.rootDir, record);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseSkillMetadata(skillDir) {
|
|
154
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
155
|
+
const metadata = {};
|
|
156
|
+
if (!fs.existsSync(skillPath)) {
|
|
157
|
+
return metadata;
|
|
158
|
+
}
|
|
159
|
+
const contents = fs.readFileSync(skillPath, "utf8");
|
|
160
|
+
const match = contents.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
161
|
+
if (!match) {
|
|
162
|
+
return metadata;
|
|
163
|
+
}
|
|
164
|
+
let inMetadata = false;
|
|
165
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
166
|
+
if (/^metadata:\s*$/.test(line)) {
|
|
167
|
+
inMetadata = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const top = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
171
|
+
if (top) {
|
|
172
|
+
inMetadata = false;
|
|
173
|
+
if (["name", "description"].includes(top[1])) {
|
|
174
|
+
metadata[top[1]] = unquote(top[2].trim());
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const nested = line.match(/^\s+([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
179
|
+
if (inMetadata && nested && ["domain", "stage"].includes(nested[1])) {
|
|
180
|
+
metadata[nested[1]] = unquote(nested[2].trim());
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return metadata;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function shouldIgnoreEntry(name) {
|
|
187
|
+
return name === ".oh-skillhub" || name === "trash" || name === "cache" || name.startsWith(".");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function assertCleanPath(rootDir, candidate) {
|
|
191
|
+
const root = path.resolve(rootDir);
|
|
192
|
+
const resolved = path.resolve(candidate);
|
|
193
|
+
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) {
|
|
194
|
+
throw new Error(`Refusing to clean outside target root: ${resolved}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function uniqueDestination(destination) {
|
|
199
|
+
if (!fs.existsSync(destination)) {
|
|
200
|
+
return destination;
|
|
201
|
+
}
|
|
202
|
+
for (let index = 1; index < 1000; index += 1) {
|
|
203
|
+
const candidate = `${destination}-${index}`;
|
|
204
|
+
if (!fs.existsSync(candidate)) {
|
|
205
|
+
return candidate;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`Unable to find unique trash destination for ${destination}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function timestampId(date) {
|
|
212
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
213
|
+
return [
|
|
214
|
+
date.getFullYear(),
|
|
215
|
+
pad(date.getMonth() + 1),
|
|
216
|
+
pad(date.getDate()),
|
|
217
|
+
"-",
|
|
218
|
+
pad(date.getHours()),
|
|
219
|
+
pad(date.getMinutes()),
|
|
220
|
+
pad(date.getSeconds()),
|
|
221
|
+
].join("");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stripBom(text) {
|
|
225
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function unquote(value) {
|
|
229
|
+
return value.replace(/^["']|["']$/g, "");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
applyCleanPlan,
|
|
234
|
+
parseSkillMetadata,
|
|
235
|
+
planClean,
|
|
236
|
+
readInstallRecord,
|
|
237
|
+
scanInstalledSkills,
|
|
238
|
+
writeInstallRecord,
|
|
239
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ const readline = require("node:readline");
|
|
|
5
5
|
const readlinePromises = require("node:readline/promises");
|
|
6
6
|
|
|
7
7
|
const { resolveAgentTargets } = require("./agents");
|
|
8
|
+
const { applyCleanPlan, planClean, scanInstalledSkills } = require("./cleaner");
|
|
8
9
|
const { loadLocalManifest, loadProfiles, selectSkills } = require("./manifest");
|
|
9
10
|
const { applyInstallPlan, planInstall } = require("./planner");
|
|
10
11
|
const { ensureSkillSourceRoot } = require("./source");
|
|
@@ -66,6 +67,10 @@ async function main(argv = []) {
|
|
|
66
67
|
console.log(renderDoctor(options));
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
70
|
+
if (command === "clean") {
|
|
71
|
+
await runClean(options);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
69
74
|
if (command === "telemetry") {
|
|
70
75
|
console.log(renderTelemetry(rest));
|
|
71
76
|
return;
|
|
@@ -89,12 +94,162 @@ function parseArgs(args) {
|
|
|
89
94
|
else if (arg === "--offline") options.offline = true;
|
|
90
95
|
else if (arg === "--no-telemetry") options.noTelemetry = true;
|
|
91
96
|
else if (arg === "--force") options.force = true;
|
|
97
|
+
else if (arg === "--purge") options.purge = true;
|
|
92
98
|
else if (!arg.startsWith("-")) options.names.push(arg);
|
|
93
99
|
else throw new Error(`Unknown option "${arg}".`);
|
|
94
100
|
}
|
|
95
101
|
return options;
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
async function runClean(options, input = process.stdin, output = process.stdout) {
|
|
105
|
+
const scriptedAnswers = input.isTTY ? null : splitPromptAnswers(await readAll(input));
|
|
106
|
+
let answerIndex = 0;
|
|
107
|
+
const takeAnswer = () => {
|
|
108
|
+
if (!scriptedAnswers) return null;
|
|
109
|
+
const answer = scriptedAnswers[answerIndex] || "";
|
|
110
|
+
answerIndex += 1;
|
|
111
|
+
return answer;
|
|
112
|
+
};
|
|
113
|
+
let agent = options.agent;
|
|
114
|
+
if (!agent && !options.target) {
|
|
115
|
+
if (input.isTTY && output.isTTY) {
|
|
116
|
+
agent = await runRawAgentSelection(input, output);
|
|
117
|
+
} else {
|
|
118
|
+
output.write(renderAgentMenu());
|
|
119
|
+
output.write("Select target [1]: \n");
|
|
120
|
+
agent = AGENT_CHOICES[parseSingleSelection(takeAnswer() || "", AGENT_CHOICES.length, 1)].agent;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const targets = resolveAgentTargets({
|
|
124
|
+
agent: agent || "codex",
|
|
125
|
+
scope: options.scope || "user",
|
|
126
|
+
target: options.target,
|
|
127
|
+
cwd: process.cwd(),
|
|
128
|
+
homeDir: os.homedir(),
|
|
129
|
+
env: process.env,
|
|
130
|
+
});
|
|
131
|
+
const discovered = scanInstalledSkills(targets);
|
|
132
|
+
if (!discovered.length) {
|
|
133
|
+
output.write(renderNoSkillsFound(targets));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let selected;
|
|
137
|
+
let prefilledConfirmation = null;
|
|
138
|
+
if (!options.names.length && !options.dryRun) {
|
|
139
|
+
if (input.isTTY) {
|
|
140
|
+
selected = await promptCleanSelection(input, output, discovered);
|
|
141
|
+
} else {
|
|
142
|
+
output.write(renderCleanSelectionMenu(discovered));
|
|
143
|
+
output.write("Select skills [1]: \n");
|
|
144
|
+
selected = parseSelection(takeAnswer() || "", discovered.length, 1).map((index) => discovered[index]);
|
|
145
|
+
prefilledConfirmation = takeAnswer() || "";
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
selected = selectCleanSkills(discovered, options.names);
|
|
149
|
+
if (!input.isTTY) {
|
|
150
|
+
prefilledConfirmation = takeAnswer();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!selected.length) {
|
|
154
|
+
throw new Error(`No installed skills matched: ${options.names.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
const plan = planClean(selected, { mode: options.purge ? "purge" : "trash" });
|
|
157
|
+
if (options.dryRun) {
|
|
158
|
+
output.write(`${renderCleanPlan("Clean dry run", plan)}\n`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await confirmClean(input, output, plan, prefilledConfirmation);
|
|
162
|
+
const applied = applyCleanPlan(plan);
|
|
163
|
+
output.write(`${renderCleanPlan("Clean summary", { operations: applied })}\n`);
|
|
164
|
+
output.write(`${renderCleanCompletion(applied)}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function promptCleanSelection(input, output, skills) {
|
|
168
|
+
output.write(renderCleanSelectionMenu(skills));
|
|
169
|
+
const rl = readlinePromises.createInterface({ input, output });
|
|
170
|
+
try {
|
|
171
|
+
const answer = await rl.question("Select skills [1]: ");
|
|
172
|
+
return parseSelection(answer, skills.length, 1).map((index) => skills[index]);
|
|
173
|
+
} finally {
|
|
174
|
+
rl.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function selectCleanSkills(skills, names = []) {
|
|
179
|
+
if (!names.length) {
|
|
180
|
+
return skills;
|
|
181
|
+
}
|
|
182
|
+
const wanted = new Set(names);
|
|
183
|
+
return skills.filter((skill) => wanted.has(skill.name) || wanted.has(skill.directoryName));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function confirmClean(input, output, plan, prefilledAnswer = null) {
|
|
187
|
+
const first = plan.operations[0];
|
|
188
|
+
const action = first.action === "purge" ? "Permanently delete" : "Remove";
|
|
189
|
+
const target = plan.operations.length
|
|
190
|
+
? `${first.agent}:${first.scope}`
|
|
191
|
+
: "selected target";
|
|
192
|
+
output.write(`${action} ${plan.operations.length} selected skill(s) from ${target}?\n`);
|
|
193
|
+
if (first.action === "purge") {
|
|
194
|
+
output.write("This cannot be undone.\n");
|
|
195
|
+
} else {
|
|
196
|
+
output.write("Selected directories will be moved to .oh-skillhub/trash.\n");
|
|
197
|
+
}
|
|
198
|
+
if (prefilledAnswer !== null) {
|
|
199
|
+
output.write("Press Enter to confirm, or type anything else to cancel: \n");
|
|
200
|
+
if (prefilledAnswer.trim()) {
|
|
201
|
+
throw new Error("Cancelled. No skills were removed.");
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const rl = readlinePromises.createInterface({ input, output });
|
|
206
|
+
try {
|
|
207
|
+
const answer = await rl.question("Press Enter to confirm, or type anything else to cancel: ");
|
|
208
|
+
if (answer.trim()) {
|
|
209
|
+
throw new Error("Cancelled. No skills were removed.");
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
rl.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderCleanSelectionMenu(skills) {
|
|
217
|
+
const lines = ["Detected skills"];
|
|
218
|
+
skills.forEach((skill, index) => {
|
|
219
|
+
const group = `${skill.domain || "unknown"}/${skill.stage || "unknown"}`;
|
|
220
|
+
lines.push(
|
|
221
|
+
`${String(index + 1).padStart(2, " ")}. [ ] ${skill.name.padEnd(36, " ")} ${skill.status.padEnd(9, " ")} ${group}`,
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
lines.push("");
|
|
225
|
+
return `${lines.join("\n")}\n`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderCleanPlan(title, plan) {
|
|
229
|
+
const lines = [title];
|
|
230
|
+
for (const operation of plan.operations) {
|
|
231
|
+
const destination = operation.destination || operation.source;
|
|
232
|
+
lines.push(
|
|
233
|
+
`${operation.action.toUpperCase()} ${operation.skill.name} -> ${operation.agent}:${operation.scope} ${destination}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderCleanCompletion(applied) {
|
|
240
|
+
const first = applied[0];
|
|
241
|
+
const target = first ? `${first.agent}:${first.scope}` : "selected target";
|
|
242
|
+
return ["", `Done. Removed ${applied.length} skill(s) from ${target}.`].join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderNoSkillsFound(targets) {
|
|
246
|
+
const lines = ["No skills found."];
|
|
247
|
+
for (const target of targets) {
|
|
248
|
+
lines.push(`${target.agent}:${target.scope} ${target.dir}`);
|
|
249
|
+
}
|
|
250
|
+
return `${lines.join("\n")}\n`;
|
|
251
|
+
}
|
|
252
|
+
|
|
98
253
|
function renderList(options) {
|
|
99
254
|
const manifest = loadLocalManifest();
|
|
100
255
|
const skills = manifest.skills
|
|
@@ -624,6 +779,7 @@ function helpText() {
|
|
|
624
779
|
"Commands:",
|
|
625
780
|
" list [--domain <name>] [--stage <name>]",
|
|
626
781
|
" install [skill...] [--domain <name>] [--preset <name>] [--agent codex|claude|opencode|all]",
|
|
782
|
+
" clean [skill...] [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--purge]",
|
|
627
783
|
" doctor [--agent codex|claude|opencode|all]",
|
|
628
784
|
" telemetry status",
|
|
629
785
|
"",
|
|
@@ -642,6 +798,7 @@ module.exports = {
|
|
|
642
798
|
renderTelemetry,
|
|
643
799
|
buildRepositoryChoices,
|
|
644
800
|
renderAgentMenu,
|
|
801
|
+
renderCleanPlan,
|
|
645
802
|
renderRawAgentMenu,
|
|
646
803
|
parseSelection,
|
|
647
804
|
renderRawTuiMenu,
|