opencode-skills-doctor 0.3.9 → 0.3.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 +22 -2
- package/{README.zh-CN.md → docs/README.zh-CN.md} +23 -3
- package/package.json +59 -57
- package/src/catalog.mjs +39 -4
- package/src/cli.mjs +32 -1
- package/src/i18n.mjs +20 -0
- package/src/install.mjs +211 -0
- package/src/lint.mjs +9 -5
- package/src/plugin-v1.mjs +5 -0
- package/src/plugin.mjs +7 -0
- package/src/warning.mjs +37 -0
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;
|
|
@@ -157,6 +157,26 @@ It registers the same `skills_doctor` tool (1.x has no `/skills-doctor` command
|
|
|
157
157
|
|
|
158
158
|
> The package root entry is the V2 plugin and requires OpenCode 2.x. The CLI works on both lines.
|
|
159
159
|
|
|
160
|
+
### Deep integration (one command)
|
|
161
|
+
|
|
162
|
+
```powershell
|
|
163
|
+
npx opencode-skills-doctor install
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The installer detects your OpenCode version and wires everything up:
|
|
167
|
+
|
|
168
|
+
- **2.x** — adds `"plugins": ["opencode-skills-doctor"]` to `opencode.json(c)`;
|
|
169
|
+
- **1.x** — installs the package into `~/.config/opencode`, writes the local plugin entry, adds a `/skills-doctor` command, sets `"type": "module"`;
|
|
170
|
+
- backs up the config file as `<config>.bak-<timestamp>` before editing.
|
|
171
|
+
|
|
172
|
+
Use `--dry-run` to preview, `--config-dir <dir>` for a custom config location, and `--no-npm` to skip the package install.
|
|
173
|
+
|
|
174
|
+
Once installed you get:
|
|
175
|
+
|
|
176
|
+
- the `skills_doctor` tool;
|
|
177
|
+
- the `/skills-doctor` command (1.x: generated markdown command; 2.x: plugin command);
|
|
178
|
+
- a one-time system-prompt warning when the skill description budget is exceeded.
|
|
179
|
+
|
|
160
180
|
## MCP server (Claude Code, Codex, Kimi Code, any MCP client)
|
|
161
181
|
|
|
162
182
|
`skills-doctor mcp` speaks the Model Context Protocol over stdio and exposes one tool: `skills_doctor` with arguments `directory?`, `budget?`, `lang?` and `global?`. It does not need OpenCode installed.
|
|
@@ -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 互相遮蔽——你改的可能是一份永远不会加载的文件;
|
|
@@ -157,6 +157,26 @@ export { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"
|
|
|
157
157
|
|
|
158
158
|
> 包根入口是 V2 插件,需要 OpenCode 2.x;CLI 两条线都能用。
|
|
159
159
|
|
|
160
|
+
### 一键深度集成
|
|
161
|
+
|
|
162
|
+
```powershell
|
|
163
|
+
npx opencode-skills-doctor install
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
安装器会自动识别 OpenCode 版本并接好:
|
|
167
|
+
|
|
168
|
+
- **2.x** —— 在 `opencode.json(c)` 里加 `"plugins": ["opencode-skills-doctor"]`;
|
|
169
|
+
- **1.x** —— 把包装进 `~/.config/opencode`、写本地插件入口、生成 `/skills-doctor` 命令、补 `"type": "module"`;
|
|
170
|
+
- 改配置前会备份为 `<config>.bak-<timestamp>`。
|
|
171
|
+
|
|
172
|
+
支持 `--dry-run` 预览、`--config-dir <dir>` 指定配置目录、`--no-npm` 跳过装包。
|
|
173
|
+
|
|
174
|
+
装好后你会得到:
|
|
175
|
+
|
|
176
|
+
- `skills_doctor` 工具;
|
|
177
|
+
- `/skills-doctor` 命令(1.x 是生成的 markdown 命令,2.x 是插件命令);
|
|
178
|
+
- 当 skill 描述预算超标时,系统提示里会注入一次提醒(每个会话最多一次)。
|
|
179
|
+
|
|
160
180
|
## MCP server(Claude Code、Codex、Kimi Code、任何 MCP 客户端)
|
|
161
181
|
|
|
162
182
|
`skills-doctor mcp` 用 stdio 讲 Model Context Protocol,只暴露一个工具:`skills_doctor`,参数 `directory?`、`budget?`、`lang?`、`global?`。不需要装 OpenCode。
|
|
@@ -252,7 +272,7 @@ Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c",
|
|
|
252
272
|
- `fix` / `--prune` 会移动或删除文件——先 `--dry-run` 预览;
|
|
253
273
|
- 它**不是沙箱**,不限制 agent 能做什么。
|
|
254
274
|
|
|
255
|
-
完整威胁模型、加固清单和私密漏洞报告方式见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。
|
|
275
|
+
完整威胁模型、加固清单和私密漏洞报告方式见 [SECURITY.zh-CN.md](../SECURITY.zh-CN.md)。
|
|
256
276
|
|
|
257
277
|
## 注意事项与已知限制
|
|
258
278
|
|
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.11",
|
|
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/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { planFixes, applyFixes, hideSkills } from "./fix.mjs"
|
|
|
8
8
|
import { auditSkills } from "./audit.mjs"
|
|
9
9
|
import { loadConfig } from "./config.mjs"
|
|
10
10
|
import { startMcpServer } from "./mcp.mjs"
|
|
11
|
+
import { installOpenCode } from "./install.mjs"
|
|
11
12
|
import { padDisplay } from "./parse.mjs"
|
|
12
13
|
import { detectLang, normalizeLang, translator } from "./i18n.mjs"
|
|
13
14
|
|
|
@@ -56,12 +57,14 @@ function parseArgs(argv) {
|
|
|
56
57
|
into: null,
|
|
57
58
|
checkOnly: false,
|
|
58
59
|
prune: false,
|
|
60
|
+
configDir: null,
|
|
61
|
+
noNpm: false,
|
|
59
62
|
help: false,
|
|
60
63
|
version: false,
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
const args = [...argv]
|
|
64
|
-
const commands = new Set(["check", "scan", "update", "fix", "mcp", "help"])
|
|
67
|
+
const commands = new Set(["check", "scan", "update", "fix", "install", "mcp", "help"])
|
|
65
68
|
if (args[0] && commands.has(args[0])) {
|
|
66
69
|
options.command = args.shift()
|
|
67
70
|
}
|
|
@@ -116,6 +119,12 @@ function parseArgs(argv) {
|
|
|
116
119
|
case "--prune":
|
|
117
120
|
options.prune = true
|
|
118
121
|
break
|
|
122
|
+
case "--config-dir":
|
|
123
|
+
options.configDir = path.resolve(args[++i] ?? ".")
|
|
124
|
+
break
|
|
125
|
+
case "--no-npm":
|
|
126
|
+
options.noNpm = true
|
|
127
|
+
break
|
|
119
128
|
case "-h":
|
|
120
129
|
case "--help":
|
|
121
130
|
options.help = true
|
|
@@ -444,6 +453,28 @@ export async function main(argv) {
|
|
|
444
453
|
return
|
|
445
454
|
}
|
|
446
455
|
|
|
456
|
+
if (options.command === "install") {
|
|
457
|
+
const result = installOpenCode({
|
|
458
|
+
configDir: options.configDir ?? undefined,
|
|
459
|
+
dryRun: options.dryRun,
|
|
460
|
+
installPackage: !options.noNpm,
|
|
461
|
+
})
|
|
462
|
+
if (options.json) {
|
|
463
|
+
console.log(JSON.stringify(result, null, 2))
|
|
464
|
+
} else {
|
|
465
|
+
console.log(bold(t("install.title")))
|
|
466
|
+
console.log(dim(t("install.version", { version: result.version ?? "unknown" })))
|
|
467
|
+
console.log(dim(t("install.configDir", { dir: result.configDir })))
|
|
468
|
+
if (result.dryRun) console.log(dim(t("install.dryRun")))
|
|
469
|
+
for (const action of result.actions) console.log(` · ${action}`)
|
|
470
|
+
for (const error of result.errors) console.log(red(t("install.error", { error })))
|
|
471
|
+
console.log("")
|
|
472
|
+
console.log(result.errors.length ? red(t("install.failed")) : dim(t("install.next")))
|
|
473
|
+
}
|
|
474
|
+
process.exitCode = result.errors.length ? 1 : 0
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
|
|
447
478
|
if (options.command === "check") {
|
|
448
479
|
const audit = auditSkills({
|
|
449
480
|
cwd,
|
package/src/i18n.mjs
CHANGED
|
@@ -10,6 +10,7 @@ Usage:
|
|
|
10
10
|
skills-doctor scan [options] list every skill with its source and precedence
|
|
11
11
|
skills-doctor fix [options] archive shadowed definitions (safe structural fixes)
|
|
12
12
|
skills-doctor update [options] compare and download skills from HTTP catalogs
|
|
13
|
+
skills-doctor install [options] wire the plugin into OpenCode (1.x / 2.x)
|
|
13
14
|
skills-doctor mcp run as an MCP server (Claude Code, Codex, any MCP client)
|
|
14
15
|
|
|
15
16
|
Options:
|
|
@@ -27,6 +28,8 @@ Options:
|
|
|
27
28
|
--into <dir> update: output directory (default: .opencode/skills)
|
|
28
29
|
--check-only update: only report available updates
|
|
29
30
|
--prune update: delete skills that disappeared from the catalog
|
|
31
|
+
--config-dir <dir> install: OpenCode config directory (default: ~/.config/opencode)
|
|
32
|
+
--no-npm install: do not run npm install (1.x)
|
|
30
33
|
-h, --help show this help
|
|
31
34
|
-v, --version print version
|
|
32
35
|
|
|
@@ -74,6 +77,13 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
74
77
|
"update.pruned": " · pruned {count} skills no longer in the catalog: {names}",
|
|
75
78
|
"update.orphaned": " ! {count} skills are no longer in the catalog (use --prune to delete them)",
|
|
76
79
|
"update.lock": " lock: {path} ({name})",
|
|
80
|
+
"install.title": "OpenCode integration",
|
|
81
|
+
"install.version": " OpenCode: {version}",
|
|
82
|
+
"install.configDir": " config dir: {dir}",
|
|
83
|
+
"install.dryRun": " (dry run: nothing is written)",
|
|
84
|
+
"install.error": " ✗ {error}",
|
|
85
|
+
"install.failed": "Integration failed.",
|
|
86
|
+
"install.next": "Restart OpenCode to activate.",
|
|
77
87
|
"update.lockTooNew": " ! lock is newer than this tool; parsed in compatibility mode (unknown fields ignored)",
|
|
78
88
|
"update.detector": " check now sees these skills ({dir} is where opencode reads)",
|
|
79
89
|
"update.noCatalogs": "No HTTP catalog configured. Add https://... to the skills array in opencode.json(c).",
|
|
@@ -156,6 +166,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
156
166
|
skills-doctor scan [选项] 列出所有 skill 及其来源/优先级
|
|
157
167
|
skills-doctor fix [选项] 归档被遮蔽的定义(安全的结构性修复)
|
|
158
168
|
skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
|
|
169
|
+
skills-doctor install [选项] 把插件接入 OpenCode(1.x / 2.x)
|
|
159
170
|
skills-doctor mcp 以 MCP server 方式运行(Claude Code、Codex 等任何 MCP 客户端)
|
|
160
171
|
|
|
161
172
|
选项:
|
|
@@ -173,6 +184,8 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
173
184
|
--into <dir> update 的落盘目录(默认 .opencode/skills)
|
|
174
185
|
--check-only update 只报告可用更新,不下载
|
|
175
186
|
--prune update: 删除 catalog 中已移除的 skill
|
|
187
|
+
--config-dir <dir> install: OpenCode 配置目录(默认 ~/.config/opencode)
|
|
188
|
+
--no-npm install: 不执行 npm install(1.x)
|
|
176
189
|
-h, --help 帮助
|
|
177
190
|
-v, --version 版本
|
|
178
191
|
|
|
@@ -220,6 +233,13 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
220
233
|
"update.pruned": " · 清理 catalog 中已移除的 skill {count} 个: {names}",
|
|
221
234
|
"update.orphaned": " ! {count} 个 skill 已不在 catalog 中(加 --prune 可删除本地残留)",
|
|
222
235
|
"update.lock": " lock: {path}({name})",
|
|
236
|
+
"install.title": "OpenCode 集成",
|
|
237
|
+
"install.version": " OpenCode: {version}",
|
|
238
|
+
"install.configDir": " 配置目录: {dir}",
|
|
239
|
+
"install.dryRun": " (dry run:不会写入任何内容)",
|
|
240
|
+
"install.error": " ✗ {error}",
|
|
241
|
+
"install.failed": "集成失败。",
|
|
242
|
+
"install.next": "重启 OpenCode 生效。",
|
|
223
243
|
"update.lockTooNew": " ! lock 版本比本工具新,已按兼容模式解析(未知字段忽略)",
|
|
224
244
|
"update.detector": " check 现在可以直接检测到这些 skill({dir} 是 opencode 会读取的目录)",
|
|
225
245
|
"update.noCatalogs": "没有配置 HTTP catalog。在 opencode.json(c) 的 skills 数组里写 https://... 即可。",
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import { spawnSync, execFileSync } from "node:child_process"
|
|
5
|
+
import { stripJsonc } from "./discover.mjs"
|
|
6
|
+
|
|
7
|
+
export const PACKAGE_NAME = "opencode-skills-doctor"
|
|
8
|
+
const PLUGIN_FILE = "skills-doctor.js"
|
|
9
|
+
const COMMAND_FILE = "skills-doctor.md"
|
|
10
|
+
|
|
11
|
+
const V1_PLUGIN_SOURCE = `// Generated by skills-doctor install\n// OpenCode 1.x plugin entry\nexport { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"\n`
|
|
12
|
+
|
|
13
|
+
const V1_COMMAND = `---
|
|
14
|
+
description: Audit local skills with skills-doctor
|
|
15
|
+
---
|
|
16
|
+
Use the skills_doctor tool to audit the skills in this project. Summarize the top findings and propose concrete fixes (archive shadowed duplicates, hide low-frequency skills). $ARGUMENTS
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
export function defaultConfigDir() {
|
|
20
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
|
|
21
|
+
return path.join(base, "opencode")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readJsonc(file) {
|
|
25
|
+
return JSON.parse(stripJsonc(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "")))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJson(file, data) {
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n", "utf8")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readText(file) {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "")
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detectVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const out = execFileSync("opencode", ["--version"], { encoding: "utf8", timeout: 15000 })
|
|
43
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(out))
|
|
44
|
+
if (match) return match[0]
|
|
45
|
+
} catch {}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function configFile(dir) {
|
|
50
|
+
const candidates = ["opencode.jsonc", "opencode.json"]
|
|
51
|
+
.map((name) => path.join(dir, name))
|
|
52
|
+
.filter((file) => fs.existsSync(file))
|
|
53
|
+
|
|
54
|
+
// 优先沿用已经在配置 plugin/plugins 的文件,避免把插件写进另一个配置文件后被覆盖
|
|
55
|
+
for (const file of candidates) {
|
|
56
|
+
try {
|
|
57
|
+
const config = readJsonc(file)
|
|
58
|
+
if (config.plugin !== undefined || config.plugins !== undefined) return file
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return candidates[0] ?? path.join(dir, "opencode.json")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function backup(file, stamp) {
|
|
66
|
+
if (!fs.existsSync(file)) return null
|
|
67
|
+
const target = `${file}.bak-${stamp}`
|
|
68
|
+
fs.copyFileSync(file, target)
|
|
69
|
+
return target
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureUniqueEntry(list, entry) {
|
|
73
|
+
if (!Array.isArray(list)) return [entry]
|
|
74
|
+
return list.includes(entry) ? list : [...list, entry]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function installPackage(dir) {
|
|
78
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
|
|
79
|
+
const result = spawnSync(npm, ["install", "--prefix", dir, PACKAGE_NAME + "@latest", "--no-audit", "--no-fund"], {
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
env: process.env,
|
|
82
|
+
timeout: 180000,
|
|
83
|
+
})
|
|
84
|
+
return result.status === 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 把 skills-doctor 装进 OpenCode:
|
|
89
|
+
* - 2.x:写入 plugins 数组(OpenCode 自己从 npm 拉包)
|
|
90
|
+
* - 1.x:在配置目录安装包、写本地插件入口、写 /skills-doctor 命令、补插件数组
|
|
91
|
+
*/
|
|
92
|
+
export function installOpenCode({ configDir = defaultConfigDir(), version = null, dryRun = false, installPackage: withPackage = true } = {}) {
|
|
93
|
+
const dir = path.resolve(configDir)
|
|
94
|
+
const actions = []
|
|
95
|
+
const errors = []
|
|
96
|
+
const dry = (message) => actions.push(`[dry-run] ${message}`)
|
|
97
|
+
const act = (message) => actions.push(message)
|
|
98
|
+
|
|
99
|
+
const cfgFile = configFile(dir)
|
|
100
|
+
const existingText = readText(cfgFile)
|
|
101
|
+
let config = {}
|
|
102
|
+
if (existingText !== null) {
|
|
103
|
+
try {
|
|
104
|
+
config = readJsonc(cfgFile)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
errors.push(`cannot parse ${cfgFile}: ${err.message}`)
|
|
107
|
+
return { version, major: null, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const resolvedVersion = version ?? detectVersion()
|
|
112
|
+
const inferredMajor = config.plugin !== undefined ? 1 : 2
|
|
113
|
+
const major = resolvedVersion ? Number(resolvedVersion.split(".")[0]) : inferredMajor
|
|
114
|
+
|
|
115
|
+
if (dryRun) dry(`config dir: ${dir}`)
|
|
116
|
+
if (!fs.existsSync(dir)) {
|
|
117
|
+
if (!dryRun) fs.mkdirSync(dir, { recursive: true })
|
|
118
|
+
act(`created config dir ${dir}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
122
|
+
|
|
123
|
+
if (major >= 2) {
|
|
124
|
+
const next = { ...config, plugins: ensureUniqueEntry(config.plugins, PACKAGE_NAME) }
|
|
125
|
+
const changed = !Array.isArray(config.plugins) || !config.plugins.includes(PACKAGE_NAME)
|
|
126
|
+
if (changed) {
|
|
127
|
+
if (!dryRun) {
|
|
128
|
+
const backupFile = backup(cfgFile, stamp)
|
|
129
|
+
if (backupFile) act(`backup ${backupFile}`)
|
|
130
|
+
writeJson(cfgFile, next)
|
|
131
|
+
act(`added "${PACKAGE_NAME}" to plugins in ${cfgFile}`)
|
|
132
|
+
} else dry(`add "${PACKAGE_NAME}" to plugins in ${cfgFile}`)
|
|
133
|
+
} else {
|
|
134
|
+
act(`plugins already contains "${PACKAGE_NAME}"`)
|
|
135
|
+
}
|
|
136
|
+
act("restart OpenCode; the /skills-doctor command and skills_doctor tool will be available")
|
|
137
|
+
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 1.x
|
|
141
|
+
const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
|
|
142
|
+
const commandFile = path.join(dir, "command", COMMAND_FILE)
|
|
143
|
+
const pluginPath = pluginFile
|
|
144
|
+
|
|
145
|
+
const hasPackage = fs.existsSync(path.join(dir, "node_modules", PACKAGE_NAME, "package.json"))
|
|
146
|
+
if (!hasPackage) {
|
|
147
|
+
if (withPackage && !dryRun) {
|
|
148
|
+
act(`installing ${PACKAGE_NAME} into ${dir}`)
|
|
149
|
+
if (!installPackage(dir)) errors.push(`npm install failed for ${PACKAGE_NAME}`)
|
|
150
|
+
else act(`installed ${PACKAGE_NAME}`)
|
|
151
|
+
} else if (dryRun) {
|
|
152
|
+
dry(`install ${PACKAGE_NAME} into ${dir}`)
|
|
153
|
+
} else {
|
|
154
|
+
act(`skipped npm install; run: npm i --prefix "${dir}" ${PACKAGE_NAME}`)
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
act(`${PACKAGE_NAME} already installed in ${dir}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!fs.existsSync(pluginFile)) {
|
|
161
|
+
if (!dryRun) {
|
|
162
|
+
fs.mkdirSync(path.dirname(pluginFile), { recursive: true })
|
|
163
|
+
fs.writeFileSync(pluginFile, V1_PLUGIN_SOURCE, "utf8")
|
|
164
|
+
act(`wrote plugin entry ${pluginFile}`)
|
|
165
|
+
} else dry(`write plugin entry ${pluginFile}`)
|
|
166
|
+
} else {
|
|
167
|
+
act(`plugin entry already exists ${pluginFile}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!fs.existsSync(commandFile)) {
|
|
171
|
+
if (!dryRun) {
|
|
172
|
+
fs.mkdirSync(path.dirname(commandFile), { recursive: true })
|
|
173
|
+
fs.writeFileSync(commandFile, V1_COMMAND, "utf8")
|
|
174
|
+
act(`wrote command ${commandFile}`)
|
|
175
|
+
} else dry(`write command ${commandFile}`)
|
|
176
|
+
} else {
|
|
177
|
+
act(`command already exists ${commandFile}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const nextPlugin = ensureUniqueEntry(config.plugin, pluginPath)
|
|
181
|
+
const pluginChanged = !Array.isArray(config.plugin) || !config.plugin.includes(pluginPath)
|
|
182
|
+
if (pluginChanged) {
|
|
183
|
+
if (!dryRun) {
|
|
184
|
+
const backupFile = backup(cfgFile, stamp)
|
|
185
|
+
if (backupFile) act(`backup ${backupFile}`)
|
|
186
|
+
writeJson(cfgFile, { ...config, plugin: nextPlugin })
|
|
187
|
+
act(`added plugin entry to ${cfgFile}`)
|
|
188
|
+
} else dry(`add plugin entry to ${cfgFile}`)
|
|
189
|
+
} else {
|
|
190
|
+
act("plugin array already configured")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const pkgFile = path.join(dir, "package.json")
|
|
194
|
+
const pkgText = readText(pkgFile)
|
|
195
|
+
let pkg = {}
|
|
196
|
+
if (pkgText !== null) {
|
|
197
|
+
try {
|
|
198
|
+
pkg = JSON.parse(pkgText)
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
if (pkg.type !== "module") {
|
|
202
|
+
if (!dryRun) {
|
|
203
|
+
pkg.type = "module"
|
|
204
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n", "utf8")
|
|
205
|
+
act(`set "type": "module" in ${pkgFile}`)
|
|
206
|
+
} else dry(`set "type": "module" in ${pkgFile}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
act("restart OpenCode; the skills_doctor tool and /skills-doctor command will be available")
|
|
210
|
+
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
211
|
+
}
|
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)) {
|
package/src/plugin-v1.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { auditSkills, formatMarkdown } from "./audit.mjs"
|
|
3
|
+
import { budgetWarning } from "./warning.mjs"
|
|
3
4
|
|
|
4
5
|
const z = tool.schema
|
|
5
6
|
|
|
@@ -25,6 +26,10 @@ export const SkillsDoctorPlugin = async ({ directory }) => ({
|
|
|
25
26
|
},
|
|
26
27
|
}),
|
|
27
28
|
},
|
|
29
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
30
|
+
const warning = budgetWarning({ cwd: directory, sessionID: input?.sessionID })
|
|
31
|
+
if (warning) output.system.push(warning)
|
|
32
|
+
},
|
|
28
33
|
})
|
|
29
34
|
|
|
30
35
|
export const server = SkillsDoctorPlugin
|
package/src/plugin.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
2
|
import { auditSkills, formatMarkdown } from "./audit.mjs"
|
|
3
|
+
import { budgetWarning } from "./warning.mjs"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* OpenCode 插件入口:把 skills-doctor 暴露成 skills_doctor 工具和 /skills-doctor 命令。
|
|
@@ -48,5 +49,11 @@ export default Plugin.define({
|
|
|
48
49
|
},
|
|
49
50
|
})
|
|
50
51
|
})
|
|
52
|
+
|
|
53
|
+
// 每个会话最多注入一次预算预警,避免刷屏
|
|
54
|
+
await ctx.session.hook("context", (event) => {
|
|
55
|
+
const warning = budgetWarning({ cwd: ctx.location.directory, sessionID: event.sessionID })
|
|
56
|
+
if (warning) event.system.push({ type: "text", text: warning })
|
|
57
|
+
})
|
|
51
58
|
},
|
|
52
59
|
})
|
package/src/warning.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { auditSkills } from "./audit.mjs"
|
|
2
|
+
|
|
3
|
+
const cache = new Map()
|
|
4
|
+
const warned = new Set()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 每个会话最多注入一次「skill 预算超标」提醒。
|
|
8
|
+
* 只在存在 error(默认预算超标 2 倍)时提醒,避免刷屏。
|
|
9
|
+
*/
|
|
10
|
+
export function budgetWarning({ cwd = process.cwd(), sessionID = "global", ttlMs = 60000, includeGlobal = true } = {}) {
|
|
11
|
+
const key = `${cwd}::${sessionID}`
|
|
12
|
+
if (warned.has(key)) return null
|
|
13
|
+
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
let entry = cache.get(cwd)
|
|
16
|
+
if (!entry || now - entry.at > ttlMs) {
|
|
17
|
+
const audit = auditSkills({ cwd, includeGlobal })
|
|
18
|
+
const over = audit.counts.errors > 0
|
|
19
|
+
entry = {
|
|
20
|
+
at: now,
|
|
21
|
+
text: over
|
|
22
|
+
? `[skills-doctor] ${audit.summary.unique} skills use ~${audit.summary.advertisedTokens} tokens of descriptions per step (budget ${audit.budget}); ${audit.counts.errors} error(s), ${audit.counts.warnings} warning(s). Use the skills_doctor tool for details, then hide low-frequency skills with \`skills-doctor fix --hide <ids>\`.`
|
|
23
|
+
: null,
|
|
24
|
+
}
|
|
25
|
+
cache.set(cwd, entry)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!entry.text) return null
|
|
29
|
+
warned.add(key)
|
|
30
|
+
return entry.text
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 测试用:清空缓存与已提醒集合。 */
|
|
34
|
+
export function resetWarningState() {
|
|
35
|
+
cache.clear()
|
|
36
|
+
warned.clear()
|
|
37
|
+
}
|