opencode-skills-doctor 0.3.14 → 0.4.0

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
@@ -15,13 +15,14 @@ Local scanner, linter, fixer, updater and **OpenCode plugin** for agent skills
15
15
  | | |
16
16
  |---|---|
17
17
  | **What it is** | Audit, fix and update the skills your coding agent loads |
18
- | **Interfaces** | CLI · OpenCode plugin (1.x / 2.x) · MCP server (Claude Code, Codex, DSH, …) |
19
- | **Skill sources** | `.opencode/skills`, `.claude/skills`, `.agents/skills`, `opencode.json(c)` entries, HTTP catalogs |
20
- | **What it finds** | shadowed IDs, duplicates, long descriptions, oversized bodies, per-step token budget, stale skills |
18
+ | **Interfaces** | CLI · OpenCode plugin (1.x / 2.x) · MCP server (Claude Code, Codex, DSH, …) · Grok CLI compatible |
19
+ | **Skill sources** | `.opencode/skills`, `.claude/skills`, `.agents/skills`, `.grok/skills`, `.cursor/skills`, `opencode.json(c)` entries, HTTP catalogs |
20
+ | **What it finds** | shadowed IDs, duplicates, long descriptions, oversized bodies, per-step token budget, broken file references, invalid frontmatter, empty bodies, stale skills |
21
21
  | **What it fixes** | archives shadowed definitions (reversible), hides low-frequency skills, syncs catalogs with drift detection |
22
+ | **Reports** | terminal, JSON, or a self-contained HTML report (`--html [file] --open`) |
22
23
  | **Cost** | CLI: 0 tokens. Plugin: ~191 tokens/step for the tool definition, plus a one-time warning per session |
23
24
  | **Requirements** | Node ≥ 20, zero runtime dependencies |
24
- | **Quality** | 41 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
25
+ | **Quality** | 49 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
25
26
 
26
27
  ## Why
27
28
 
@@ -86,8 +87,21 @@ skills-doctor check
86
87
  skills-doctor check --json
87
88
  skills-doctor check --budget 800 --strict # for CI
88
89
  skills-doctor check --root .\some-project --no-global
90
+ skills-doctor check --html report.html # self-contained HTML report
91
+ skills-doctor check --html report.html --open
89
92
  ```
90
93
 
94
+ ### HTML report
95
+
96
+ `--html [file]` writes a single self-contained HTML file (no external assets):
97
+
98
+ - summary cards and a context-budget bar;
99
+ - findings filterable by severity and text;
100
+ - a skill table with scope, token cost, description preview and file path;
101
+ - default path `./skills-report.html` when `--html` is given without a value; `--open` launches the browser.
102
+
103
+ The plugin and MCP tool accept the same thing as an `html` argument, so the agent can ask you where to save it — for example: *"save the report to D:\reports\skills.html"*.
104
+
91
105
  ### `scan`
92
106
 
93
107
  Lists every skill with its source, precedence, whether the model can see it, and token cost.
@@ -221,8 +235,9 @@ Use `--dry-run` to preview, `--config-dir <dir>` for a custom config location, a
221
235
 
222
236
  Once installed you get:
223
237
 
224
- - the `skills_doctor` tool;
238
+ - the `skills_doctor` tool (with an optional `html` argument to write a report to a path you choose);
225
239
  - the `/skills-doctor` command (1.x: generated markdown command; 2.x: plugin command);
240
+ - the `/skills-report` command (2.x) that asks where to save the HTML report;
226
241
  - a one-time system-prompt warning when the skill description budget is exceeded.
227
242
 
228
243
  ### Uninstall
@@ -305,6 +320,17 @@ On Windows, if the stdio spawn cannot resolve `npx`, use `command: cmd` and `arg
305
320
 
306
321
  `check` reads the lockfile and reports drift (`managed-drift`), missing files (`managed-missing`) and unknown lock versions (`lock-version-unsupported`). Re-running `update` re-downloads drifted files.
307
322
 
323
+ ### Grok CLI
324
+
325
+ Grok discovers skills from `.grok/skills` (project, repo and `~/.grok/skills`), plus `.claude/skills`, `.cursor/skills` and `.agents/skills`. `skills-doctor` scans exactly the same locations, so:
326
+
327
+ ```powershell
328
+ skills-doctor check --root . # covers your Grok project skills
329
+ skills-doctor check --root $HOME # covers ~/.grok/skills and other user-level skills
330
+ ```
331
+
332
+ No install step is needed — Grok loads `SKILL.md` packages natively. If your Grok build has MCP enabled, you can additionally register the MCP server above to let the model call `skills_doctor` itself.
333
+
308
334
  ## Rules
309
335
 
310
336
  | Rule | Level | Meaning |
@@ -321,6 +347,10 @@ On Windows, if the stdio spawn cannot resolve `npx`, use `command: cmd` and `arg
321
347
  | `invalid-id` | info | ID is not kebab-case |
322
348
  | `hidden-skill` | info | Hidden via `opencode/autoinvoke: false` or `disable-model-invocation: true` |
323
349
  | `stale-skill` | info | Not updated within `--stale-days` |
350
+ | `broken-reference` | warn | `references/`, `scripts/`, `assets/`, `templates/` or `examples/` path does not exist |
351
+ | `reference-outside-skill` | info | A referenced file lives outside the skill directory (hurts portability) |
352
+ | `invalid-frontmatter` | warn | Unclosed `---` fence, or non-string name/description / non-object metadata |
353
+ | `empty-body` | info | Body is nearly empty, so the skill carries no instructions |
324
354
  | `managed-drift` | warn | A file installed by `update` was modified afterwards |
325
355
  | `managed-missing` | warn | Recorded in the lock, but the local file is gone |
326
356
  | `managed-unsafe` | warn | Path traversal or another unsafe path in the lock |
@@ -340,9 +370,13 @@ On Windows, if the stdio spawn cannot resolve `npx`, use `command: cmd` and `arg
340
370
  2. project `.claude/skills` (from the project root up to the current directory)
341
371
  3. `~/.agents/skills`
342
372
  4. project `.agents/skills`
343
- 5. `~/.config/opencode/skills`
344
- 6. project `.opencode/skills`
345
- 7. the `skills` array in `opencode.json(c)` (2.x) or `skills.paths` (1.x); relative paths resolve from the working directory; supports `~/`, absolute paths and HTTP catalogs
373
+ 5. `~/.cursor/skills`
374
+ 6. project `.cursor/skills`
375
+ 7. `~/.grok/skills`
376
+ 8. project `.grok/skills`
377
+ 9. `~/.config/opencode/skills`
378
+ 10. project `.opencode/skills`
379
+ 11. the `skills` array in `opencode.json(c)` (2.x) or `skills.paths` (1.x); relative paths resolve from the working directory; supports `~/`, absolute paths and HTTP catalogs
346
380
 
347
381
  ## JSON output
348
382
 
@@ -15,13 +15,14 @@ OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **Open
15
15
  | | |
16
16
  |---|---|
17
17
  | **是什么** | 审计、修复、更新 coding agent 加载的 skill |
18
- | **形态** | CLI · OpenCode 插件(1.x / 2.x)· MCP server(Claude Code、Codex、DSH…) |
19
- | **来源** | `.opencode/skills`、`.claude/skills`、`.agents/skills`、`opencode.json(c)` 配置、HTTP catalog |
20
- | **能查** | 同 ID 遮蔽、重复、超长描述、超大正文、每步 token 预算、陈旧 skill |
18
+ | **形态** | CLI · OpenCode 插件(1.x / 2.x)· MCP server(Claude Code、Codex、DSH…)· 兼容 Grok CLI |
19
+ | **来源** | `.opencode/skills`、`.claude/skills`、`.agents/skills`、`.grok/skills`、`.cursor/skills`、`opencode.json(c)` 配置、HTTP catalog |
20
+ | **能查** | 同 ID 遮蔽、重复、超长描述、超大正文、每步 token 预算、断链引用、非法 frontmatter、空正文、陈旧 skill |
21
21
  | **能修** | 归档遮蔽定义(可逆)、隐藏低频 skill、catalog 同步并检测 drift |
22
+ | **报告** | 终端、JSON,或自包含 HTML(`--html [file] --open`) |
22
23
  | **成本** | CLI 0 token;插件工具定义约 191/步,另有每会话一次预警 |
23
24
  | **要求** | Node ≥ 20,零运行时依赖 |
24
- | **质量** | 41 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
25
+ | **质量** | 49 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
25
26
 
26
27
  ## 为什么需要它
27
28
 
@@ -86,8 +87,21 @@ skills-doctor check
86
87
  skills-doctor check --json
87
88
  skills-doctor check --budget 800 --strict # CI 用
88
89
  skills-doctor check --root .\some-project --no-global
90
+ skills-doctor check --html report.html # 自包含 HTML 报告
91
+ skills-doctor check --html report.html --open
89
92
  ```
90
93
 
94
+ ### HTML 报告
95
+
96
+ `--html [file]` 生成单个自包含 HTML 文件(无外部资源):
97
+
98
+ - 摘要卡片 + 上下文预算条;
99
+ - 可按严重级别和文本过滤的 findings;
100
+ - skill 表格:来源、token 成本、描述预览、文件路径;
101
+ - 只写 `--html` 不带路径时默认 `./skills-report.html`;`--open` 自动打开浏览器。
102
+
103
+ 插件和 MCP 工具同样接受 `html` 参数,所以可以让 agent 先问你要存哪里——例如:*"把报告存到 D:\reports\skills.html"*。
104
+
91
105
  ### `scan`
92
106
 
93
107
  列出所有 skill 及其来源、优先级、是否会被模型看到、token 占用。
@@ -221,8 +235,9 @@ npx opencode-skills-doctor install
221
235
 
222
236
  装好后你会得到:
223
237
 
224
- - `skills_doctor` 工具;
238
+ - `skills_doctor` 工具(可选 `html` 参数,把报告写到指定路径);
225
239
  - `/skills-doctor` 命令(1.x 是生成的 markdown 命令,2.x 是插件命令);
240
+ - `/skills-report` 命令(2.x)—— 会先问你要把 HTML 报告存到哪里;
226
241
  - 当 skill 描述预算超标时,系统提示里会注入一次提醒(每个会话最多一次)。
227
242
 
228
243
  ### 卸载
@@ -305,6 +320,17 @@ Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c",
305
320
 
306
321
  `check` 会读 lockfile,报告 drift(`managed-drift`)、文件缺失(`managed-missing`)和未知 lock 版本(`lock-version-unsupported`);再跑一次 `update` 会重下被改坏的文件。
307
322
 
323
+ ### Grok CLI
324
+
325
+ Grok 从 `.grok/skills`(项目、仓库、`~/.grok/skills`)以及 `.claude/skills`、`.cursor/skills`、`.agents/skills` 发现 skill。skills-doctor 扫描的正是同一批位置:
326
+
327
+ ```powershell
328
+ skills-doctor check --root . # 覆盖当前项目的 Grok skill
329
+ skills-doctor check --root $HOME # 覆盖 ~/.grok/skills 等用户级 skill
330
+ ```
331
+
332
+ 不需要额外安装——Grok 原生加载 `SKILL.md`。如果该 Grok 版本支持 MCP,还可以把上面的 MCP server 接进去,让模型自己调用 `skills_doctor`。
333
+
308
334
  ## 检测规则
309
335
 
310
336
  | 规则 | 级别 | 含义 |
@@ -321,6 +347,10 @@ Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c",
321
347
  | `invalid-id` | info | 不符合 kebab-case 规范 |
322
348
  | `hidden-skill` | info | 被 `opencode/autoinvoke: false` 或 `disable-model-invocation: true` 隐藏 |
323
349
  | `stale-skill` | info | `--stale-days` 指定的天数内没更新 |
350
+ | `broken-reference` | warn | 引用的 `references/`、`scripts/`、`assets/`、`templates/`、`examples/` 文件不存在 |
351
+ | `reference-outside-skill` | info | 被引用的文件在 skill 目录之外(影响可移植性) |
352
+ | `invalid-frontmatter` | warn | `---` 未闭合,或 name/description 非字符串 / metadata 非对象 |
353
+ | `empty-body` | info | 正文几乎是空的,skill 没有实际指令 |
324
354
  | `managed-drift` | warn | 由 update 安装的文件在更新后被改过 |
325
355
  | `managed-missing` | warn | lock 里记录了,但本地文件没了 |
326
356
  | `managed-unsafe` | warn | lock 里有路径穿越等不安全路径 |
@@ -340,9 +370,13 @@ Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c",
340
370
  2. 项目 `.claude/skills`(项目根 → 当前目录逐级)
341
371
  3. `~/.agents/skills`
342
372
  4. 项目 `.agents/skills`
343
- 5. `~/.config/opencode/skills`
344
- 6. 项目 `.opencode/skills`
345
- 7. `opencode.json(c)` 的 `skills` 数组(2.x)或 `skills.paths`(1.x);相对路径按当前工作目录解析,支持 `~/`、绝对路径、HTTP catalog
373
+ 5. `~/.cursor/skills`
374
+ 6. 项目 `.cursor/skills`
375
+ 7. `~/.grok/skills`
376
+ 8. 项目 `.grok/skills`
377
+ 9. `~/.config/opencode/skills`
378
+ 10. 项目 `.opencode/skills`
379
+ 11. `opencode.json(c)` 的 `skills` 数组(2.x)或 `skills.paths`(1.x);相对路径按当前工作目录解析,支持 `~/`、绝对路径、HTTP catalog
346
380
 
347
381
  ## JSON 输出
348
382
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.3.14",
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).",
3
+ "version": "0.4.0",
4
+ "description": "Audit, fix and update agent skills: token cost, broken references, shadowed duplicates, HTML reports, catalog sync. CLI, OpenCode plugin, MCP server; works with OpenCode, Claude Code, Codex, Grok CLI and Cursor skill layouts.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/plugin.mjs",
package/src/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import { auditSkills } from "./audit.mjs"
9
9
  import { loadConfig } from "./config.mjs"
10
10
  import { startMcpServer } from "./mcp.mjs"
11
11
  import { installOpenCode, uninstallOpenCode } from "./install.mjs"
12
+ import { writeHtmlReport, openReport } from "./html.mjs"
12
13
  import { padDisplay } from "./parse.mjs"
13
14
  import { detectLang, normalizeLang, translator } from "./i18n.mjs"
14
15
 
@@ -38,6 +39,10 @@ const HINT_KEYS = {
38
39
  "invalid-id": "hint.invalid-id",
39
40
  "hidden-skill": "hint.hidden-skill",
40
41
  "stale-skill": "hint.stale-skill",
42
+ "invalid-frontmatter": "hint.invalid-frontmatter",
43
+ "empty-body": "hint.empty-body",
44
+ "broken-reference": "hint.broken-reference",
45
+ "reference-outside-skill": "hint.reference-outside-skill",
41
46
  }
42
47
 
43
48
  function parseArgs(argv) {
@@ -59,6 +64,8 @@ function parseArgs(argv) {
59
64
  prune: false,
60
65
  configDir: null,
61
66
  noNpm: false,
67
+ html: null,
68
+ open: false,
62
69
  help: false,
63
70
  version: false,
64
71
  }
@@ -125,6 +132,19 @@ function parseArgs(argv) {
125
132
  case "--no-npm":
126
133
  options.noNpm = true
127
134
  break
135
+ case "--html": {
136
+ const next = args[i + 1]
137
+ if (next && !next.startsWith("-")) {
138
+ options.html = path.resolve(next)
139
+ i++
140
+ } else {
141
+ options.html = path.resolve("skills-report.html")
142
+ }
143
+ break
144
+ }
145
+ case "--open":
146
+ options.open = true
147
+ break
128
148
  case "-h":
129
149
  case "--help":
130
150
  options.help = true
@@ -506,21 +526,27 @@ export async function main(argv) {
506
526
  budget: options.budget,
507
527
  staleDays: options.staleDays,
508
528
  })
529
+ const result = buildCheckResult(audit, options)
530
+
531
+ let htmlPath = null
532
+ if (options.html) {
533
+ htmlPath = writeHtmlReport(audit, options.html)
534
+ if (options.open) openReport(htmlPath)
535
+ }
536
+
509
537
  if (options.json) {
510
538
  console.log(
511
539
  JSON.stringify(
512
- { ...buildCheckResult(audit, options), skills: serializeSkills(audit.skills, audit.managed) },
540
+ { ...result, html: htmlPath, skills: serializeSkills(audit.skills, audit.managed) },
513
541
  null,
514
542
  2,
515
543
  ),
516
544
  )
517
545
  } else {
518
- const result = buildCheckResult(audit, options)
519
546
  printCheck(audit, result, options, t)
520
- process.exitCode = result.exitCode
521
- return
547
+ if (htmlPath) console.log(dim(t("check.htmlSaved", { path: htmlPath })))
522
548
  }
523
- process.exitCode = buildCheckResult(audit, options).exitCode
549
+ process.exitCode = result.exitCode
524
550
  return
525
551
  }
526
552
 
package/src/discover.mjs CHANGED
@@ -110,14 +110,19 @@ function isUnder(parent, child) {
110
110
  }
111
111
 
112
112
  /**
113
- * 按官方文档的注册顺序返回 skill 来源(顺序即优先级,越后越高):
113
+ * 返回 skill 来源(顺序即优先级,越后越高)。这是多个 agent 的并集,
114
+ * 不同 agent 的真实优先级可能不同(Grok 的 local 最高,OpenCode 的 config 最高):
114
115
  * 1. ~/.claude/skills
115
116
  * 2. 项目 .claude/skills(远端 → cwd)
116
117
  * 3. ~/.agents/skills
117
118
  * 4. 项目 .agents/skills(远端 → cwd)
118
- * 5. ~/.config/opencode/skills
119
- * 6. 项目 .opencode/skills(项目根 → cwd)
120
- * 7. opencode.json(c) 里的 skills 数组
119
+ * 5. ~/.cursor/skills
120
+ * 6. 项目 .cursor/skills
121
+ * 7. ~/.grok/skills
122
+ * 8. 项目 .grok/skills
123
+ * 9. ~/.config/opencode/skills
124
+ * 10. 项目 .opencode/skills(项目根 → cwd)
125
+ * 11. opencode.json(c) 里的 skills 数组 / skills.paths
121
126
  */
122
127
  export function discoverSources({ cwd = process.cwd(), home = os.homedir(), includeGlobal = true } = {}) {
123
128
  const projectRoot = findProjectRoot(cwd) ?? path.resolve(cwd)
@@ -140,6 +145,12 @@ export function discoverSources({ cwd = process.cwd(), home = os.homedir(), incl
140
145
  if (includeGlobal) addSource("agents-global", "global", path.join(home, ".agents", "skills"))
141
146
  for (const dir of projectChain) addSource("agents-project", "project", path.join(dir, ".agents", "skills"))
142
147
 
148
+ if (includeGlobal) addSource("cursor-global", "global", path.join(home, ".cursor", "skills"))
149
+ for (const dir of projectChain) addSource("cursor-project", "project", path.join(dir, ".cursor", "skills"))
150
+
151
+ if (includeGlobal) addSource("grok-global", "global", path.join(home, ".grok", "skills"))
152
+ for (const dir of projectChain) addSource("grok-project", "project", path.join(dir, ".grok", "skills"))
153
+
143
154
  if (includeGlobal) addSource("opencode-global", "global", path.join(home, ".config", "opencode", "skills"))
144
155
  for (const dir of projectChain) addSource("opencode-project", "project", path.join(dir, ".opencode", "skills"))
145
156
 
@@ -246,7 +257,14 @@ function buildSkill({ id, file, source, form }) {
246
257
  } catch {
247
258
  text = ""
248
259
  }
249
- const { data, body } = parseFrontmatter(text)
260
+ const { data, body, hasFrontmatter } = parseFrontmatter(text)
261
+ const startsWithFence = /^---\r?\n/.test(text)
262
+ const frontmatterBroken = startsWithFence && !hasFrontmatter
263
+ const frontmatterInvalid =
264
+ hasFrontmatter &&
265
+ ((data.name !== undefined && typeof data.name !== "string") ||
266
+ (data.description !== undefined && typeof data.description !== "string") ||
267
+ (data.metadata !== undefined && (typeof data.metadata !== "object" || data.metadata === null)))
250
268
  const autoinvoke = data?.metadata?.["opencode/autoinvoke"]
251
269
  const disabled = data?.["disable-model-invocation"]
252
270
  const description = typeof data.description === "string" ? data.description.trim() : ""
@@ -269,7 +287,11 @@ function buildSkill({ id, file, source, form }) {
269
287
  disabled,
270
288
  hidden,
271
289
  advertised: Boolean(description) && !hidden,
290
+ hasFrontmatter,
291
+ frontmatterBroken,
292
+ frontmatterInvalid,
272
293
  body,
294
+ bodyChars: body.trim().length,
273
295
  bodyTokens: estimateTokens(body),
274
296
  bytes: Buffer.byteLength(text),
275
297
  descriptionTokens: estimateTokens(description),
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
@@ -29,6 +29,8 @@ Options:
29
29
  --into <dir> update: output directory (default: .opencode/skills)
30
30
  --check-only update: only report available updates
31
31
  --prune update: delete skills that disappeared from the catalog
32
+ --html [file] check: write a self-contained HTML report (default: skills-report.html)
33
+ --open check: open the HTML report after writing it
32
34
  --config-dir <dir> install/uninstall: OpenCode config directory (default: ~/.config/opencode)
33
35
  --no-npm install/uninstall: do not run npm (1.x)
34
36
  -h, --help show this help
@@ -47,6 +49,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
47
49
  "check.configError": " ! cannot parse config: {path} ({error})",
48
50
  "check.missingPath": " ! path in config does not exist: {path}",
49
51
  "check.none": "No problems found.",
52
+ "check.htmlSaved": " HTML report: {path}",
50
53
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
51
54
  "check.strict": " (--strict: warnings also fail)",
52
55
  "check.hints": "Hints:",
@@ -121,6 +124,10 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
121
124
  "hint.invalid-id": "Use lowercase kebab-case and keep it aligned with the directory name.",
122
125
  "hint.hidden-skill": "Confirm the skill is intentionally hidden; hidden skills are not advertised.",
123
126
  "hint.stale-skill": "Confirm the content is still current, or delete it.",
127
+ "hint.invalid-frontmatter": "Fix the YAML frontmatter: closing ---, string name/description, object metadata.",
128
+ "hint.empty-body": "Add real instructions to the body, or delete the skill.",
129
+ "hint.broken-reference": "Fix the path or remove the reference; missing files silently break the skill.",
130
+ "hint.reference-outside-skill": "Keep referenced files inside the skill directory so the skill stays portable.",
124
131
 
125
132
  "lint.shadowed":
126
133
  '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 +147,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
140
147
  "lint.tokenBudgetError":
141
148
  "Advertised skill descriptions cost about {tokens} tokens per model step, over 2x the {budget} budget",
142
149
  "lint.tooManySkills": "{count} skills are advertised to the model (recommended ≤ 60)",
150
+ "lint.invalidFrontmatter": '"{id}" has malformed frontmatter: {detail}',
151
+ "lint.frontmatterUnclosed": "missing closing ---",
152
+ "lint.frontmatterTypes": "name/description/metadata have wrong types",
153
+ "lint.emptyBody": '"{id}" body is nearly empty ({chars} chars)',
154
+ "lint.brokenReference": '"{id}" references missing files: {refs}',
155
+ "lint.referenceOutside": '"{id}" references files outside its skill directory: {refs}',
143
156
 
144
157
  "managed.lockUnsupported":
145
158
  "Lock version {version} is newer than the supported {supported}; parsing in compatibility mode (unknown fields ignored)",
@@ -188,6 +201,8 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
188
201
  --into <dir> update 的落盘目录(默认 .opencode/skills)
189
202
  --check-only update 只报告可用更新,不下载
190
203
  --prune update: 删除 catalog 中已移除的 skill
204
+ --html [file] check: 生成自包含 HTML 报告(默认 skills-report.html)
205
+ --open check: 写完报告后用浏览器打开
191
206
  --config-dir <dir> install/uninstall: OpenCode 配置目录(默认 ~/.config/opencode)
192
207
  --no-npm install/uninstall: 不执行 npm(1.x)
193
208
  -h, --help 帮助
@@ -206,6 +221,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
206
221
  "check.configError": " ! 配置文件无法解析: {path}({error})",
207
222
  "check.missingPath": " ! 配置里引用的路径不存在: {path}",
208
223
  "check.none": "没有发现问题。",
224
+ "check.htmlSaved": " HTML 报告: {path}",
209
225
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
210
226
  "check.strict": "(--strict:warning 也会非 0 退出)",
211
227
  "check.hints": "建议:",
@@ -278,6 +294,10 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
278
294
  "hint.invalid-id": "改成小写 kebab-case,并与目录名对齐。",
279
295
  "hint.hidden-skill": "确认是刻意隐藏;隐藏的 skill 不会出现在模型列表里。",
280
296
  "hint.stale-skill": "确认内容没过期,或删掉。",
297
+ "hint.invalid-frontmatter": "修好 frontmatter:闭合的 ---、字符串形式的 name/description、对象形式的 metadata。",
298
+ "hint.empty-body": "给正文补上真正的说明,否则删掉这个 skill。",
299
+ "hint.broken-reference": "修正路径或删掉引用;缺失的文件会让 skill 静默失效。",
300
+ "hint.reference-outside-skill": "把被引用的文件放进 skill 目录,保证 skill 可移植。",
281
301
 
282
302
  "lint.shadowed":
283
303
  'ID "{id}" 有 {count} 处定义,实际生效的是 {winner};其余 {losers} 处被遮蔽(白占体积、容易改错文件)',
@@ -293,6 +313,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
293
313
  "lint.tokenBudget": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget}",
294
314
  "lint.tokenBudgetError": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget} 的 2 倍",
295
315
  "lint.tooManySkills": "有 {count} 个 skill 会展示给模型(建议 ≤ 60)",
316
+ "lint.invalidFrontmatter": '"{id}" 的 frontmatter 格式有问题:{detail}',
317
+ "lint.frontmatterUnclosed": "缺少闭合的 ---",
318
+ "lint.frontmatterTypes": "name/description/metadata 类型不对",
319
+ "lint.emptyBody": '"{id}" 正文几乎是空的({chars} 字符)',
320
+ "lint.brokenReference": '"{id}" 引用了不存在的文件:{refs}',
321
+ "lint.referenceOutside": '"{id}" 引用了 skill 目录之外的文件:{refs}',
296
322
 
297
323
  "managed.lockUnsupported":
298
324
  "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,17 @@ 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_COMMANDS = [
25
+ { file: "skills-doctor.md", body: V1_COMMAND },
26
+ { file: "skills-report.md", body: V1_REPORT_COMMAND },
27
+ ]
28
+
19
29
  export function defaultConfigDir() {
20
30
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
21
31
  return path.join(base, "opencode")
@@ -139,7 +149,6 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
139
149
 
140
150
  // 1.x
141
151
  const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
142
- const commandFile = path.join(dir, "command", COMMAND_FILE)
143
152
  const pluginPath = pluginFile
144
153
 
145
154
  const hasPackage = fs.existsSync(path.join(dir, "node_modules", PACKAGE_NAME, "package.json"))
@@ -167,14 +176,17 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
167
176
  act(`plugin entry already exists ${pluginFile}`)
168
177
  }
169
178
 
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}`)
179
+ for (const command of V1_COMMANDS) {
180
+ const commandFile = path.join(dir, "command", command.file)
181
+ if (!fs.existsSync(commandFile)) {
182
+ if (!dryRun) {
183
+ fs.mkdirSync(path.dirname(commandFile), { recursive: true })
184
+ fs.writeFileSync(commandFile, command.body, "utf8")
185
+ act(`wrote command ${commandFile}`)
186
+ } else dry(`write command ${commandFile}`)
187
+ } else {
188
+ act(`command already exists ${commandFile}`)
189
+ }
178
190
  }
179
191
 
180
192
  const nextPlugin = ensureUniqueEntry(config.plugin, pluginPath)
@@ -265,7 +277,6 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
265
277
  }
266
278
 
267
279
  const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
268
- const commandFile = path.join(dir, "command", COMMAND_FILE)
269
280
 
270
281
  if (Array.isArray(config.plugin) && config.plugin.includes(pluginFile)) {
271
282
  const next = config.plugin.filter((entry) => entry !== pluginFile)
@@ -281,7 +292,10 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
281
292
 
282
293
  if (fs.existsSync(pluginFile)) {
283
294
  const content = readText(pluginFile) ?? ""
284
- if (content.includes("Generated by skills-doctor install")) {
295
+ const generated =
296
+ content.includes("Generated by skills-doctor install") ||
297
+ (content.includes("SkillsDoctorPlugin") && content.includes("opencode-skills-doctor/v1"))
298
+ if (generated) {
285
299
  if (!dryRun) {
286
300
  fs.rmSync(pluginFile, { force: true })
287
301
  act(`deleted ${pluginFile}`)
@@ -291,7 +305,9 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
291
305
  }
292
306
  }
293
307
 
294
- if (fs.existsSync(commandFile)) {
308
+ for (const command of V1_COMMANDS) {
309
+ const commandFile = path.join(dir, "command", command.file)
310
+ if (!fs.existsSync(commandFile)) continue
295
311
  const content = readText(commandFile) ?? ""
296
312
  if (content.includes("skills_doctor tool")) {
297
313
  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,5 +1,6 @@
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"
4
5
 
5
6
  const z = tool.schema
@@ -18,11 +19,17 @@ export const SkillsDoctorPlugin = async ({ directory }) => ({
18
19
  args: z.object({
19
20
  directory: z.string().optional().describe("Directory to audit (defaults to the current project)"),
20
21
  budget: z.number().optional().describe("Description token budget (default 1200)"),
22
+ html: z.string().optional().describe("Optional path; when set, a self-contained HTML report is written there"),
21
23
  }),
22
24
  execute: async (args) => {
23
25
  const cwd = args.directory || directory || process.cwd()
24
26
  const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null, lang: "en" })
25
- return formatMarkdown(audit)
27
+ let markdown = formatMarkdown(audit)
28
+ if (typeof args.html === "string" && args.html.trim()) {
29
+ const file = writeHtmlReport(audit, args.html.trim(), { title: "skills-doctor report" })
30
+ markdown += `\n\nHTML report written to \`${file}\``
31
+ }
32
+ return markdown
26
33
  },
27
34
  }),
28
35
  },
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 { writeHtmlReport } from "./html.mjs"
3
4
  import { budgetWarning } from "./warning.mjs"
4
5
 
5
6
  /**
@@ -22,6 +23,10 @@ export default Plugin.define({
22
23
  properties: {
23
24
  directory: { type: "string", description: "Directory to audit (defaults to the current project)" },
24
25
  budget: { type: "number", description: "Description token budget (default 1200)" },
26
+ html: {
27
+ type: "string",
28
+ description: "Optional path; when set, a self-contained HTML report is written there",
29
+ },
25
30
  },
26
31
  additionalProperties: false,
27
32
  },
@@ -30,7 +35,12 @@ export default Plugin.define({
30
35
  const directory = (input && typeof input.directory === "string" && input.directory) || ctx.location.directory
31
36
  const budget = input && typeof input.budget === "number" ? input.budget : null
32
37
  const audit = auditSkills({ cwd: directory, budget, lang: "en" })
33
- return { content: formatMarkdown(audit) }
38
+ let markdown = formatMarkdown(audit)
39
+ if (input && typeof input.html === "string" && input.html.trim()) {
40
+ const file = writeHtmlReport(audit, input.html.trim(), { title: "skills-doctor report" })
41
+ markdown += `\n\nHTML report written to \`${file}\``
42
+ }
43
+ return { content: markdown }
34
44
  },
35
45
  })
36
46
  })
@@ -48,6 +58,18 @@ export default Plugin.define({
48
58
  })
49
59
  },
50
60
  })
61
+ editor.add({
62
+ name: "skills-report",
63
+ description: "Write a self-contained HTML skills report to a path you choose",
64
+ execute: async ({ sessionID, prompt, delivery }) => {
65
+ await ctx.session.prompt({
66
+ ...prompt,
67
+ sessionID,
68
+ 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.",
69
+ delivery,
70
+ })
71
+ },
72
+ })
51
73
  })
52
74
 
53
75
  // 每个会话最多注入一次预算预警,避免刷屏
package/src/refs.mjs ADDED
@@ -0,0 +1,40 @@
1
+ const URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:|#|\/|~)/i
2
+ const GLOB_LIKE = /[*?[\]{}]/
3
+
4
+ function cleanTarget(raw) {
5
+ let target = String(raw ?? "").trim()
6
+ if (!target) return null
7
+ if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1).trim()
8
+ target = target.split("#")[0].split("?")[0].trim()
9
+ if (!target) return null
10
+ return target
11
+ }
12
+
13
+ /**
14
+ * 从 SKILL.md 正文里提取「看起来是相对文件路径」的引用:
15
+ * - markdown 链接 [x](path)
16
+ * - 行内代码 `references/foo.md`
17
+ * 跳过 URL、绝对路径、~/、占位符、glob。
18
+ */
19
+ export function extractReferences(body) {
20
+ const refs = new Set()
21
+
22
+ const add = (raw) => {
23
+ let target = cleanTarget(raw)
24
+ if (!target) return
25
+ if (URL_LIKE.test(target)) return
26
+ if (GLOB_LIKE.test(target)) return
27
+ if (target.includes("<") || target.includes(">") || target.includes("${")) return
28
+ if (target.startsWith("$") || target.startsWith("-")) return
29
+ if (/\s/.test(target)) return
30
+
31
+ const looksLikePath = target.includes("/") || /\.(md|markdown|sh|bash|py|ts|js|mjs|cjs|json|ya?ml|toml|txt|svg|png|jpe?g|gif|webp|csv)$/i.test(target)
32
+ if (!looksLikePath) return
33
+ refs.add(target)
34
+ }
35
+
36
+ for (const match of String(body ?? "").matchAll(/\]\(\s*([^)\s]+)\s*\)/g)) add(match[1])
37
+ for (const match of String(body ?? "").matchAll(/`([^`\n]+)`/g)) add(match[1])
38
+
39
+ return [...refs]
40
+ }