opencode-skills-doctor 0.3.10 → 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
@@ -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.
@@ -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。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
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/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/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
+ }