opencode-skills-doctor 0.3.15 → 0.4.1

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/src/html.mjs ADDED
@@ -0,0 +1,167 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { spawn } from "node:child_process"
4
+
5
+ function esc(value) {
6
+ return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char])
7
+ }
8
+
9
+ const SEVERITY_LABEL = { error: "error", warn: "warn", info: "info" }
10
+
11
+ /** 生成自包含 HTML 报告(无外部资源,可直接分享/作 CI artifact)。 */
12
+ export function renderHtml(audit, { title = "skills-doctor report", now = new Date() } = {}) {
13
+ const summary = audit.summary
14
+ const counts = audit.counts
15
+ const budget = audit.budget
16
+ const ratio = budget > 0 ? Math.min(1.5, summary.advertisedTokens / (budget * 2)) : 0
17
+ const barWidth = Math.min(100, Math.round(ratio * 100))
18
+ const barClass = summary.advertisedTokens > budget * 2 ? "over" : summary.advertisedTokens > budget ? "warn" : "ok"
19
+
20
+ const findings = audit.findings
21
+ .map(
22
+ (finding) => `<tr class="finding ${finding.severity}" data-severity="${finding.severity}">
23
+ <td><span class="badge ${finding.severity}">${SEVERITY_LABEL[finding.severity] ?? finding.severity}</span></td>
24
+ <td class="rule">${esc(finding.rule)}</td>
25
+ <td>${esc(finding.message)}</td>
26
+ </tr>`,
27
+ )
28
+ .join("\n")
29
+
30
+ const skills = audit.skills
31
+ .map(
32
+ (skill) => `<tr class="skill">
33
+ <td class="mono">${esc(skill.id)}</td>
34
+ <td>${esc(skill.source.scope)}</td>
35
+ <td>${skill.advertised ? "yes" : "no"}</td>
36
+ <td class="num">${skill.descriptionTokens}</td>
37
+ <td class="num">${skill.bodyTokens}</td>
38
+ <td class="desc">${esc(String(skill.description ?? "").slice(0, 80))}</td>
39
+ <td class="mono path">${esc(skill.file)}</td>
40
+ </tr>`,
41
+ )
42
+ .join("\n")
43
+
44
+ return `<!doctype html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="utf-8">
48
+ <meta name="viewport" content="width=device-width, initial-scale=1">
49
+ <title>${esc(title)}</title>
50
+ <style>
51
+ :root { color-scheme: light dark; --fg:#1f2328; --muted:#656d76; --bg:#fff; --card:#f6f8fa; --border:#d0d7de; --red:#cf222e; --yellow:#9a6700; --green:#1a7f37; --blue:#0969da; }
52
+ @media (prefers-color-scheme: dark) { :root { --fg:#e6edf3; --muted:#8d96a0; --bg:#0d1117; --card:#161b22; --border:#30363d; --red:#ff7b72; --yellow:#d29922; --green:#3fb950; --blue:#58a6ff; } }
53
+ * { box-sizing: border-box; }
54
+ body { margin:0; padding:32px; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
55
+ h1 { margin:0 0 4px; font-size:24px; }
56
+ h2 { margin:32px 0 12px; font-size:18px; }
57
+ .sub { color:var(--muted); font-size:12px; }
58
+ .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:20px 0; }
59
+ .card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px 14px; }
60
+ .card .k { color:var(--muted); font-size:12px; }
61
+ .card .v { font-size:22px; font-weight:600; margin-top:2px; }
62
+ .card.error .v { color:var(--red); } .card.warn .v { color:var(--yellow); } .card.info .v { color:var(--blue); }
63
+ .bar { height:10px; border-radius:6px; background:var(--card); border:1px solid var(--border); overflow:hidden; margin:8px 0 4px; }
64
+ .bar > span { display:block; height:100%; width:${barWidth}%; }
65
+ .bar > span.ok { background:var(--green); } .bar > span.warn { background:var(--yellow); } .bar > span.over { background:var(--red); }
66
+ table { width:100%; border-collapse:collapse; background:var(--bg); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
67
+ th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
68
+ th { background:var(--card); font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.03em; }
69
+ tr:last-child td { border-bottom:none; }
70
+ .badge { display:inline-block; padding:1px 8px; border-radius:999px; font-size:11px; font-weight:600; border:1px solid currentColor; }
71
+ .badge.error { color:var(--red); } .badge.warn { color:var(--yellow); } .badge.info { color:var(--blue); }
72
+ .rule { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; white-space:nowrap; }
73
+ .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
74
+ .path { color:var(--muted); word-break:break-all; }
75
+ .num { text-align:right; font-variant-numeric:tabular-nums; }
76
+ .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin:12px 0; }
77
+ .toolbar button, .toolbar input { font:inherit; padding:6px 10px; border-radius:8px; border:1px solid var(--border); background:var(--card); color:var(--fg); }
78
+ .toolbar button.active { border-color:var(--blue); color:var(--blue); }
79
+ .empty { color:var(--muted); padding:16px; }
80
+ footer { margin-top:40px; color:var(--muted); font-size:12px; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <h1>${esc(title)}</h1>
85
+ <div class="sub">generated ${esc(now.toISOString())} · ${esc(audit.cwd)}</div>
86
+
87
+ <div class="cards">
88
+ <div class="card"><div class="k">definitions</div><div class="v">${summary.total}</div></div>
89
+ <div class="card"><div class="k">unique IDs</div><div class="v">${summary.unique}</div></div>
90
+ <div class="card"><div class="k">advertised</div><div class="v">${summary.advertised}</div></div>
91
+ <div class="card"><div class="k">description tokens / step</div><div class="v">${summary.advertisedTokens}</div></div>
92
+ <div class="card error"><div class="k">errors</div><div class="v">${counts.errors}</div></div>
93
+ <div class="card warn"><div class="k">warnings</div><div class="v">${counts.warnings}</div></div>
94
+ <div class="card info"><div class="k">info</div><div class="v">${counts.infos}</div></div>
95
+ </div>
96
+
97
+ <h2>Context budget</h2>
98
+ <div class="bar"><span class="${barClass}"></span></div>
99
+ <div class="sub">${summary.advertisedTokens} tokens vs budget ${budget}${summary.advertisedTokens > budget * 2 ? " (over 2x)" : summary.advertisedTokens > budget ? " (over)" : " (ok)"}</div>
100
+
101
+ <h2>Findings (${audit.findings.length})</h2>
102
+ <div class="toolbar">
103
+ <button data-filter="all" class="active">all</button>
104
+ <button data-filter="error">error</button>
105
+ <button data-filter="warn">warn</button>
106
+ <button data-filter="info">info</button>
107
+ <input id="q" placeholder="filter text…" />
108
+ </div>
109
+ <table id="findings">
110
+ <thead><tr><th>severity</th><th>rule</th><th>message</th></tr></thead>
111
+ <tbody>
112
+ ${findings || '<tr><td colspan="3" class="empty">No problems found.</td></tr>'}
113
+ </tbody>
114
+ </table>
115
+
116
+ <h2>Skills (${audit.skills.length})</h2>
117
+ <table>
118
+ <thead><tr><th>ID</th><th>scope</th><th>advertised</th><th>desc tokens</th><th>body tokens</th><th>description</th><th>file</th></tr></thead>
119
+ <tbody>
120
+ ${skills}
121
+ </tbody>
122
+ </table>
123
+
124
+ <footer>skills-doctor · ${esc(audit.version ?? "")} · updates: re-run <code>skills-doctor check --html</code></footer>
125
+
126
+ <script>
127
+ const buttons = [...document.querySelectorAll("[data-filter]")]
128
+ const q = document.getElementById("q")
129
+ let active = "all"
130
+ function apply() {
131
+ const text = (q.value || "").toLowerCase()
132
+ for (const row of document.querySelectorAll("tr.finding")) {
133
+ const okSeverity = active === "all" || row.dataset.severity === active
134
+ const okText = !text || row.textContent.toLowerCase().includes(text)
135
+ row.style.display = okSeverity && okText ? "" : "none"
136
+ }
137
+ }
138
+ for (const button of buttons) {
139
+ button.addEventListener("click", () => {
140
+ active = button.dataset.filter
141
+ for (const other of buttons) other.classList.toggle("active", other === button)
142
+ apply()
143
+ })
144
+ }
145
+ q.addEventListener("input", apply)
146
+ </script>
147
+ </body>
148
+ </html>
149
+ `
150
+ }
151
+
152
+ export function writeHtmlReport(audit, file, options = {}) {
153
+ const target = path.resolve(file)
154
+ fs.mkdirSync(path.dirname(target), { recursive: true })
155
+ fs.writeFileSync(target, renderHtml(audit, options), "utf8")
156
+ return target
157
+ }
158
+
159
+ /** 用系统默认程序打开报告;失败时静默(调用方已经知道路径)。 */
160
+ export function openReport(file) {
161
+ const target = path.resolve(file)
162
+ try {
163
+ if (process.platform === "win32") spawn("cmd", ["/c", "start", "", target], { detached: true, stdio: "ignore" }).unref()
164
+ else if (process.platform === "darwin") spawn("open", [target], { detached: true, stdio: "ignore" }).unref()
165
+ else spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref()
166
+ } catch {}
167
+ }
package/src/i18n.mjs CHANGED
@@ -12,6 +12,7 @@ Usage:
12
12
  skills-doctor update [options] compare and download skills from HTTP catalogs
13
13
  skills-doctor install [options] wire the plugin into OpenCode (1.x / 2.x)
14
14
  skills-doctor uninstall [options] remove it again (config entry, generated files, package)
15
+ skills-doctor upgrade [options] check for a newer skills-doctor itself (--apply installs it)
15
16
  skills-doctor mcp run as an MCP server (Claude Code, Codex, any MCP client)
16
17
 
17
18
  Options:
@@ -29,8 +30,12 @@ Options:
29
30
  --into <dir> update: output directory (default: .opencode/skills)
30
31
  --check-only update: only report available updates
31
32
  --prune update: delete skills that disappeared from the catalog
33
+ --html [file] check: write a self-contained HTML report (default: skills-report.html)
34
+ --open check: open the HTML report after writing it
32
35
  --config-dir <dir> install/uninstall: OpenCode config directory (default: ~/.config/opencode)
33
36
  --no-npm install/uninstall: do not run npm (1.x)
37
+ --apply upgrade: run npm install -g opencode-skills-doctor@latest
38
+ --no-update-check skip the automatic update check for this run
34
39
  -h, --help show this help
35
40
  -v, --version print version
36
41
 
@@ -47,6 +52,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
47
52
  "check.configError": " ! cannot parse config: {path} ({error})",
48
53
  "check.missingPath": " ! path in config does not exist: {path}",
49
54
  "check.none": "No problems found.",
55
+ "check.htmlSaved": " HTML report: {path}",
50
56
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
51
57
  "check.strict": " (--strict: warnings also fail)",
52
58
  "check.hints": "Hints:",
@@ -87,6 +93,16 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
87
93
  "install.next": "Restart OpenCode to activate.",
88
94
  "uninstall.title": "Remove OpenCode integration",
89
95
  "uninstall.next": "Restart OpenCode to finish removal.",
96
+ "update.toolAvailable":
97
+ " ! skills-doctor {latest} is available (you have {current}); update with: {cmd} · {url}",
98
+ "upgrade.title": "skills-doctor upgrade",
99
+ "upgrade.current": " current: {version}",
100
+ "upgrade.available": "New version available: {latest} (current {current})",
101
+ "upgrade.command": " upgrade with: {cmd}",
102
+ "upgrade.open": " release notes: {url}",
103
+ "upgrade.upToDate": "Already up to date.",
104
+ "upgrade.done": "Upgraded to the latest version.",
105
+ "upgrade.failed": "Upgrade failed; run it manually: {cmd}",
90
106
  "update.lockTooNew": " ! lock is newer than this tool; parsed in compatibility mode (unknown fields ignored)",
91
107
  "update.detector": " check now sees these skills ({dir} is where opencode reads)",
92
108
  "update.noCatalogs": "No HTTP catalog configured. Add https://... to the skills array in opencode.json(c).",
@@ -121,6 +137,10 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
121
137
  "hint.invalid-id": "Use lowercase kebab-case and keep it aligned with the directory name.",
122
138
  "hint.hidden-skill": "Confirm the skill is intentionally hidden; hidden skills are not advertised.",
123
139
  "hint.stale-skill": "Confirm the content is still current, or delete it.",
140
+ "hint.invalid-frontmatter": "Fix the YAML frontmatter: closing ---, string name/description, object metadata.",
141
+ "hint.empty-body": "Add real instructions to the body, or delete the skill.",
142
+ "hint.broken-reference": "Fix the path or remove the reference; missing files silently break the skill.",
143
+ "hint.reference-outside-skill": "Keep referenced files inside the skill directory so the skill stays portable.",
124
144
 
125
145
  "lint.shadowed":
126
146
  'ID "{id}" is defined {count} times; the effective definition is {winner}; the other {losers} are shadowed (dead weight, easy to edit the wrong file)',
@@ -140,6 +160,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
140
160
  "lint.tokenBudgetError":
141
161
  "Advertised skill descriptions cost about {tokens} tokens per model step, over 2x the {budget} budget",
142
162
  "lint.tooManySkills": "{count} skills are advertised to the model (recommended ≤ 60)",
163
+ "lint.invalidFrontmatter": '"{id}" has malformed frontmatter: {detail}',
164
+ "lint.frontmatterUnclosed": "missing closing ---",
165
+ "lint.frontmatterTypes": "name/description/metadata have wrong types",
166
+ "lint.emptyBody": '"{id}" body is nearly empty ({chars} chars)',
167
+ "lint.brokenReference": '"{id}" references missing files: {refs}',
168
+ "lint.referenceOutside": '"{id}" references files outside its skill directory: {refs}',
143
169
 
144
170
  "managed.lockUnsupported":
145
171
  "Lock version {version} is newer than the supported {supported}; parsing in compatibility mode (unknown fields ignored)",
@@ -171,6 +197,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
171
197
  skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
172
198
  skills-doctor install [选项] 把插件接入 OpenCode(1.x / 2.x)
173
199
  skills-doctor uninstall [选项] 撤销安装(配置条目、生成的文件、npm 包)
200
+ skills-doctor upgrade [选项] 检查 skills-doctor 自身是否有新版本(--apply 直接装)
174
201
  skills-doctor mcp 以 MCP server 方式运行(Claude Code、Codex 等任何 MCP 客户端)
175
202
 
176
203
  选项:
@@ -188,8 +215,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
188
215
  --into <dir> update 的落盘目录(默认 .opencode/skills)
189
216
  --check-only update 只报告可用更新,不下载
190
217
  --prune update: 删除 catalog 中已移除的 skill
218
+ --html [file] check: 生成自包含 HTML 报告(默认 skills-report.html)
219
+ --open check: 写完报告后用浏览器打开
191
220
  --config-dir <dir> install/uninstall: OpenCode 配置目录(默认 ~/.config/opencode)
192
221
  --no-npm install/uninstall: 不执行 npm(1.x)
222
+ --apply upgrade: 执行 npm install -g opencode-skills-doctor@latest
223
+ --no-update-check 本次运行跳过自动更新检查
193
224
  -h, --help 帮助
194
225
  -v, --version 版本
195
226
 
@@ -206,6 +237,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
206
237
  "check.configError": " ! 配置文件无法解析: {path}({error})",
207
238
  "check.missingPath": " ! 配置里引用的路径不存在: {path}",
208
239
  "check.none": "没有发现问题。",
240
+ "check.htmlSaved": " HTML 报告: {path}",
209
241
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
210
242
  "check.strict": "(--strict:warning 也会非 0 退出)",
211
243
  "check.hints": "建议:",
@@ -246,6 +278,15 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
246
278
  "install.next": "重启 OpenCode 生效。",
247
279
  "uninstall.title": "撤销 OpenCode 集成",
248
280
  "uninstall.next": "重启 OpenCode 完成卸载。",
281
+ "update.toolAvailable": " ! skills-doctor 有新版本 {latest}(当前 {current});更新:{cmd} · {url}",
282
+ "upgrade.title": "skills-doctor 自更新",
283
+ "upgrade.current": " 当前版本: {version}",
284
+ "upgrade.available": "发现新版本: {latest}(当前 {current})",
285
+ "upgrade.command": " 更新命令: {cmd}",
286
+ "upgrade.open": " 更新说明: {url}",
287
+ "upgrade.upToDate": "已是最新版本。",
288
+ "upgrade.done": "已升级到最新版本。",
289
+ "upgrade.failed": "升级失败,请手动执行: {cmd}",
249
290
  "update.lockTooNew": " ! lock 版本比本工具新,已按兼容模式解析(未知字段忽略)",
250
291
  "update.detector": " check 现在可以直接检测到这些 skill({dir} 是 opencode 会读取的目录)",
251
292
  "update.noCatalogs": "没有配置 HTTP catalog。在 opencode.json(c) 的 skills 数组里写 https://... 即可。",
@@ -278,6 +319,10 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
278
319
  "hint.invalid-id": "改成小写 kebab-case,并与目录名对齐。",
279
320
  "hint.hidden-skill": "确认是刻意隐藏;隐藏的 skill 不会出现在模型列表里。",
280
321
  "hint.stale-skill": "确认内容没过期,或删掉。",
322
+ "hint.invalid-frontmatter": "修好 frontmatter:闭合的 ---、字符串形式的 name/description、对象形式的 metadata。",
323
+ "hint.empty-body": "给正文补上真正的说明,否则删掉这个 skill。",
324
+ "hint.broken-reference": "修正路径或删掉引用;缺失的文件会让 skill 静默失效。",
325
+ "hint.reference-outside-skill": "把被引用的文件放进 skill 目录,保证 skill 可移植。",
281
326
 
282
327
  "lint.shadowed":
283
328
  'ID "{id}" 有 {count} 处定义,实际生效的是 {winner};其余 {losers} 处被遮蔽(白占体积、容易改错文件)',
@@ -293,6 +338,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
293
338
  "lint.tokenBudget": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget}",
294
339
  "lint.tokenBudgetError": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget} 的 2 倍",
295
340
  "lint.tooManySkills": "有 {count} 个 skill 会展示给模型(建议 ≤ 60)",
341
+ "lint.invalidFrontmatter": '"{id}" 的 frontmatter 格式有问题:{detail}',
342
+ "lint.frontmatterUnclosed": "缺少闭合的 ---",
343
+ "lint.frontmatterTypes": "name/description/metadata 类型不对",
344
+ "lint.emptyBody": '"{id}" 正文几乎是空的({chars} 字符)',
345
+ "lint.brokenReference": '"{id}" 引用了不存在的文件:{refs}',
346
+ "lint.referenceOutside": '"{id}" 引用了 skill 目录之外的文件:{refs}',
296
347
 
297
348
  "managed.lockUnsupported":
298
349
  "lock 版本 {version} 高于本工具支持的 {supported},已按兼容模式解析(未知字段忽略)",
package/src/install.mjs CHANGED
@@ -6,7 +6,6 @@ import { stripJsonc } from "./discover.mjs"
6
6
 
7
7
  export const PACKAGE_NAME = "opencode-skills-doctor"
8
8
  const PLUGIN_FILE = "skills-doctor.js"
9
- const COMMAND_FILE = "skills-doctor.md"
10
9
 
11
10
  const V1_PLUGIN_SOURCE = `// Generated by skills-doctor install\n// OpenCode 1.x plugin entry\nexport { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"\n`
12
11
 
@@ -16,6 +15,24 @@ description: Audit local skills with skills-doctor
16
15
  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
16
  `
18
17
 
18
+ const V1_REPORT_COMMAND = `---
19
+ description: Write an HTML skills report to a path you choose
20
+ ---
21
+ Ask the user where to save the HTML report (default ./skills-report.html), then call the skills_doctor tool with the html argument set to that path and tell them where it was written. $ARGUMENTS
22
+ `
23
+
24
+ const V1_UPGRADE_COMMAND = `---
25
+ description: Check whether skills-doctor itself has a newer version
26
+ ---
27
+ Run \`npx -y opencode-skills-doctor upgrade\` in the terminal (add --apply only after the user confirms), then report whether a newer version exists. $ARGUMENTS
28
+ `
29
+
30
+ const V1_COMMANDS = [
31
+ { file: "skills-doctor.md", body: V1_COMMAND },
32
+ { file: "skills-report.md", body: V1_REPORT_COMMAND },
33
+ { file: "skills-upgrade.md", body: V1_UPGRADE_COMMAND },
34
+ ]
35
+
19
36
  export function defaultConfigDir() {
20
37
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
21
38
  return path.join(base, "opencode")
@@ -139,7 +156,6 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
139
156
 
140
157
  // 1.x
141
158
  const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
142
- const commandFile = path.join(dir, "command", COMMAND_FILE)
143
159
  const pluginPath = pluginFile
144
160
 
145
161
  const hasPackage = fs.existsSync(path.join(dir, "node_modules", PACKAGE_NAME, "package.json"))
@@ -167,14 +183,17 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
167
183
  act(`plugin entry already exists ${pluginFile}`)
168
184
  }
169
185
 
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}`)
186
+ for (const command of V1_COMMANDS) {
187
+ const commandFile = path.join(dir, "command", command.file)
188
+ if (!fs.existsSync(commandFile)) {
189
+ if (!dryRun) {
190
+ fs.mkdirSync(path.dirname(commandFile), { recursive: true })
191
+ fs.writeFileSync(commandFile, command.body, "utf8")
192
+ act(`wrote command ${commandFile}`)
193
+ } else dry(`write command ${commandFile}`)
194
+ } else {
195
+ act(`command already exists ${commandFile}`)
196
+ }
178
197
  }
179
198
 
180
199
  const nextPlugin = ensureUniqueEntry(config.plugin, pluginPath)
@@ -265,7 +284,6 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
265
284
  }
266
285
 
267
286
  const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
268
- const commandFile = path.join(dir, "command", COMMAND_FILE)
269
287
 
270
288
  if (Array.isArray(config.plugin) && config.plugin.includes(pluginFile)) {
271
289
  const next = config.plugin.filter((entry) => entry !== pluginFile)
@@ -294,7 +312,9 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
294
312
  }
295
313
  }
296
314
 
297
- if (fs.existsSync(commandFile)) {
315
+ for (const command of V1_COMMANDS) {
316
+ const commandFile = path.join(dir, "command", command.file)
317
+ if (!fs.existsSync(commandFile)) continue
298
318
  const content = readText(commandFile) ?? ""
299
319
  if (content.includes("skills_doctor tool")) {
300
320
  if (!dryRun) {
package/src/lint.mjs CHANGED
@@ -1,4 +1,7 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
1
3
  import { estimateTokens } from "./parse.mjs"
4
+ import { extractReferences } from "./refs.mjs"
2
5
  import { translator } from "./i18n.mjs"
3
6
 
4
7
  export const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 }
@@ -117,6 +120,49 @@ export function lint(skills, options = {}) {
117
120
  add("invalid-id", "info", t("lint.invalidId", { id: skill.id }), { skills: [skill.id] })
118
121
  }
119
122
 
123
+ if (skill.frontmatterBroken) {
124
+ add("invalid-frontmatter", "warn", t("lint.invalidFrontmatter", { id: skill.id, detail: t("lint.frontmatterUnclosed") }), {
125
+ skills: [skill.id],
126
+ })
127
+ } else if (skill.frontmatterInvalid) {
128
+ add("invalid-frontmatter", "warn", t("lint.invalidFrontmatter", { id: skill.id, detail: t("lint.frontmatterTypes") }), {
129
+ skills: [skill.id],
130
+ })
131
+ }
132
+
133
+ if (skill.bodyChars < 20) {
134
+ add("empty-body", "info", t("lint.emptyBody", { id: skill.id, chars: skill.bodyChars }), { skills: [skill.id] })
135
+ }
136
+
137
+ // 只审计「skill 本地资源目录」的引用,避免把 npm 包名、项目相对路径误报成断链
138
+ const refs = extractReferences(skill.body).filter((ref) => {
139
+ if (ref.startsWith("@") || ref.endsWith("/")) return false
140
+ return /^(?:\.\/)?(?:references|scripts|assets|templates|examples)\//.test(ref)
141
+ })
142
+ if (refs.length) {
143
+ const base = path.resolve(path.dirname(skill.file))
144
+ const missingRefs = []
145
+ const outsideRefs = []
146
+ for (const ref of refs) {
147
+ const target = path.resolve(base, ref)
148
+ if (target !== base && !target.startsWith(base + path.sep)) outsideRefs.push(ref)
149
+ if (!fs.existsSync(target)) missingRefs.push(ref)
150
+ }
151
+ if (missingRefs.length) {
152
+ add("broken-reference", "warn", t("lint.brokenReference", { id: skill.id, refs: missingRefs.slice(0, 5).join(", ") }), {
153
+ skills: [skill.id],
154
+ })
155
+ }
156
+ if (outsideRefs.length) {
157
+ add(
158
+ "reference-outside-skill",
159
+ "info",
160
+ t("lint.referenceOutside", { id: skill.id, refs: outsideRefs.slice(0, 5).join(", ") }),
161
+ { skills: [skill.id] },
162
+ )
163
+ }
164
+ }
165
+
120
166
  if (skill.bodyTokens > 4000) {
121
167
  add("large-body", "warn", t("lint.largeBody", { id: skill.id, tokens: skill.bodyTokens }), {
122
168
  skills: [skill.id],
package/src/mcp.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { auditSkills, formatMarkdown } from "./audit.mjs"
2
+ import { writeHtmlReport } from "./html.mjs"
2
3
  import { normalizeLang, detectLang } from "./i18n.mjs"
3
4
 
4
5
  export const PROTOCOL_VERSION = "2025-06-18"
@@ -30,6 +31,10 @@ export function toolDefinition() {
30
31
  type: "boolean",
31
32
  description: "Include global skill directories such as ~/.config/opencode and ~/.claude (default true)",
32
33
  },
34
+ html: {
35
+ type: "string",
36
+ description: "Optional path; when set, a self-contained HTML report is written there and the path is returned",
37
+ },
33
38
  },
34
39
  additionalProperties: false,
35
40
  },
@@ -86,7 +91,12 @@ export async function handleMessage(message, { version = "0.0.0" } = {}) {
86
91
  const lang = normalizeLang(args.lang) ?? detectLang()
87
92
 
88
93
  const audit = auditSkills({ cwd: directory, budget, lang, includeGlobal: args.global !== false })
89
- return ok(id, { content: [{ type: "text", text: formatMarkdown(audit) }] })
94
+ let text = formatMarkdown(audit)
95
+ if (typeof args.html === "string" && args.html.trim()) {
96
+ const file = writeHtmlReport(audit, args.html.trim(), { title: "skills-doctor report" })
97
+ text += `\n\nHTML report written to \`${file}\``
98
+ }
99
+ return ok(id, { content: [{ type: "text", text }] })
90
100
  }
91
101
 
92
102
  default:
package/src/plugin-v1.mjs CHANGED
@@ -1,6 +1,25 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
2
  import { auditSkills, formatMarkdown } from "./audit.mjs"
3
+ import { writeHtmlReport } from "./html.mjs"
3
4
  import { budgetWarning } from "./warning.mjs"
5
+ import { loadConfig } from "./config.mjs"
6
+ import { checkForUpdate, resolveMode, toolVersion } from "./self-update.mjs"
7
+
8
+ const VERSION = toolVersion()
9
+ const notifiedSessions = new Set()
10
+
11
+ async function updateNote({ directory, sessionID }) {
12
+ if (!sessionID || notifiedSessions.has(sessionID)) return null
13
+ notifiedSessions.add(sessionID)
14
+ try {
15
+ const { config } = loadConfig(directory)
16
+ const update = await checkForUpdate({ current: VERSION, mode: resolveMode(config) })
17
+ if (!update) return null
18
+ return `skills-doctor ${update.latest} is available (current ${VERSION}). Update: npx -y opencode-skills-doctor@latest upgrade --apply`
19
+ } catch {
20
+ return null
21
+ }
22
+ }
4
23
 
5
24
  const z = tool.schema
6
25
 
@@ -18,17 +37,25 @@ export const SkillsDoctorPlugin = async ({ directory }) => ({
18
37
  args: z.object({
19
38
  directory: z.string().optional().describe("Directory to audit (defaults to the current project)"),
20
39
  budget: z.number().optional().describe("Description token budget (default 1200)"),
40
+ html: z.string().optional().describe("Optional path; when set, a self-contained HTML report is written there"),
21
41
  }),
22
42
  execute: async (args) => {
23
43
  const cwd = args.directory || directory || process.cwd()
24
44
  const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null, lang: "en" })
25
- return formatMarkdown(audit)
45
+ let markdown = formatMarkdown(audit)
46
+ if (typeof args.html === "string" && args.html.trim()) {
47
+ const file = writeHtmlReport(audit, args.html.trim(), { title: "skills-doctor report" })
48
+ markdown += `\n\nHTML report written to \`${file}\``
49
+ }
50
+ return markdown
26
51
  },
27
52
  }),
28
53
  },
29
54
  "experimental.chat.system.transform": async (input, output) => {
30
55
  const warning = budgetWarning({ cwd: directory, sessionID: input?.sessionID })
31
56
  if (warning) output.system.push(warning)
57
+ const note = await updateNote({ directory, sessionID: input?.sessionID })
58
+ if (note) output.system.push(note)
32
59
  },
33
60
  })
34
61
 
package/src/plugin.mjs CHANGED
@@ -1,6 +1,25 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
2
  import { auditSkills, formatMarkdown } from "./audit.mjs"
3
+ import { writeHtmlReport } from "./html.mjs"
3
4
  import { budgetWarning } from "./warning.mjs"
5
+ import { loadConfig } from "./config.mjs"
6
+ import { checkForUpdate, resolveMode, toolVersion } from "./self-update.mjs"
7
+
8
+ const VERSION = toolVersion()
9
+ const notifiedSessions = new Set()
10
+
11
+ async function updateNote({ directory, sessionID }) {
12
+ if (!sessionID || notifiedSessions.has(sessionID)) return null
13
+ notifiedSessions.add(sessionID)
14
+ try {
15
+ const { config } = loadConfig(directory)
16
+ const update = await checkForUpdate({ current: VERSION, mode: resolveMode(config) })
17
+ if (!update) return null
18
+ return `skills-doctor ${update.latest} is available (current ${VERSION}). Update: npx -y opencode-skills-doctor@latest upgrade --apply`
19
+ } catch {
20
+ return null
21
+ }
22
+ }
4
23
 
5
24
  /**
6
25
  * OpenCode 插件入口:把 skills-doctor 暴露成 skills_doctor 工具和 /skills-doctor 命令。
@@ -22,6 +41,10 @@ export default Plugin.define({
22
41
  properties: {
23
42
  directory: { type: "string", description: "Directory to audit (defaults to the current project)" },
24
43
  budget: { type: "number", description: "Description token budget (default 1200)" },
44
+ html: {
45
+ type: "string",
46
+ description: "Optional path; when set, a self-contained HTML report is written there",
47
+ },
25
48
  },
26
49
  additionalProperties: false,
27
50
  },
@@ -30,7 +53,12 @@ export default Plugin.define({
30
53
  const directory = (input && typeof input.directory === "string" && input.directory) || ctx.location.directory
31
54
  const budget = input && typeof input.budget === "number" ? input.budget : null
32
55
  const audit = auditSkills({ cwd: directory, budget, lang: "en" })
33
- return { content: formatMarkdown(audit) }
56
+ let markdown = formatMarkdown(audit)
57
+ if (input && typeof input.html === "string" && input.html.trim()) {
58
+ const file = writeHtmlReport(audit, input.html.trim(), { title: "skills-doctor report" })
59
+ markdown += `\n\nHTML report written to \`${file}\``
60
+ }
61
+ return { content: markdown }
34
62
  },
35
63
  })
36
64
  })
@@ -48,12 +76,38 @@ export default Plugin.define({
48
76
  })
49
77
  },
50
78
  })
79
+ editor.add({
80
+ name: "skills-report",
81
+ description: "Write a self-contained HTML skills report to a path you choose",
82
+ execute: async ({ sessionID, prompt, delivery }) => {
83
+ await ctx.session.prompt({
84
+ ...prompt,
85
+ sessionID,
86
+ text: "Ask me where to save the HTML report (a file path, default ./skills-report.html), then call the skills_doctor tool with the html argument set to that path and tell me where it was written.",
87
+ delivery,
88
+ })
89
+ },
90
+ })
91
+ editor.add({
92
+ name: "skills-upgrade",
93
+ description: "Check whether skills-doctor itself has a newer version",
94
+ execute: async ({ sessionID, prompt, delivery }) => {
95
+ await ctx.session.prompt({
96
+ ...prompt,
97
+ sessionID,
98
+ text: "Run `npx -y opencode-skills-doctor upgrade` in the terminal (add --apply only after I confirm), then report whether a newer version exists.",
99
+ delivery,
100
+ })
101
+ },
102
+ })
51
103
  })
52
104
 
53
105
  // 每个会话最多注入一次预算预警,避免刷屏
54
- await ctx.session.hook("context", (event) => {
106
+ await ctx.session.hook("context", async (event) => {
55
107
  const warning = budgetWarning({ cwd: ctx.location.directory, sessionID: event.sessionID })
56
108
  if (warning) event.system.push({ type: "text", text: warning })
109
+ const note = await updateNote({ directory: ctx.location.directory, sessionID: event.sessionID })
110
+ if (note) event.system.push({ type: "text", text: note })
57
111
  })
58
112
  },
59
113
  })