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 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.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.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(`${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/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://... 即可。",
@@ -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 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)) {
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
  })
@@ -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
+ }