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 +3 -1
- package/README.zh-CN.md +3 -1
- package/package.json +1 -1
- package/src/catalog.mjs +62 -2
- package/src/cli.mjs +12 -1
- package/src/i18n.mjs +8 -0
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.
|
|
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(
|
|
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 = {
|
|
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 会读取的目录)",
|