opencode-skills-doctor 0.3.15 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -11
- package/docs/README.zh-CN.md +72 -11
- package/package.json +2 -2
- package/src/cli.mjs +109 -6
- package/src/config.mjs +4 -0
- package/src/discover.mjs +27 -5
- package/src/html.mjs +167 -0
- package/src/i18n.mjs +51 -0
- package/src/install.mjs +32 -12
- package/src/lint.mjs +46 -0
- package/src/mcp.mjs +11 -1
- package/src/plugin-v1.mjs +28 -1
- package/src/plugin.mjs +56 -2
- package/src/refs.mjs +40 -0
- package/src/self-update.mjs +142 -0
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
|
@@ -12,6 +12,7 @@ Usage:
|
|
|
12
12
|
skills-doctor update [options] compare and download skills from HTTP catalogs
|
|
13
13
|
skills-doctor install [options] wire the plugin into OpenCode (1.x / 2.x)
|
|
14
14
|
skills-doctor uninstall [options] remove it again (config entry, generated files, package)
|
|
15
|
+
skills-doctor upgrade [options] check for a newer skills-doctor itself (--apply installs it)
|
|
15
16
|
skills-doctor mcp run as an MCP server (Claude Code, Codex, any MCP client)
|
|
16
17
|
|
|
17
18
|
Options:
|
|
@@ -29,8 +30,12 @@ Options:
|
|
|
29
30
|
--into <dir> update: output directory (default: .opencode/skills)
|
|
30
31
|
--check-only update: only report available updates
|
|
31
32
|
--prune update: delete skills that disappeared from the catalog
|
|
33
|
+
--html [file] check: write a self-contained HTML report (default: skills-report.html)
|
|
34
|
+
--open check: open the HTML report after writing it
|
|
32
35
|
--config-dir <dir> install/uninstall: OpenCode config directory (default: ~/.config/opencode)
|
|
33
36
|
--no-npm install/uninstall: do not run npm (1.x)
|
|
37
|
+
--apply upgrade: run npm install -g opencode-skills-doctor@latest
|
|
38
|
+
--no-update-check skip the automatic update check for this run
|
|
34
39
|
-h, --help show this help
|
|
35
40
|
-v, --version print version
|
|
36
41
|
|
|
@@ -47,6 +52,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
47
52
|
"check.configError": " ! cannot parse config: {path} ({error})",
|
|
48
53
|
"check.missingPath": " ! path in config does not exist: {path}",
|
|
49
54
|
"check.none": "No problems found.",
|
|
55
|
+
"check.htmlSaved": " HTML report: {path}",
|
|
50
56
|
"check.summary": "{errors} error, {warnings} warn, {infos} info",
|
|
51
57
|
"check.strict": " (--strict: warnings also fail)",
|
|
52
58
|
"check.hints": "Hints:",
|
|
@@ -87,6 +93,16 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
87
93
|
"install.next": "Restart OpenCode to activate.",
|
|
88
94
|
"uninstall.title": "Remove OpenCode integration",
|
|
89
95
|
"uninstall.next": "Restart OpenCode to finish removal.",
|
|
96
|
+
"update.toolAvailable":
|
|
97
|
+
" ! skills-doctor {latest} is available (you have {current}); update with: {cmd} · {url}",
|
|
98
|
+
"upgrade.title": "skills-doctor upgrade",
|
|
99
|
+
"upgrade.current": " current: {version}",
|
|
100
|
+
"upgrade.available": "New version available: {latest} (current {current})",
|
|
101
|
+
"upgrade.command": " upgrade with: {cmd}",
|
|
102
|
+
"upgrade.open": " release notes: {url}",
|
|
103
|
+
"upgrade.upToDate": "Already up to date.",
|
|
104
|
+
"upgrade.done": "Upgraded to the latest version.",
|
|
105
|
+
"upgrade.failed": "Upgrade failed; run it manually: {cmd}",
|
|
90
106
|
"update.lockTooNew": " ! lock is newer than this tool; parsed in compatibility mode (unknown fields ignored)",
|
|
91
107
|
"update.detector": " check now sees these skills ({dir} is where opencode reads)",
|
|
92
108
|
"update.noCatalogs": "No HTTP catalog configured. Add https://... to the skills array in opencode.json(c).",
|
|
@@ -121,6 +137,10 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
121
137
|
"hint.invalid-id": "Use lowercase kebab-case and keep it aligned with the directory name.",
|
|
122
138
|
"hint.hidden-skill": "Confirm the skill is intentionally hidden; hidden skills are not advertised.",
|
|
123
139
|
"hint.stale-skill": "Confirm the content is still current, or delete it.",
|
|
140
|
+
"hint.invalid-frontmatter": "Fix the YAML frontmatter: closing ---, string name/description, object metadata.",
|
|
141
|
+
"hint.empty-body": "Add real instructions to the body, or delete the skill.",
|
|
142
|
+
"hint.broken-reference": "Fix the path or remove the reference; missing files silently break the skill.",
|
|
143
|
+
"hint.reference-outside-skill": "Keep referenced files inside the skill directory so the skill stays portable.",
|
|
124
144
|
|
|
125
145
|
"lint.shadowed":
|
|
126
146
|
'ID "{id}" is defined {count} times; the effective definition is {winner}; the other {losers} are shadowed (dead weight, easy to edit the wrong file)',
|
|
@@ -140,6 +160,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
140
160
|
"lint.tokenBudgetError":
|
|
141
161
|
"Advertised skill descriptions cost about {tokens} tokens per model step, over 2x the {budget} budget",
|
|
142
162
|
"lint.tooManySkills": "{count} skills are advertised to the model (recommended ≤ 60)",
|
|
163
|
+
"lint.invalidFrontmatter": '"{id}" has malformed frontmatter: {detail}',
|
|
164
|
+
"lint.frontmatterUnclosed": "missing closing ---",
|
|
165
|
+
"lint.frontmatterTypes": "name/description/metadata have wrong types",
|
|
166
|
+
"lint.emptyBody": '"{id}" body is nearly empty ({chars} chars)',
|
|
167
|
+
"lint.brokenReference": '"{id}" references missing files: {refs}',
|
|
168
|
+
"lint.referenceOutside": '"{id}" references files outside its skill directory: {refs}',
|
|
143
169
|
|
|
144
170
|
"managed.lockUnsupported":
|
|
145
171
|
"Lock version {version} is newer than the supported {supported}; parsing in compatibility mode (unknown fields ignored)",
|
|
@@ -171,6 +197,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
171
197
|
skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
|
|
172
198
|
skills-doctor install [选项] 把插件接入 OpenCode(1.x / 2.x)
|
|
173
199
|
skills-doctor uninstall [选项] 撤销安装(配置条目、生成的文件、npm 包)
|
|
200
|
+
skills-doctor upgrade [选项] 检查 skills-doctor 自身是否有新版本(--apply 直接装)
|
|
174
201
|
skills-doctor mcp 以 MCP server 方式运行(Claude Code、Codex 等任何 MCP 客户端)
|
|
175
202
|
|
|
176
203
|
选项:
|
|
@@ -188,8 +215,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
188
215
|
--into <dir> update 的落盘目录(默认 .opencode/skills)
|
|
189
216
|
--check-only update 只报告可用更新,不下载
|
|
190
217
|
--prune update: 删除 catalog 中已移除的 skill
|
|
218
|
+
--html [file] check: 生成自包含 HTML 报告(默认 skills-report.html)
|
|
219
|
+
--open check: 写完报告后用浏览器打开
|
|
191
220
|
--config-dir <dir> install/uninstall: OpenCode 配置目录(默认 ~/.config/opencode)
|
|
192
221
|
--no-npm install/uninstall: 不执行 npm(1.x)
|
|
222
|
+
--apply upgrade: 执行 npm install -g opencode-skills-doctor@latest
|
|
223
|
+
--no-update-check 本次运行跳过自动更新检查
|
|
193
224
|
-h, --help 帮助
|
|
194
225
|
-v, --version 版本
|
|
195
226
|
|
|
@@ -206,6 +237,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
206
237
|
"check.configError": " ! 配置文件无法解析: {path}({error})",
|
|
207
238
|
"check.missingPath": " ! 配置里引用的路径不存在: {path}",
|
|
208
239
|
"check.none": "没有发现问题。",
|
|
240
|
+
"check.htmlSaved": " HTML 报告: {path}",
|
|
209
241
|
"check.summary": "{errors} error, {warnings} warn, {infos} info",
|
|
210
242
|
"check.strict": "(--strict:warning 也会非 0 退出)",
|
|
211
243
|
"check.hints": "建议:",
|
|
@@ -246,6 +278,15 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
246
278
|
"install.next": "重启 OpenCode 生效。",
|
|
247
279
|
"uninstall.title": "撤销 OpenCode 集成",
|
|
248
280
|
"uninstall.next": "重启 OpenCode 完成卸载。",
|
|
281
|
+
"update.toolAvailable": " ! skills-doctor 有新版本 {latest}(当前 {current});更新:{cmd} · {url}",
|
|
282
|
+
"upgrade.title": "skills-doctor 自更新",
|
|
283
|
+
"upgrade.current": " 当前版本: {version}",
|
|
284
|
+
"upgrade.available": "发现新版本: {latest}(当前 {current})",
|
|
285
|
+
"upgrade.command": " 更新命令: {cmd}",
|
|
286
|
+
"upgrade.open": " 更新说明: {url}",
|
|
287
|
+
"upgrade.upToDate": "已是最新版本。",
|
|
288
|
+
"upgrade.done": "已升级到最新版本。",
|
|
289
|
+
"upgrade.failed": "升级失败,请手动执行: {cmd}",
|
|
249
290
|
"update.lockTooNew": " ! lock 版本比本工具新,已按兼容模式解析(未知字段忽略)",
|
|
250
291
|
"update.detector": " check 现在可以直接检测到这些 skill({dir} 是 opencode 会读取的目录)",
|
|
251
292
|
"update.noCatalogs": "没有配置 HTTP catalog。在 opencode.json(c) 的 skills 数组里写 https://... 即可。",
|
|
@@ -278,6 +319,10 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
278
319
|
"hint.invalid-id": "改成小写 kebab-case,并与目录名对齐。",
|
|
279
320
|
"hint.hidden-skill": "确认是刻意隐藏;隐藏的 skill 不会出现在模型列表里。",
|
|
280
321
|
"hint.stale-skill": "确认内容没过期,或删掉。",
|
|
322
|
+
"hint.invalid-frontmatter": "修好 frontmatter:闭合的 ---、字符串形式的 name/description、对象形式的 metadata。",
|
|
323
|
+
"hint.empty-body": "给正文补上真正的说明,否则删掉这个 skill。",
|
|
324
|
+
"hint.broken-reference": "修正路径或删掉引用;缺失的文件会让 skill 静默失效。",
|
|
325
|
+
"hint.reference-outside-skill": "把被引用的文件放进 skill 目录,保证 skill 可移植。",
|
|
281
326
|
|
|
282
327
|
"lint.shadowed":
|
|
283
328
|
'ID "{id}" 有 {count} 处定义,实际生效的是 {winner};其余 {losers} 处被遮蔽(白占体积、容易改错文件)',
|
|
@@ -293,6 +338,12 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
293
338
|
"lint.tokenBudget": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget}",
|
|
294
339
|
"lint.tokenBudgetError": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget} 的 2 倍",
|
|
295
340
|
"lint.tooManySkills": "有 {count} 个 skill 会展示给模型(建议 ≤ 60)",
|
|
341
|
+
"lint.invalidFrontmatter": '"{id}" 的 frontmatter 格式有问题:{detail}',
|
|
342
|
+
"lint.frontmatterUnclosed": "缺少闭合的 ---",
|
|
343
|
+
"lint.frontmatterTypes": "name/description/metadata 类型不对",
|
|
344
|
+
"lint.emptyBody": '"{id}" 正文几乎是空的({chars} 字符)',
|
|
345
|
+
"lint.brokenReference": '"{id}" 引用了不存在的文件:{refs}',
|
|
346
|
+
"lint.referenceOutside": '"{id}" 引用了 skill 目录之外的文件:{refs}',
|
|
296
347
|
|
|
297
348
|
"managed.lockUnsupported":
|
|
298
349
|
"lock 版本 {version} 高于本工具支持的 {supported},已按兼容模式解析(未知字段忽略)",
|
package/src/install.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import { stripJsonc } from "./discover.mjs"
|
|
|
6
6
|
|
|
7
7
|
export const PACKAGE_NAME = "opencode-skills-doctor"
|
|
8
8
|
const PLUGIN_FILE = "skills-doctor.js"
|
|
9
|
-
const COMMAND_FILE = "skills-doctor.md"
|
|
10
9
|
|
|
11
10
|
const V1_PLUGIN_SOURCE = `// Generated by skills-doctor install\n// OpenCode 1.x plugin entry\nexport { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"\n`
|
|
12
11
|
|
|
@@ -16,6 +15,24 @@ description: Audit local skills with skills-doctor
|
|
|
16
15
|
Use the skills_doctor tool to audit the skills in this project. Summarize the top findings and propose concrete fixes (archive shadowed duplicates, hide low-frequency skills). $ARGUMENTS
|
|
17
16
|
`
|
|
18
17
|
|
|
18
|
+
const V1_REPORT_COMMAND = `---
|
|
19
|
+
description: Write an HTML skills report to a path you choose
|
|
20
|
+
---
|
|
21
|
+
Ask the user where to save the HTML report (default ./skills-report.html), then call the skills_doctor tool with the html argument set to that path and tell them where it was written. $ARGUMENTS
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const V1_UPGRADE_COMMAND = `---
|
|
25
|
+
description: Check whether skills-doctor itself has a newer version
|
|
26
|
+
---
|
|
27
|
+
Run \`npx -y opencode-skills-doctor upgrade\` in the terminal (add --apply only after the user confirms), then report whether a newer version exists. $ARGUMENTS
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
const V1_COMMANDS = [
|
|
31
|
+
{ file: "skills-doctor.md", body: V1_COMMAND },
|
|
32
|
+
{ file: "skills-report.md", body: V1_REPORT_COMMAND },
|
|
33
|
+
{ file: "skills-upgrade.md", body: V1_UPGRADE_COMMAND },
|
|
34
|
+
]
|
|
35
|
+
|
|
19
36
|
export function defaultConfigDir() {
|
|
20
37
|
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
|
|
21
38
|
return path.join(base, "opencode")
|
|
@@ -139,7 +156,6 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
|
|
|
139
156
|
|
|
140
157
|
// 1.x
|
|
141
158
|
const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
|
|
142
|
-
const commandFile = path.join(dir, "command", COMMAND_FILE)
|
|
143
159
|
const pluginPath = pluginFile
|
|
144
160
|
|
|
145
161
|
const hasPackage = fs.existsSync(path.join(dir, "node_modules", PACKAGE_NAME, "package.json"))
|
|
@@ -167,14 +183,17 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
|
|
|
167
183
|
act(`plugin entry already exists ${pluginFile}`)
|
|
168
184
|
}
|
|
169
185
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
for (const command of V1_COMMANDS) {
|
|
187
|
+
const commandFile = path.join(dir, "command", command.file)
|
|
188
|
+
if (!fs.existsSync(commandFile)) {
|
|
189
|
+
if (!dryRun) {
|
|
190
|
+
fs.mkdirSync(path.dirname(commandFile), { recursive: true })
|
|
191
|
+
fs.writeFileSync(commandFile, command.body, "utf8")
|
|
192
|
+
act(`wrote command ${commandFile}`)
|
|
193
|
+
} else dry(`write command ${commandFile}`)
|
|
194
|
+
} else {
|
|
195
|
+
act(`command already exists ${commandFile}`)
|
|
196
|
+
}
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
const nextPlugin = ensureUniqueEntry(config.plugin, pluginPath)
|
|
@@ -265,7 +284,6 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
|
|
|
265
284
|
}
|
|
266
285
|
|
|
267
286
|
const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
|
|
268
|
-
const commandFile = path.join(dir, "command", COMMAND_FILE)
|
|
269
287
|
|
|
270
288
|
if (Array.isArray(config.plugin) && config.plugin.includes(pluginFile)) {
|
|
271
289
|
const next = config.plugin.filter((entry) => entry !== pluginFile)
|
|
@@ -294,7 +312,9 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
|
|
|
294
312
|
}
|
|
295
313
|
}
|
|
296
314
|
|
|
297
|
-
|
|
315
|
+
for (const command of V1_COMMANDS) {
|
|
316
|
+
const commandFile = path.join(dir, "command", command.file)
|
|
317
|
+
if (!fs.existsSync(commandFile)) continue
|
|
298
318
|
const content = readText(commandFile) ?? ""
|
|
299
319
|
if (content.includes("skills_doctor tool")) {
|
|
300
320
|
if (!dryRun) {
|
package/src/lint.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
1
3
|
import { estimateTokens } from "./parse.mjs"
|
|
4
|
+
import { extractReferences } from "./refs.mjs"
|
|
2
5
|
import { translator } from "./i18n.mjs"
|
|
3
6
|
|
|
4
7
|
export const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 }
|
|
@@ -117,6 +120,49 @@ export function lint(skills, options = {}) {
|
|
|
117
120
|
add("invalid-id", "info", t("lint.invalidId", { id: skill.id }), { skills: [skill.id] })
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
if (skill.frontmatterBroken) {
|
|
124
|
+
add("invalid-frontmatter", "warn", t("lint.invalidFrontmatter", { id: skill.id, detail: t("lint.frontmatterUnclosed") }), {
|
|
125
|
+
skills: [skill.id],
|
|
126
|
+
})
|
|
127
|
+
} else if (skill.frontmatterInvalid) {
|
|
128
|
+
add("invalid-frontmatter", "warn", t("lint.invalidFrontmatter", { id: skill.id, detail: t("lint.frontmatterTypes") }), {
|
|
129
|
+
skills: [skill.id],
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (skill.bodyChars < 20) {
|
|
134
|
+
add("empty-body", "info", t("lint.emptyBody", { id: skill.id, chars: skill.bodyChars }), { skills: [skill.id] })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 只审计「skill 本地资源目录」的引用,避免把 npm 包名、项目相对路径误报成断链
|
|
138
|
+
const refs = extractReferences(skill.body).filter((ref) => {
|
|
139
|
+
if (ref.startsWith("@") || ref.endsWith("/")) return false
|
|
140
|
+
return /^(?:\.\/)?(?:references|scripts|assets|templates|examples)\//.test(ref)
|
|
141
|
+
})
|
|
142
|
+
if (refs.length) {
|
|
143
|
+
const base = path.resolve(path.dirname(skill.file))
|
|
144
|
+
const missingRefs = []
|
|
145
|
+
const outsideRefs = []
|
|
146
|
+
for (const ref of refs) {
|
|
147
|
+
const target = path.resolve(base, ref)
|
|
148
|
+
if (target !== base && !target.startsWith(base + path.sep)) outsideRefs.push(ref)
|
|
149
|
+
if (!fs.existsSync(target)) missingRefs.push(ref)
|
|
150
|
+
}
|
|
151
|
+
if (missingRefs.length) {
|
|
152
|
+
add("broken-reference", "warn", t("lint.brokenReference", { id: skill.id, refs: missingRefs.slice(0, 5).join(", ") }), {
|
|
153
|
+
skills: [skill.id],
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
if (outsideRefs.length) {
|
|
157
|
+
add(
|
|
158
|
+
"reference-outside-skill",
|
|
159
|
+
"info",
|
|
160
|
+
t("lint.referenceOutside", { id: skill.id, refs: outsideRefs.slice(0, 5).join(", ") }),
|
|
161
|
+
{ skills: [skill.id] },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
120
166
|
if (skill.bodyTokens > 4000) {
|
|
121
167
|
add("large-body", "warn", t("lint.largeBody", { id: skill.id, tokens: skill.bodyTokens }), {
|
|
122
168
|
skills: [skill.id],
|
package/src/mcp.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { auditSkills, formatMarkdown } from "./audit.mjs"
|
|
2
|
+
import { writeHtmlReport } from "./html.mjs"
|
|
2
3
|
import { normalizeLang, detectLang } from "./i18n.mjs"
|
|
3
4
|
|
|
4
5
|
export const PROTOCOL_VERSION = "2025-06-18"
|
|
@@ -30,6 +31,10 @@ export function toolDefinition() {
|
|
|
30
31
|
type: "boolean",
|
|
31
32
|
description: "Include global skill directories such as ~/.config/opencode and ~/.claude (default true)",
|
|
32
33
|
},
|
|
34
|
+
html: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "Optional path; when set, a self-contained HTML report is written there and the path is returned",
|
|
37
|
+
},
|
|
33
38
|
},
|
|
34
39
|
additionalProperties: false,
|
|
35
40
|
},
|
|
@@ -86,7 +91,12 @@ export async function handleMessage(message, { version = "0.0.0" } = {}) {
|
|
|
86
91
|
const lang = normalizeLang(args.lang) ?? detectLang()
|
|
87
92
|
|
|
88
93
|
const audit = auditSkills({ cwd: directory, budget, lang, includeGlobal: args.global !== false })
|
|
89
|
-
|
|
94
|
+
let text = formatMarkdown(audit)
|
|
95
|
+
if (typeof args.html === "string" && args.html.trim()) {
|
|
96
|
+
const file = writeHtmlReport(audit, args.html.trim(), { title: "skills-doctor report" })
|
|
97
|
+
text += `\n\nHTML report written to \`${file}\``
|
|
98
|
+
}
|
|
99
|
+
return ok(id, { content: [{ type: "text", text }] })
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
default:
|
package/src/plugin-v1.mjs
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { auditSkills, formatMarkdown } from "./audit.mjs"
|
|
3
|
+
import { writeHtmlReport } from "./html.mjs"
|
|
3
4
|
import { budgetWarning } from "./warning.mjs"
|
|
5
|
+
import { loadConfig } from "./config.mjs"
|
|
6
|
+
import { checkForUpdate, resolveMode, toolVersion } from "./self-update.mjs"
|
|
7
|
+
|
|
8
|
+
const VERSION = toolVersion()
|
|
9
|
+
const notifiedSessions = new Set()
|
|
10
|
+
|
|
11
|
+
async function updateNote({ directory, sessionID }) {
|
|
12
|
+
if (!sessionID || notifiedSessions.has(sessionID)) return null
|
|
13
|
+
notifiedSessions.add(sessionID)
|
|
14
|
+
try {
|
|
15
|
+
const { config } = loadConfig(directory)
|
|
16
|
+
const update = await checkForUpdate({ current: VERSION, mode: resolveMode(config) })
|
|
17
|
+
if (!update) return null
|
|
18
|
+
return `skills-doctor ${update.latest} is available (current ${VERSION}). Update: npx -y opencode-skills-doctor@latest upgrade --apply`
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
|
|
5
24
|
const z = tool.schema
|
|
6
25
|
|
|
@@ -18,17 +37,25 @@ export const SkillsDoctorPlugin = async ({ directory }) => ({
|
|
|
18
37
|
args: z.object({
|
|
19
38
|
directory: z.string().optional().describe("Directory to audit (defaults to the current project)"),
|
|
20
39
|
budget: z.number().optional().describe("Description token budget (default 1200)"),
|
|
40
|
+
html: z.string().optional().describe("Optional path; when set, a self-contained HTML report is written there"),
|
|
21
41
|
}),
|
|
22
42
|
execute: async (args) => {
|
|
23
43
|
const cwd = args.directory || directory || process.cwd()
|
|
24
44
|
const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null, lang: "en" })
|
|
25
|
-
|
|
45
|
+
let markdown = formatMarkdown(audit)
|
|
46
|
+
if (typeof args.html === "string" && args.html.trim()) {
|
|
47
|
+
const file = writeHtmlReport(audit, args.html.trim(), { title: "skills-doctor report" })
|
|
48
|
+
markdown += `\n\nHTML report written to \`${file}\``
|
|
49
|
+
}
|
|
50
|
+
return markdown
|
|
26
51
|
},
|
|
27
52
|
}),
|
|
28
53
|
},
|
|
29
54
|
"experimental.chat.system.transform": async (input, output) => {
|
|
30
55
|
const warning = budgetWarning({ cwd: directory, sessionID: input?.sessionID })
|
|
31
56
|
if (warning) output.system.push(warning)
|
|
57
|
+
const note = await updateNote({ directory, sessionID: input?.sessionID })
|
|
58
|
+
if (note) output.system.push(note)
|
|
32
59
|
},
|
|
33
60
|
})
|
|
34
61
|
|
package/src/plugin.mjs
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
2
|
import { auditSkills, formatMarkdown } from "./audit.mjs"
|
|
3
|
+
import { writeHtmlReport } from "./html.mjs"
|
|
3
4
|
import { budgetWarning } from "./warning.mjs"
|
|
5
|
+
import { loadConfig } from "./config.mjs"
|
|
6
|
+
import { checkForUpdate, resolveMode, toolVersion } from "./self-update.mjs"
|
|
7
|
+
|
|
8
|
+
const VERSION = toolVersion()
|
|
9
|
+
const notifiedSessions = new Set()
|
|
10
|
+
|
|
11
|
+
async function updateNote({ directory, sessionID }) {
|
|
12
|
+
if (!sessionID || notifiedSessions.has(sessionID)) return null
|
|
13
|
+
notifiedSessions.add(sessionID)
|
|
14
|
+
try {
|
|
15
|
+
const { config } = loadConfig(directory)
|
|
16
|
+
const update = await checkForUpdate({ current: VERSION, mode: resolveMode(config) })
|
|
17
|
+
if (!update) return null
|
|
18
|
+
return `skills-doctor ${update.latest} is available (current ${VERSION}). Update: npx -y opencode-skills-doctor@latest upgrade --apply`
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
|
|
5
24
|
/**
|
|
6
25
|
* OpenCode 插件入口:把 skills-doctor 暴露成 skills_doctor 工具和 /skills-doctor 命令。
|
|
@@ -22,6 +41,10 @@ export default Plugin.define({
|
|
|
22
41
|
properties: {
|
|
23
42
|
directory: { type: "string", description: "Directory to audit (defaults to the current project)" },
|
|
24
43
|
budget: { type: "number", description: "Description token budget (default 1200)" },
|
|
44
|
+
html: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "Optional path; when set, a self-contained HTML report is written there",
|
|
47
|
+
},
|
|
25
48
|
},
|
|
26
49
|
additionalProperties: false,
|
|
27
50
|
},
|
|
@@ -30,7 +53,12 @@ export default Plugin.define({
|
|
|
30
53
|
const directory = (input && typeof input.directory === "string" && input.directory) || ctx.location.directory
|
|
31
54
|
const budget = input && typeof input.budget === "number" ? input.budget : null
|
|
32
55
|
const audit = auditSkills({ cwd: directory, budget, lang: "en" })
|
|
33
|
-
|
|
56
|
+
let markdown = formatMarkdown(audit)
|
|
57
|
+
if (input && typeof input.html === "string" && input.html.trim()) {
|
|
58
|
+
const file = writeHtmlReport(audit, input.html.trim(), { title: "skills-doctor report" })
|
|
59
|
+
markdown += `\n\nHTML report written to \`${file}\``
|
|
60
|
+
}
|
|
61
|
+
return { content: markdown }
|
|
34
62
|
},
|
|
35
63
|
})
|
|
36
64
|
})
|
|
@@ -48,12 +76,38 @@ export default Plugin.define({
|
|
|
48
76
|
})
|
|
49
77
|
},
|
|
50
78
|
})
|
|
79
|
+
editor.add({
|
|
80
|
+
name: "skills-report",
|
|
81
|
+
description: "Write a self-contained HTML skills report to a path you choose",
|
|
82
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
83
|
+
await ctx.session.prompt({
|
|
84
|
+
...prompt,
|
|
85
|
+
sessionID,
|
|
86
|
+
text: "Ask me where to save the HTML report (a file path, default ./skills-report.html), then call the skills_doctor tool with the html argument set to that path and tell me where it was written.",
|
|
87
|
+
delivery,
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
editor.add({
|
|
92
|
+
name: "skills-upgrade",
|
|
93
|
+
description: "Check whether skills-doctor itself has a newer version",
|
|
94
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
95
|
+
await ctx.session.prompt({
|
|
96
|
+
...prompt,
|
|
97
|
+
sessionID,
|
|
98
|
+
text: "Run `npx -y opencode-skills-doctor upgrade` in the terminal (add --apply only after I confirm), then report whether a newer version exists.",
|
|
99
|
+
delivery,
|
|
100
|
+
})
|
|
101
|
+
},
|
|
102
|
+
})
|
|
51
103
|
})
|
|
52
104
|
|
|
53
105
|
// 每个会话最多注入一次预算预警,避免刷屏
|
|
54
|
-
await ctx.session.hook("context", (event) => {
|
|
106
|
+
await ctx.session.hook("context", async (event) => {
|
|
55
107
|
const warning = budgetWarning({ cwd: ctx.location.directory, sessionID: event.sessionID })
|
|
56
108
|
if (warning) event.system.push({ type: "text", text: warning })
|
|
109
|
+
const note = await updateNote({ directory: ctx.location.directory, sessionID: event.sessionID })
|
|
110
|
+
if (note) event.system.push({ type: "text", text: note })
|
|
57
111
|
})
|
|
58
112
|
},
|
|
59
113
|
})
|