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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-skillhub",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "OpenHarmony Skills installer for Codex, Claude Code, and OpenCode.",
5
5
  "type": "commonjs",
6
6
  "bin": {
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,