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 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.9",
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
- "files": [
35
- "bin",
36
- "src",
37
- "README.md",
38
- "README.zh-CN.md",
39
- "SECURITY.md",
40
- "SECURITY.zh-CN.md"
41
- ],
42
- "keywords": [
43
- "opencode",
44
- "skills",
45
- "agent-skills",
46
- "mcp",
47
- "lint",
48
- "fix",
49
- "cli",
50
- "plugin"
51
- ],
52
- "repository": {
53
- "type": "git",
54
- "url": "https://github.com/wangyuchen5110/opencodeplugginprogram.git"
55
- },
56
- "license": "MIT"
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(`${base}/index.json`, { signal: AbortSignal.timeout(timeoutMs) })
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(fileUrl, { signal: AbortSignal.timeout(30000) })
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
- fs.writeFileSync(target, buffer)
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
- fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n", "utf8")
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 score = jaccard(tokenSet(a.description), tokenSet(b.description))
169
- if (score >= NEAR_DUPLICATE_THRESHOLD) pairs.push({ a: a.id, b: b.id, score })
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)) {