opencode-skills-doctor 0.3.9 → 0.3.10
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 +2 -2
- package/{README.zh-CN.md → docs/README.zh-CN.md} +3 -3
- package/package.json +59 -57
- package/src/catalog.mjs +39 -4
- package/src/lint.mjs +9 -5
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
Local scanner, linter, fixer, updater and **OpenCode plugin** for skills — zero dependencies, Node ≥ 20.
|
|
6
6
|
|
|
7
|
-
**English** | [简体中文](README.zh-CN.md)
|
|
7
|
+
**English** | [简体中文](docs/README.zh-CN.md)
|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
11
11
|
On every model step, OpenCode advertises every skill that has a `description` — the model sees `ID + name + description` for each of them. Skills are not free: they are a permanent context tax on every request.
|
|
12
12
|
|
|
13
|
-
**What this costs.** 29 skills with long descriptions ≈ **3,000+ tokens every step**. Over a 100-step session that is ~300k tokens of pure overhead — spent before the model reads a single line of your code.
|
|
13
|
+
**What this costs.** 29 skills with long descriptions ≈ **3,000+ tokens every step**. Over a 100-step session that is ~300k tokens of pure overhead — spent before the model reads a single line of your code. And that is only 29 skills: many users have far more they downloaded without noticing, some already out of date. This tool helps you understand your skill repository much better.
|
|
14
14
|
|
|
15
15
|
**What can go wrong.** As skills pile up:
|
|
16
16
|
- duplicate IDs silently shadow each other — you may be editing a file that never loads;
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **OpenCode 插件入口** —— 零依赖,Node ≥ 20。
|
|
6
6
|
|
|
7
|
-
[English](README.md) | **简体中文**
|
|
7
|
+
[English](../README.md) | **简体中文**
|
|
8
8
|
|
|
9
9
|
## 为什么需要它
|
|
10
10
|
|
|
11
11
|
opencode 每个模型步骤都会把所有「有 description 的 skill」展示给模型(`ID + name + description`)。skill 不是免费的——它是**每一次请求都要交的上下文税**。
|
|
12
12
|
|
|
13
|
-
**成本有多大**:29 个 skill、描述偏长,就是**每步 3000+ tokens**。一次 100 步的会话,光 skill 目录就烧掉约 30 万 tokens
|
|
13
|
+
**成本有多大**:29 个 skill、描述偏长,就是**每步 3000+ tokens**。一次 100 步的会话,光 skill 目录就烧掉约 30 万 tokens,模型还没开始读你一行代码。而这只是 29 个——很多人还有更多没注意就装上的 skill,有些已经过时。这个工具能帮你真正看清自己的 skill 仓库。
|
|
14
14
|
|
|
15
15
|
**会出什么问题**:skill 越攒越多,就会出现
|
|
16
16
|
- 同 ID 互相遮蔽——你改的可能是一份永远不会加载的文件;
|
|
@@ -252,7 +252,7 @@ Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c",
|
|
|
252
252
|
- `fix` / `--prune` 会移动或删除文件——先 `--dry-run` 预览;
|
|
253
253
|
- 它**不是沙箱**,不限制 agent 能做什么。
|
|
254
254
|
|
|
255
|
-
完整威胁模型、加固清单和私密漏洞报告方式见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。
|
|
255
|
+
完整威胁模型、加固清单和私密漏洞报告方式见 [SECURITY.zh-CN.md](../SECURITY.zh-CN.md)。
|
|
256
256
|
|
|
257
257
|
## 注意事项与已知限制
|
|
258
258
|
|
package/package.json
CHANGED
|
@@ -1,57 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "opencode-skills-doctor",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
"type": "module",
|
|
6
|
-
"exports": {
|
|
7
|
-
".": "./src/plugin.mjs",
|
|
8
|
-
"./plugin": "./src/plugin.mjs",
|
|
9
|
-
"./server": "./src/plugin-v1.mjs",
|
|
10
|
-
"./v1": "./src/plugin-v1.mjs",
|
|
11
|
-
"./audit": "./src/audit.mjs"
|
|
12
|
-
},
|
|
13
|
-
"bin": {
|
|
14
|
-
"skills-doctor": "./bin/skills-doctor.mjs"
|
|
15
|
-
},
|
|
16
|
-
"peerDependencies": {
|
|
17
|
-
"@opencode/plugin": ">=2.0.0",
|
|
18
|
-
"@opencode-ai/plugin": ">=1.0.0"
|
|
19
|
-
},
|
|
20
|
-
"peerDependenciesMeta": {
|
|
21
|
-
"@opencode/plugin": {
|
|
22
|
-
"optional": true
|
|
23
|
-
},
|
|
24
|
-
"@opencode-ai/plugin": {
|
|
25
|
-
"optional": true
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
"engines": {
|
|
29
|
-
"node": ">=20"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"test": "node test/doctor.test.mjs"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-skills-doctor",
|
|
3
|
+
"version": "0.3.10",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/plugin.mjs",
|
|
8
|
+
"./plugin": "./src/plugin.mjs",
|
|
9
|
+
"./server": "./src/plugin-v1.mjs",
|
|
10
|
+
"./v1": "./src/plugin-v1.mjs",
|
|
11
|
+
"./audit": "./src/audit.mjs"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"skills-doctor": "./bin/skills-doctor.mjs"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@opencode/plugin": ">=2.0.0",
|
|
18
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependenciesMeta": {
|
|
21
|
+
"@opencode/plugin": {
|
|
22
|
+
"optional": true
|
|
23
|
+
},
|
|
24
|
+
"@opencode-ai/plugin": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node test/doctor.test.mjs",
|
|
33
|
+
"bench": "node scripts/bench.mjs",
|
|
34
|
+
"prepublishOnly": "node test/doctor.test.mjs"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin",
|
|
38
|
+
"src",
|
|
39
|
+
"docs",
|
|
40
|
+
"README.md",
|
|
41
|
+
"SECURITY.md",
|
|
42
|
+
"SECURITY.zh-CN.md"
|
|
43
|
+
],
|
|
44
|
+
"keywords": [
|
|
45
|
+
"opencode",
|
|
46
|
+
"skills",
|
|
47
|
+
"agent-skills",
|
|
48
|
+
"mcp",
|
|
49
|
+
"lint",
|
|
50
|
+
"fix",
|
|
51
|
+
"cli",
|
|
52
|
+
"plugin"
|
|
53
|
+
],
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "https://github.com/wangyuchen5110/opencodeplugginprogram.git"
|
|
57
|
+
},
|
|
58
|
+
"license": "MIT"
|
|
59
|
+
}
|
package/src/catalog.mjs
CHANGED
|
@@ -68,6 +68,41 @@ function lockEntryVersion(entry) {
|
|
|
68
68
|
return null
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
72
|
+
|
|
73
|
+
/** fetch + 有限重试(5xx 与网络错误),避免 catalog 抖动直接失败。 */
|
|
74
|
+
async function fetchWithRetry(fetchImpl, url, timeoutMs, attempts = 3) {
|
|
75
|
+
let lastError
|
|
76
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
79
|
+
if (res.status >= 500 && attempt < attempts - 1) {
|
|
80
|
+
await sleep(200 * 2 ** attempt)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
return res
|
|
84
|
+
} catch (err) {
|
|
85
|
+
lastError = err
|
|
86
|
+
if (attempt < attempts - 1) await sleep(200 * 2 ** attempt)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError ?? new Error("fetch failed")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 先写临时文件再原子替换,避免中途失败留下半截文件。 */
|
|
93
|
+
function writeFileAtomic(target, data) {
|
|
94
|
+
const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`
|
|
95
|
+
fs.writeFileSync(tmp, data)
|
|
96
|
+
try {
|
|
97
|
+
fs.renameSync(tmp, target)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
fs.rmSync(tmp, { force: true })
|
|
101
|
+
} catch {}
|
|
102
|
+
throw err
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
function pruneEmptyDirs(dir, stopDir) {
|
|
72
107
|
const stop = path.resolve(stopDir)
|
|
73
108
|
let current = path.resolve(dir)
|
|
@@ -96,7 +131,7 @@ function filesMatch(into, name, files, t) {
|
|
|
96
131
|
/** 抓取 <url>/index.json。未知字段一律忽略(向上兼容)。 */
|
|
97
132
|
export async function fetchCatalogIndex(url, { fetchImpl = fetch, timeoutMs = 15000, t = translator("en") } = {}) {
|
|
98
133
|
const base = url.replace(/\/+$/, "")
|
|
99
|
-
const res = await fetchImpl
|
|
134
|
+
const res = await fetchWithRetry(fetchImpl, `${base}/index.json`, timeoutMs)
|
|
100
135
|
if (!res.ok) throw new Error(t("catalog.indexHttp", { status: res.status }))
|
|
101
136
|
const json = await res.json()
|
|
102
137
|
if (!json || !Array.isArray(json.skills)) throw new Error(t("catalog.indexShape"))
|
|
@@ -162,12 +197,12 @@ export async function updateCatalogs(
|
|
|
162
197
|
const encodedPath = file.split("/").map(encodeURIComponent).join("/")
|
|
163
198
|
const fileUrl = new URL(`${encodeURIComponent(entry.name)}/${encodedPath}`, baseUrl)
|
|
164
199
|
if (fileUrl.origin !== baseUrl.origin) throw new Error(t("catalog.crossOrigin", { file }))
|
|
165
|
-
const res = await fetchImpl
|
|
200
|
+
const res = await fetchWithRetry(fetchImpl, fileUrl, 30000)
|
|
166
201
|
if (!res.ok) throw new Error(t("catalog.fileHttp", { name: entry.name, file, status: res.status }))
|
|
167
202
|
const buffer = Buffer.from(await res.arrayBuffer())
|
|
168
203
|
const target = resolveInside(path.join(into, entry.name), file, t)
|
|
169
204
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
170
|
-
|
|
205
|
+
writeFileAtomic(target, buffer)
|
|
171
206
|
hashes[file] = sha1Buffer(buffer)
|
|
172
207
|
result.files++
|
|
173
208
|
}
|
|
@@ -219,7 +254,7 @@ export async function updateCatalogs(
|
|
|
219
254
|
lock.version = LOCK_VERSION
|
|
220
255
|
lock.generatedAt = new Date().toISOString()
|
|
221
256
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true })
|
|
222
|
-
|
|
257
|
+
writeFileAtomic(lockPath, JSON.stringify(lock, null, 2) + "\n")
|
|
223
258
|
}
|
|
224
259
|
|
|
225
260
|
return { lockPath, lockTooNew, results }
|
package/src/lint.mjs
CHANGED
|
@@ -157,16 +157,20 @@ export function lint(skills, options = {}) {
|
|
|
157
157
|
})
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
// 5)
|
|
161
|
-
const withDesc = described
|
|
160
|
+
// 5) 描述近似(token 集合只算一次;先按集合大小做 0.75 上界预筛)
|
|
161
|
+
const withDesc = described.map((skill) => ({ skill, tokens: tokenSet(skill.description) }))
|
|
162
162
|
const pairs = []
|
|
163
163
|
for (let i = 0; i < withDesc.length; i++) {
|
|
164
164
|
for (let j = i + 1; j < withDesc.length; j++) {
|
|
165
165
|
const a = withDesc[i]
|
|
166
166
|
const b = withDesc[j]
|
|
167
|
-
if (a.id === b.id) continue
|
|
168
|
-
const
|
|
169
|
-
|
|
167
|
+
if (a.skill.id === b.skill.id) continue
|
|
168
|
+
const min = Math.min(a.tokens.size, b.tokens.size)
|
|
169
|
+
const max = Math.max(a.tokens.size, b.tokens.size)
|
|
170
|
+
// Jaccard ≤ min/max,所以比值低于阈值时不可能达到阈值
|
|
171
|
+
if (!min || min / max < NEAR_DUPLICATE_THRESHOLD) continue
|
|
172
|
+
const score = jaccard(a.tokens, b.tokens)
|
|
173
|
+
if (score >= NEAR_DUPLICATE_THRESHOLD) pairs.push({ a: a.skill.id, b: b.skill.id, score })
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
for (const pair of pairs.slice(0, 15)) {
|