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 +42 -8
- package/docs/README.zh-CN.md +42 -8
- package/package.json +2 -2
- package/src/cli.mjs +31 -5
- package/src/discover.mjs +27 -5
- package/src/html.mjs +167 -0
- package/src/i18n.mjs +26 -0
- package/src/install.mjs +29 -13
- package/src/lint.mjs +46 -0
- package/src/mcp.mjs +11 -1
- package/src/plugin-v1.mjs +8 -1
- package/src/plugin.mjs +23 -1
- package/src/refs.mjs +40 -0
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** |
|
|
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. `~/.
|
|
344
|
-
6. project `.
|
|
345
|
-
7.
|
|
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
|
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -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
|
|
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
|
-
| **质量** |
|
|
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. `~/.
|
|
344
|
-
6. 项目 `.
|
|
345
|
-
7.
|
|
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.
|
|
4
|
-
"description": "Audit, fix and update agent skills:
|
|
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
|
-
{ ...
|
|
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
|
-
|
|
521
|
-
return
|
|
547
|
+
if (htmlPath) console.log(dim(t("check.htmlSaved", { path: htmlPath })))
|
|
522
548
|
}
|
|
523
|
-
process.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
|
-
*
|
|
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. ~/.
|
|
119
|
-
* 6. 项目 .
|
|
120
|
-
* 7.
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|