opencode-skills-doctor 0.3.6 → 0.3.7

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
@@ -89,9 +89,11 @@ skills-doctor update --into .\team-skills
89
89
  ```
90
90
 
91
91
  - The output directory is a real OpenCode skill directory, so **`check` picks the update up immediately**;
92
+ - Overwrites files in place and **deletes files that the new version no longer lists** (no stale leftovers from the old version);
92
93
  - Writes `.skills-doctor.lock.json` (schema v2: a sha1 for every file);
93
94
  - `check` reads that lock: shows managed versions, detects hand edits (`managed-drift`) and missing files (`managed-missing`);
94
- - Running `update` again re-downloads drifted files (self-healing).
95
+ - Running `update` again re-downloads drifted files (self-healing);
96
+ - Use `--prune` to delete skills that disappeared from the catalog (by default they are reported but kept).
95
97
 
96
98
  ## Configuration
97
99
 
package/README.zh-CN.md CHANGED
@@ -89,9 +89,11 @@ skills-doctor update --into .\team-skills
89
89
  ```
90
90
 
91
91
  - 落盘位置就是 opencode 的 skill 目录 → **更新完 `check` 立刻能检测到**;
92
+ - 原地覆盖文件,并**删除新版本里已不存在的文件**(不会残留旧版本文件);
92
93
  - 写 `.skills-doctor.lock.json`(v2:记录每个文件的 sha1);
93
94
  - `check` 会读这个 lock:显示托管版本、发现文件被手改(`managed-drift`)、文件被删(`managed-missing`);
94
- - 再跑一次 `update` 会自动重下被改坏的文件(drift 自愈)。
95
+ - 再跑一次 `update` 会自动重下被改坏的文件(drift 自愈);
96
+ - 用 `--prune` 删除 catalog 中已下架的 skill(默认只提示、不删)。
95
97
 
96
98
  ## 配置
97
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Audit, fix and update agent skills: per-step context cost, shadowed duplicates, safe archive, catalog sync. CLI, OpenCode plugin and MCP server (Claude Code / Codex / any MCP client).",
5
5
  "type": "module",
6
6
  "exports": {
package/src/catalog.mjs CHANGED
@@ -68,6 +68,20 @@ function lockEntryVersion(entry) {
68
68
  return null
69
69
  }
70
70
 
71
+ function pruneEmptyDirs(dir, stopDir) {
72
+ const stop = path.resolve(stopDir)
73
+ let current = path.resolve(dir)
74
+ while (current !== stop && current.startsWith(stop + path.sep)) {
75
+ try {
76
+ if (fs.readdirSync(current).length > 0) return
77
+ fs.rmdirSync(current)
78
+ } catch {
79
+ return
80
+ }
81
+ current = path.dirname(current)
82
+ }
83
+ }
84
+
71
85
  /** 本地文件是否仍与 lock 里的哈希一致(无哈希时视为一致)。 */
72
86
  function filesMatch(into, name, files, t) {
73
87
  if (!files || typeof files !== "object") return true
@@ -93,14 +107,27 @@ export async function fetchCatalogIndex(url, { fetchImpl = fetch, timeoutMs = 15
93
107
  * 更新器:对比 HTTP catalog 的 version,必要时下载到 into 目录并写 lock。
94
108
  * lock 里记录每个文件的 sha1,检测器据此判断「更新后是否被改过」。
95
109
  */
96
- export async function updateCatalogs(catalogs, { into = process.cwd(), checkOnly = false, fetchImpl = fetch, t = translator("en") } = {}) {
110
+ export async function updateCatalogs(
111
+ catalogs,
112
+ { into = process.cwd(), checkOnly = false, fetchImpl = fetch, t = translator("en"), prune = false } = {},
113
+ ) {
97
114
  const lockPath = path.join(into, LOCK_NAME)
98
115
  const lock = readLockFile(into) ?? { version: LOCK_VERSION, catalogs: {} }
99
116
  const lockTooNew = Number(lock.version ?? 1) > LOCK_VERSION
100
117
  const results = []
101
118
 
102
119
  for (const catalog of catalogs) {
103
- const result = { url: catalog.url, error: null, updated: [], unchanged: [], invalid: [], files: 0 }
120
+ const result = {
121
+ url: catalog.url,
122
+ error: null,
123
+ updated: [],
124
+ unchanged: [],
125
+ invalid: [],
126
+ orphaned: [],
127
+ pruned: [],
128
+ removedFiles: [],
129
+ files: 0,
130
+ }
104
131
  try {
105
132
  const { base, skills } = await fetchCatalogIndex(catalog.url, { fetchImpl, t })
106
133
  const baseUrl = new URL(base + "/")
@@ -144,10 +171,43 @@ export async function updateCatalogs(catalogs, { into = process.cwd(), checkOnly
144
171
  hashes[file] = sha1Buffer(buffer)
145
172
  result.files++
146
173
  }
174
+
175
+ // 新版本里已被删除的文件:从本地清掉,避免旧文件残留
176
+ if (prevHashes) {
177
+ for (const oldFile of Object.keys(prevHashes)) {
178
+ if (entry.files.includes(oldFile)) continue
179
+ const target = resolveInside(path.join(into, entry.name), oldFile, t)
180
+ if (!fs.existsSync(target)) continue
181
+ fs.rmSync(target, { force: true })
182
+ result.removedFiles.push({ name: entry.name, file: oldFile })
183
+ pruneEmptyDirs(path.dirname(target), path.join(into, entry.name))
184
+ }
185
+ }
186
+
147
187
  nextSkills[entry.name] = { version, files: hashes }
148
188
  result.updated.push({ name: entry.name, version, previous: previousVersion, reason: drifted ? "drift" : null })
149
189
  }
150
190
 
191
+ const validNames = new Set(
192
+ skills.filter((entry) => validateCatalogEntry(entry, t).length === 0).map((entry) => entry.name),
193
+ )
194
+ const orphaned = Object.keys(previousSkills).filter((name) => !validNames.has(name))
195
+ result.orphaned = orphaned
196
+
197
+ if (!checkOnly) {
198
+ if (prune) {
199
+ for (const name of orphaned) {
200
+ const dir = resolveInside(into, name, t)
201
+ if (!fs.existsSync(dir)) continue
202
+ fs.rmSync(dir, { recursive: true, force: true })
203
+ result.pruned.push(name)
204
+ }
205
+ } else {
206
+ // 不 prune 时保留 lock 记录,方便下次继续跟踪
207
+ for (const name of orphaned) nextSkills[name] = previousSkills[name]
208
+ }
209
+ }
210
+
151
211
  if (!checkOnly) lock.catalogs[catalog.url] = { skills: nextSkills }
152
212
  } catch (err) {
153
213
  result.error = String(err?.message ?? err)
package/src/cli.mjs CHANGED
@@ -55,6 +55,7 @@ function parseArgs(argv) {
55
55
  hide: [],
56
56
  into: null,
57
57
  checkOnly: false,
58
+ prune: false,
58
59
  help: false,
59
60
  version: false,
60
61
  }
@@ -112,6 +113,9 @@ function parseArgs(argv) {
112
113
  case "--check-only":
113
114
  options.checkOnly = true
114
115
  break
116
+ case "--prune":
117
+ options.prune = true
118
+ break
115
119
  case "-h":
116
120
  case "--help":
117
121
  options.help = true
@@ -299,11 +303,12 @@ async function runUpdate(discovered, options, cwd, t) {
299
303
  const { lockPath, results, lockTooNew } = await updateCatalogs(discovered.catalogs, {
300
304
  into,
301
305
  checkOnly: options.checkOnly,
306
+ prune: options.prune,
302
307
  t,
303
308
  })
304
309
 
305
310
  if (options.json) {
306
- console.log(JSON.stringify({ lockPath, checkOnly: options.checkOnly, results }, null, 2))
311
+ console.log(JSON.stringify({ lockPath, checkOnly: options.checkOnly, prune: options.prune, results }, null, 2))
307
312
  return results.some((r) => r.error) ? 1 : 0
308
313
  }
309
314
 
@@ -329,6 +334,12 @@ async function runUpdate(discovered, options, cwd, t) {
329
334
  }
330
335
  if (result.unchanged.length) console.log(dim(t("update.unchanged", { count: result.unchanged.length })))
331
336
  if (result.files) console.log(dim(t("update.files", { count: result.files })))
337
+ if (result.removedFiles.length) console.log(dim(t("update.removedFiles", { count: result.removedFiles.length })))
338
+ if (result.pruned.length) {
339
+ console.log(yellow(t("update.pruned", { count: result.pruned.length, names: result.pruned.join(", ") })))
340
+ } else if (result.orphaned.length) {
341
+ console.log(yellow(t("update.orphaned", { count: result.orphaned.length })))
342
+ }
332
343
  for (const invalid of result.invalid) {
333
344
  console.log(yellow(t("update.skipped", { name: invalid.name, problems: invalid.problems.join("; ") })))
334
345
  }
package/src/i18n.mjs CHANGED
@@ -26,6 +26,7 @@ Options:
26
26
  --hide <ids> fix: hide skills with metadata.opencode/autoinvoke: false (comma separated)
27
27
  --into <dir> update: output directory (default: .opencode/skills)
28
28
  --check-only update: only report available updates
29
+ --prune update: delete skills that disappeared from the catalog
29
30
  -h, --help show this help
30
31
  -v, --version print version
31
32
 
@@ -69,6 +70,9 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
69
70
  "update.files": " · files written {count}",
70
71
  "update.skipped": " ! skipped {name}: {problems}",
71
72
  "update.empty": " · catalog is empty",
73
+ "update.removedFiles": " · removed {count} stale files",
74
+ "update.pruned": " · pruned {count} skills no longer in the catalog: {names}",
75
+ "update.orphaned": " ! {count} skills are no longer in the catalog (use --prune to delete them)",
72
76
  "update.lock": " lock: {path} ({name})",
73
77
  "update.lockTooNew": " ! lock is newer than this tool; parsed in compatibility mode (unknown fields ignored)",
74
78
  "update.detector": " check now sees these skills ({dir} is where opencode reads)",
@@ -168,6 +172,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
168
172
  --hide <ids> fix: 给指定 skill 加 metadata.opencode/autoinvoke: false(逗号分隔)
169
173
  --into <dir> update 的落盘目录(默认 .opencode/skills)
170
174
  --check-only update 只报告可用更新,不下载
175
+ --prune update: 删除 catalog 中已移除的 skill
171
176
  -h, --help 帮助
172
177
  -v, --version 版本
173
178
 
@@ -211,6 +216,9 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
211
216
  "update.files": " · 写入文件 {count}",
212
217
  "update.skipped": " ! 跳过 {name}: {problems}",
213
218
  "update.empty": " · catalog 为空",
219
+ "update.removedFiles": " · 删除旧版本残留文件 {count} 个",
220
+ "update.pruned": " · 清理 catalog 中已移除的 skill {count} 个: {names}",
221
+ "update.orphaned": " ! {count} 个 skill 已不在 catalog 中(加 --prune 可删除本地残留)",
214
222
  "update.lock": " lock: {path}({name})",
215
223
  "update.lockTooNew": " ! lock 版本比本工具新,已按兼容模式解析(未知字段忽略)",
216
224
  "update.detector": " check 现在可以直接检测到这些 skill({dir} 是 opencode 会读取的目录)",