opencode-skills-doctor 0.3.5 → 0.3.7

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
@@ -89,9 +89,11 @@ skills-doctor update --into .\team-skills
89
89
  ```
90
90
 
91
91
  - The output directory is a real OpenCode skill directory, so **`check` picks the update up immediately**;
92
+ - Overwrites files in place and **deletes files that the new version no longer lists** (no stale leftovers from the old version);
92
93
  - Writes `.skills-doctor.lock.json` (schema v2: a sha1 for every file);
93
94
  - `check` reads that lock: shows managed versions, detects hand edits (`managed-drift`) and missing files (`managed-missing`);
94
- - Running `update` again re-downloads drifted files (self-healing).
95
+ - Running `update` again re-downloads drifted files (self-healing);
96
+ - Use `--prune` to delete skills that disappeared from the catalog (by default they are reported but kept).
95
97
 
96
98
  ## Configuration
97
99
 
@@ -155,6 +157,32 @@ It registers the same `skills_doctor` tool (1.x has no `/skills-doctor` command
155
157
 
156
158
  > The package root entry is the V2 plugin and requires OpenCode 2.x. The CLI works on both lines.
157
159
 
160
+ ## MCP server (Claude Code, Codex, Kimi Code, any MCP client)
161
+
162
+ `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.
163
+
164
+ Claude Code (`.mcp.json` in the project, or your MCP config):
165
+
166
+ ```json
167
+ {
168
+ "mcpServers": {
169
+ "skills-doctor": { "command": "npx", "args": ["-y", "opencode-skills-doctor", "mcp"] }
170
+ }
171
+ }
172
+ ```
173
+
174
+ Codex (`~/.codex/config.toml`):
175
+
176
+ ```toml
177
+ [mcp_servers.skills-doctor]
178
+ command = "npx"
179
+ args = ["-y", "opencode-skills-doctor", "mcp"]
180
+ ```
181
+
182
+ Any other MCP client: run `npx -y opencode-skills-doctor mcp` and allow the `skills_doctor` tool.
183
+
184
+ It scans `.opencode/skills`, `.claude/skills` and `.agents/skills` (project and global), so it can audit skills used by other agents too.
185
+
158
186
  ## Rules
159
187
 
160
188
  | Rule | Level | Meaning |
package/README.zh-CN.md CHANGED
@@ -89,9 +89,11 @@ skills-doctor update --into .\team-skills
89
89
  ```
90
90
 
91
91
  - 落盘位置就是 opencode 的 skill 目录 → **更新完 `check` 立刻能检测到**;
92
+ - 原地覆盖文件,并**删除新版本里已不存在的文件**(不会残留旧版本文件);
92
93
  - 写 `.skills-doctor.lock.json`(v2:记录每个文件的 sha1);
93
94
  - `check` 会读这个 lock:显示托管版本、发现文件被手改(`managed-drift`)、文件被删(`managed-missing`);
94
- - 再跑一次 `update` 会自动重下被改坏的文件(drift 自愈)。
95
+ - 再跑一次 `update` 会自动重下被改坏的文件(drift 自愈);
96
+ - 用 `--prune` 删除 catalog 中已下架的 skill(默认只提示、不删)。
95
97
 
96
98
  ## 配置
97
99
 
@@ -155,6 +157,32 @@ export { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"
155
157
 
156
158
  > 包根入口是 V2 插件,需要 OpenCode 2.x;CLI 两条线都能用。
157
159
 
160
+ ## MCP server(Claude Code、Codex、Kimi Code、任何 MCP 客户端)
161
+
162
+ `skills-doctor mcp` 用 stdio 讲 Model Context Protocol,只暴露一个工具:`skills_doctor`,参数 `directory?`、`budget?`、`lang?`、`global?`。不需要装 OpenCode。
163
+
164
+ Claude Code(项目里的 `.mcp.json`,或你的 MCP 配置):
165
+
166
+ ```json
167
+ {
168
+ "mcpServers": {
169
+ "skills-doctor": { "command": "npx", "args": ["-y", "opencode-skills-doctor", "mcp"] }
170
+ }
171
+ }
172
+ ```
173
+
174
+ Codex(`~/.codex/config.toml`):
175
+
176
+ ```toml
177
+ [mcp_servers.skills-doctor]
178
+ command = "npx"
179
+ args = ["-y", "opencode-skills-doctor", "mcp"]
180
+ ```
181
+
182
+ 其他 MCP 客户端:启动 `npx -y opencode-skills-doctor mcp` 并允许 `skills_doctor` 工具即可。
183
+
184
+ 它扫描 `.opencode/skills`、`.claude/skills`、`.agents/skills`(项目和全局),所以其他 agent 用的 skill 也能一起体检。
185
+
158
186
  ## 检测规则
159
187
 
160
188
  | 规则 | 级别 | 含义 |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
- {
1
+ {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.3.5",
4
- "description": "Audit, fix and update OpenCode skills: measure per-step context cost, catch shadowed duplicates, archive safely and sync from catalogs. Works on OpenCode 1.x and 2.x.",
3
+ "version": "0.3.7",
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": {
7
7
  ".": "./src/plugin.mjs",
@@ -40,6 +40,8 @@
40
40
  "keywords": [
41
41
  "opencode",
42
42
  "skills",
43
+ "agent-skills",
44
+ "mcp",
43
45
  "lint",
44
46
  "fix",
45
47
  "cli",
package/src/catalog.mjs CHANGED
@@ -68,6 +68,20 @@ function lockEntryVersion(entry) {
68
68
  return null
69
69
  }
70
70
 
71
+ function pruneEmptyDirs(dir, stopDir) {
72
+ const stop = path.resolve(stopDir)
73
+ let current = path.resolve(dir)
74
+ while (current !== stop && current.startsWith(stop + path.sep)) {
75
+ try {
76
+ if (fs.readdirSync(current).length > 0) return
77
+ fs.rmdirSync(current)
78
+ } catch {
79
+ return
80
+ }
81
+ current = path.dirname(current)
82
+ }
83
+ }
84
+
71
85
  /** 本地文件是否仍与 lock 里的哈希一致(无哈希时视为一致)。 */
72
86
  function filesMatch(into, name, files, t) {
73
87
  if (!files || typeof files !== "object") return true
@@ -93,14 +107,27 @@ export async function fetchCatalogIndex(url, { fetchImpl = fetch, timeoutMs = 15
93
107
  * 更新器:对比 HTTP catalog 的 version,必要时下载到 into 目录并写 lock。
94
108
  * lock 里记录每个文件的 sha1,检测器据此判断「更新后是否被改过」。
95
109
  */
96
- export async function updateCatalogs(catalogs, { into = process.cwd(), checkOnly = false, fetchImpl = fetch, t = translator("en") } = {}) {
110
+ export async function updateCatalogs(
111
+ catalogs,
112
+ { into = process.cwd(), checkOnly = false, fetchImpl = fetch, t = translator("en"), prune = false } = {},
113
+ ) {
97
114
  const lockPath = path.join(into, LOCK_NAME)
98
115
  const lock = readLockFile(into) ?? { version: LOCK_VERSION, catalogs: {} }
99
116
  const lockTooNew = Number(lock.version ?? 1) > LOCK_VERSION
100
117
  const results = []
101
118
 
102
119
  for (const catalog of catalogs) {
103
- const result = { url: catalog.url, error: null, updated: [], unchanged: [], invalid: [], files: 0 }
120
+ const result = {
121
+ url: catalog.url,
122
+ error: null,
123
+ updated: [],
124
+ unchanged: [],
125
+ invalid: [],
126
+ orphaned: [],
127
+ pruned: [],
128
+ removedFiles: [],
129
+ files: 0,
130
+ }
104
131
  try {
105
132
  const { base, skills } = await fetchCatalogIndex(catalog.url, { fetchImpl, t })
106
133
  const baseUrl = new URL(base + "/")
@@ -144,10 +171,43 @@ export async function updateCatalogs(catalogs, { into = process.cwd(), checkOnly
144
171
  hashes[file] = sha1Buffer(buffer)
145
172
  result.files++
146
173
  }
174
+
175
+ // 新版本里已被删除的文件:从本地清掉,避免旧文件残留
176
+ if (prevHashes) {
177
+ for (const oldFile of Object.keys(prevHashes)) {
178
+ if (entry.files.includes(oldFile)) continue
179
+ const target = resolveInside(path.join(into, entry.name), oldFile, t)
180
+ if (!fs.existsSync(target)) continue
181
+ fs.rmSync(target, { force: true })
182
+ result.removedFiles.push({ name: entry.name, file: oldFile })
183
+ pruneEmptyDirs(path.dirname(target), path.join(into, entry.name))
184
+ }
185
+ }
186
+
147
187
  nextSkills[entry.name] = { version, files: hashes }
148
188
  result.updated.push({ name: entry.name, version, previous: previousVersion, reason: drifted ? "drift" : null })
149
189
  }
150
190
 
191
+ const validNames = new Set(
192
+ skills.filter((entry) => validateCatalogEntry(entry, t).length === 0).map((entry) => entry.name),
193
+ )
194
+ const orphaned = Object.keys(previousSkills).filter((name) => !validNames.has(name))
195
+ result.orphaned = orphaned
196
+
197
+ if (!checkOnly) {
198
+ if (prune) {
199
+ for (const name of orphaned) {
200
+ const dir = resolveInside(into, name, t)
201
+ if (!fs.existsSync(dir)) continue
202
+ fs.rmSync(dir, { recursive: true, force: true })
203
+ result.pruned.push(name)
204
+ }
205
+ } else {
206
+ // 不 prune 时保留 lock 记录,方便下次继续跟踪
207
+ for (const name of orphaned) nextSkills[name] = previousSkills[name]
208
+ }
209
+ }
210
+
151
211
  if (!checkOnly) lock.catalogs[catalog.url] = { skills: nextSkills }
152
212
  } catch (err) {
153
213
  result.error = String(err?.message ?? err)
package/src/cli.mjs CHANGED
@@ -7,10 +7,13 @@ import { inspectManaged } from "./managed.mjs"
7
7
  import { planFixes, applyFixes, hideSkills } from "./fix.mjs"
8
8
  import { auditSkills } from "./audit.mjs"
9
9
  import { loadConfig } from "./config.mjs"
10
+ import { startMcpServer } from "./mcp.mjs"
10
11
  import { padDisplay } from "./parse.mjs"
11
12
  import { detectLang, normalizeLang, translator } from "./i18n.mjs"
12
13
 
13
- const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"))
14
+ const pkg = JSON.parse(
15
+ fs.readFileSync(new URL("../package.json", import.meta.url), "utf8").replace(/^\uFEFF/, ""),
16
+ )
14
17
 
15
18
  const color = process.stdout.isTTY && !process.env.NO_COLOR
16
19
  const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text)
@@ -52,12 +55,13 @@ function parseArgs(argv) {
52
55
  hide: [],
53
56
  into: null,
54
57
  checkOnly: false,
58
+ prune: false,
55
59
  help: false,
56
60
  version: false,
57
61
  }
58
62
 
59
63
  const args = [...argv]
60
- const commands = new Set(["check", "scan", "update", "fix", "help"])
64
+ const commands = new Set(["check", "scan", "update", "fix", "mcp", "help"])
61
65
  if (args[0] && commands.has(args[0])) {
62
66
  options.command = args.shift()
63
67
  }
@@ -109,6 +113,9 @@ function parseArgs(argv) {
109
113
  case "--check-only":
110
114
  options.checkOnly = true
111
115
  break
116
+ case "--prune":
117
+ options.prune = true
118
+ break
112
119
  case "-h":
113
120
  case "--help":
114
121
  options.help = true
@@ -296,11 +303,12 @@ async function runUpdate(discovered, options, cwd, t) {
296
303
  const { lockPath, results, lockTooNew } = await updateCatalogs(discovered.catalogs, {
297
304
  into,
298
305
  checkOnly: options.checkOnly,
306
+ prune: options.prune,
299
307
  t,
300
308
  })
301
309
 
302
310
  if (options.json) {
303
- console.log(JSON.stringify({ lockPath, checkOnly: options.checkOnly, results }, null, 2))
311
+ console.log(JSON.stringify({ lockPath, checkOnly: options.checkOnly, prune: options.prune, results }, null, 2))
304
312
  return results.some((r) => r.error) ? 1 : 0
305
313
  }
306
314
 
@@ -326,6 +334,12 @@ async function runUpdate(discovered, options, cwd, t) {
326
334
  }
327
335
  if (result.unchanged.length) console.log(dim(t("update.unchanged", { count: result.unchanged.length })))
328
336
  if (result.files) console.log(dim(t("update.files", { count: result.files })))
337
+ if (result.removedFiles.length) console.log(dim(t("update.removedFiles", { count: result.removedFiles.length })))
338
+ if (result.pruned.length) {
339
+ console.log(yellow(t("update.pruned", { count: result.pruned.length, names: result.pruned.join(", ") })))
340
+ } else if (result.orphaned.length) {
341
+ console.log(yellow(t("update.orphaned", { count: result.orphaned.length })))
342
+ }
329
343
  for (const invalid of result.invalid) {
330
344
  console.log(yellow(t("update.skipped", { name: invalid.name, problems: invalid.problems.join("; ") })))
331
345
  }
@@ -425,6 +439,11 @@ export async function main(argv) {
425
439
  return
426
440
  }
427
441
 
442
+ if (options.command === "mcp") {
443
+ startMcpServer({ version: pkg.version })
444
+ return
445
+ }
446
+
428
447
  if (options.command === "check") {
429
448
  const audit = auditSkills({
430
449
  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 mcp run as an MCP server (Claude Code, Codex, any MCP client)
13
14
 
14
15
  Options:
15
16
  --root <dir> working directory (default: current directory)
@@ -25,6 +26,7 @@ Options:
25
26
  --hide <ids> fix: hide skills with metadata.opencode/autoinvoke: false (comma separated)
26
27
  --into <dir> update: output directory (default: .opencode/skills)
27
28
  --check-only update: only report available updates
29
+ --prune update: delete skills that disappeared from the catalog
28
30
  -h, --help show this help
29
31
  -v, --version print version
30
32
 
@@ -68,6 +70,9 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
68
70
  "update.files": " · files written {count}",
69
71
  "update.skipped": " ! skipped {name}: {problems}",
70
72
  "update.empty": " · catalog is empty",
73
+ "update.removedFiles": " · removed {count} stale files",
74
+ "update.pruned": " · pruned {count} skills no longer in the catalog: {names}",
75
+ "update.orphaned": " ! {count} skills are no longer in the catalog (use --prune to delete them)",
71
76
  "update.lock": " lock: {path} ({name})",
72
77
  "update.lockTooNew": " ! lock is newer than this tool; parsed in compatibility mode (unknown fields ignored)",
73
78
  "update.detector": " check now sees these skills ({dir} is where opencode reads)",
@@ -151,6 +156,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
151
156
  skills-doctor scan [选项] 列出所有 skill 及其来源/优先级
152
157
  skills-doctor fix [选项] 归档被遮蔽的定义(安全的结构性修复)
153
158
  skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
159
+ skills-doctor mcp 以 MCP server 方式运行(Claude Code、Codex 等任何 MCP 客户端)
154
160
 
155
161
  选项:
156
162
  --root <dir> 以该目录为工作目录(默认当前目录)
@@ -166,6 +172,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
166
172
  --hide <ids> fix: 给指定 skill 加 metadata.opencode/autoinvoke: false(逗号分隔)
167
173
  --into <dir> update 的落盘目录(默认 .opencode/skills)
168
174
  --check-only update 只报告可用更新,不下载
175
+ --prune update: 删除 catalog 中已移除的 skill
169
176
  -h, --help 帮助
170
177
  -v, --version 版本
171
178
 
@@ -209,6 +216,9 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
209
216
  "update.files": " · 写入文件 {count}",
210
217
  "update.skipped": " ! 跳过 {name}: {problems}",
211
218
  "update.empty": " · catalog 为空",
219
+ "update.removedFiles": " · 删除旧版本残留文件 {count} 个",
220
+ "update.pruned": " · 清理 catalog 中已移除的 skill {count} 个: {names}",
221
+ "update.orphaned": " ! {count} 个 skill 已不在 catalog 中(加 --prune 可删除本地残留)",
212
222
  "update.lock": " lock: {path}({name})",
213
223
  "update.lockTooNew": " ! lock 版本比本工具新,已按兼容模式解析(未知字段忽略)",
214
224
  "update.detector": " check 现在可以直接检测到这些 skill({dir} 是 opencode 会读取的目录)",
package/src/mcp.mjs ADDED
@@ -0,0 +1,150 @@
1
+ import { auditSkills, formatMarkdown } from "./audit.mjs"
2
+ import { normalizeLang, detectLang } from "./i18n.mjs"
3
+
4
+ export const PROTOCOL_VERSION = "2025-06-18"
5
+ export const SERVER_NAME = "skills-doctor"
6
+ export const TOOL_NAME = "skills_doctor"
7
+
8
+ export function toolDefinition() {
9
+ return {
10
+ name: TOOL_NAME,
11
+ description:
12
+ "Audit local agent skills (OpenCode / Claude Code / .agents): duplicate and shadowed definitions, per-step description token budget, oversized bodies and fix hints. Returns a markdown report.",
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: {
16
+ directory: {
17
+ type: "string",
18
+ description: "Directory to audit (default: the server process working directory)",
19
+ },
20
+ budget: {
21
+ type: "number",
22
+ description: "Description token budget per model step (default 1200)",
23
+ },
24
+ lang: {
25
+ type: "string",
26
+ enum: ["en", "zh"],
27
+ description: "Report language (default: auto from system locale)",
28
+ },
29
+ global: {
30
+ type: "boolean",
31
+ description: "Include global skill directories such as ~/.config/opencode and ~/.claude (default true)",
32
+ },
33
+ },
34
+ additionalProperties: false,
35
+ },
36
+ }
37
+ }
38
+
39
+ function ok(id, result) {
40
+ return { jsonrpc: "2.0", id, result }
41
+ }
42
+
43
+ function fail(id, code, message) {
44
+ return { jsonrpc: "2.0", id, error: { code, message } }
45
+ }
46
+
47
+ /** 处理一条 JSON-RPC 消息;通知返回 null。 */
48
+ export async function handleMessage(message, { version = "0.0.0" } = {}) {
49
+ if (!message || typeof message !== "object" || typeof message.method !== "string") return null
50
+ const { id, method, params } = message
51
+ const isNotification = id === undefined || id === null
52
+
53
+ try {
54
+ switch (method) {
55
+ case "initialize":
56
+ return ok(id, {
57
+ protocolVersion: PROTOCOL_VERSION,
58
+ capabilities: { tools: { listChanged: false } },
59
+ serverInfo: { name: SERVER_NAME, version },
60
+ })
61
+
62
+ case "notifications/initialized":
63
+ case "notifications/cancelled":
64
+ return null
65
+
66
+ case "ping":
67
+ return ok(id, {})
68
+
69
+ case "tools/list":
70
+ return ok(id, { tools: [toolDefinition()] })
71
+
72
+ case "resources/list":
73
+ return ok(id, { resources: [] })
74
+
75
+ case "prompts/list":
76
+ return ok(id, { prompts: [] })
77
+
78
+ case "tools/call": {
79
+ const name = params?.name
80
+ if (name !== TOOL_NAME) return fail(id, -32602, `unknown tool: ${name}`)
81
+
82
+ const args = params?.arguments && typeof params.arguments === "object" ? params.arguments : {}
83
+ const directory =
84
+ typeof args.directory === "string" && args.directory.trim() ? args.directory.trim() : process.cwd()
85
+ const budget = typeof args.budget === "number" && args.budget > 0 ? args.budget : null
86
+ const lang = normalizeLang(args.lang) ?? detectLang()
87
+
88
+ const audit = auditSkills({ cwd: directory, budget, lang, includeGlobal: args.global !== false })
89
+ return ok(id, { content: [{ type: "text", text: formatMarkdown(audit) }] })
90
+ }
91
+
92
+ default:
93
+ if (isNotification) return null
94
+ return fail(id, -32601, `method not found: ${method}`)
95
+ }
96
+ } catch (err) {
97
+ if (isNotification) return null
98
+ return fail(id, -32603, String(err?.message ?? err))
99
+ }
100
+ }
101
+
102
+ /** stdio transport:newline-delimited JSON-RPC(MCP 标准)。 */
103
+ export function startMcpServer({ version, input = process.stdin, output = process.stdout, onExit = () => process.exit(0) } = {}) {
104
+ let buffer = ""
105
+ let queue = Promise.resolve()
106
+
107
+ const write = (payload) => {
108
+ output.write(JSON.stringify(payload) + "\n")
109
+ }
110
+
111
+ const handleLine = (line) => {
112
+ let message
113
+ try {
114
+ message = JSON.parse(line)
115
+ } catch {
116
+ return
117
+ }
118
+ queue = queue
119
+ .then(() => handleMessage(message, { version }))
120
+ .then((response) => {
121
+ if (response) write(response)
122
+ })
123
+ .catch((err) => {
124
+ write(fail(message?.id ?? null, -32603, String(err?.message ?? err)))
125
+ })
126
+ }
127
+
128
+ input.setEncoding?.("utf8")
129
+ input.on("data", (chunk) => {
130
+ buffer += chunk
131
+ let index
132
+ while ((index = buffer.indexOf("\n")) >= 0) {
133
+ const line = buffer.slice(0, index).trim()
134
+ buffer = buffer.slice(index + 1)
135
+ if (line) handleLine(line)
136
+ }
137
+ })
138
+
139
+ let finished = false
140
+ const finish = () => {
141
+ if (finished) return
142
+ finished = true
143
+ // 等队列里的异步响应写完再退出,避免管道结束时丢包
144
+ queue.finally(onExit)
145
+ }
146
+ input.on("end", finish)
147
+ input.on("close", finish)
148
+
149
+ return { write, close: finish }
150
+ }