opencode-skills-doctor 0.9.4 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin-v1.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
- import { auditSkills, formatMarkdown } from "./audit.mjs"
3
- import { writeHtmlReport } from "./html.mjs"
2
+ import { auditSkills, formatChat } from "./audit.mjs"
4
3
  import { budgetWarning } from "./warning.mjs"
5
4
  import { loadConfig } from "./config.mjs"
6
5
  import { checkForUpdate, resolveMode, toolVersion } from "./self-update.mjs"
@@ -33,27 +32,21 @@ export const SkillsDoctorPlugin = async ({ directory }) => ({
33
32
  tool: {
34
33
  skills_doctor: tool({
35
34
  description:
36
- "Audit installed agent skills (OpenCode, Claude Code, Grok, Cursor, Hermes, Kimi, DSH): shadowing, per-step token budget and fix hints. Returns a markdown report.",
35
+ "Audit installed agent skills and return a short briefing to say to the user in the chat. Does not write a file.",
37
36
  args: z.object({
38
37
  directory: z.string().optional().describe("Directory to audit (defaults to the current project)"),
39
38
  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"),
41
39
  }),
42
40
  execute: async (args) => {
43
41
  const cwd = args.directory || directory || process.cwd()
44
- const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null, lang: "en" })
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
42
+ const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null })
43
+ return formatChat(audit)
51
44
  },
52
45
  }),
53
46
  },
54
47
  "experimental.chat.system.transform": async (input, output) => {
55
48
  const warning = budgetWarning({ cwd: directory, sessionID: input?.sessionID })
56
- if (warning) output.system.push(warning)
49
+ if (warning) output.system.push(`Say this to the user in the chat. Do not write a file.\n\n${warning}`)
57
50
  const note = await updateNote({ directory, sessionID: input?.sessionID })
58
51
  if (note) output.system.push(note)
59
52
  },
package/src/plugin.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
- import { auditSkills, formatMarkdown } from "./audit.mjs"
3
- import { writeHtmlReport } from "./html.mjs"
2
+ import { auditSkills, formatChat } from "./audit.mjs"
3
+ import { postChatNotice, chatNoticeVisible } from "./notify.mjs"
4
4
  import { budgetWarning } from "./warning.mjs"
5
5
  import { loadConfig } from "./config.mjs"
6
6
  import { checkForUpdate, resolveMode, toolVersion } from "./self-update.mjs"
@@ -35,16 +35,12 @@ export default Plugin.define({
35
35
  editor.namespace({ name: "skills", description: "OpenCode skill diagnostics" })
36
36
  editor.add({
37
37
  name: "doctor",
38
- description: "Audit installed agent skills (OpenCode, Claude Code, Grok, Cursor, Hermes, Kimi, DSH): shadowing, per-step token budget and fix hints",
38
+ description: "Audit installed agent skills and return a short briefing to say to the user in the chat. Does not write a file.",
39
39
  input: {
40
40
  type: "object",
41
41
  properties: {
42
42
  directory: { type: "string", description: "Directory to audit (defaults to the current project)" },
43
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
- },
48
44
  },
49
45
  additionalProperties: false,
50
46
  },
@@ -52,13 +48,8 @@ export default Plugin.define({
52
48
  execute: async (input) => {
53
49
  const directory = (input && typeof input.directory === "string" && input.directory) || ctx.location.directory
54
50
  const budget = input && typeof input.budget === "number" ? input.budget : null
55
- const audit = auditSkills({ cwd: directory, budget, lang: "en" })
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 }
51
+ const audit = auditSkills({ cwd: directory, budget })
52
+ return { content: formatChat(audit) }
62
53
  },
63
54
  })
64
55
  })
@@ -66,25 +57,15 @@ export default Plugin.define({
66
57
  await ctx.command.transform((editor) => {
67
58
  editor.add({
68
59
  name: "skills-doctor",
69
- description: "Audit local OpenCode skills",
70
- execute: async ({ sessionID, prompt, delivery }) => {
71
- await ctx.session.prompt({
72
- ...prompt,
73
- sessionID,
74
- text: "Run a skills audit with the skills_doctor tool, then summarize the top issues and propose concrete fixes.",
75
- delivery,
76
- })
77
- },
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,
60
+ description: "Tell the user about skill problems in this chat",
61
+ execute: async ({ sessionID }) => {
62
+ await postChatNotice({
85
63
  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,
64
+ cwd: ctx.location.directory,
65
+ synthetic: (input) => ctx.session.synthetic(input),
66
+ resume: true,
67
+ force: true,
68
+ when: "always",
88
69
  })
89
70
  },
90
71
  })
@@ -102,10 +83,32 @@ export default Plugin.define({
102
83
  })
103
84
  })
104
85
 
105
- // 每个会话最多注入一次预算预警,避免刷屏
86
+ // 用户发消息时,把问题直接写进对话。resume 为 false,不额外叫醒模型。
87
+ // 没有 prompt hook 的旧宿主仍靠下面的 context 退路。
88
+ try {
89
+ await ctx.session.hook("prompt", async (event) => {
90
+ await postChatNotice({
91
+ sessionID: event.sessionID,
92
+ cwd: ctx.location.directory,
93
+ synthetic: (input) => ctx.session.synthetic(input),
94
+ })
95
+ })
96
+ } catch {
97
+ // 旧版本不认识这个 hook
98
+ }
99
+
100
+ // 通知已经出现在对话里时,不再把同一段话塞进系统提示。
101
+ // 老版本没有 synthetic 时,退回系统提示,让模型在回复里说出来。
106
102
  await ctx.session.hook("context", async (event) => {
107
- const warning = budgetWarning({ cwd: ctx.location.directory, sessionID: event.sessionID })
108
- if (warning) event.system.push({ type: "text", text: warning })
103
+ if (!chatNoticeVisible(ctx.location.directory, event.sessionID)) {
104
+ const warning = budgetWarning({ cwd: ctx.location.directory, sessionID: event.sessionID })
105
+ if (warning) {
106
+ event.system.push({
107
+ type: "text",
108
+ text: `Say this to the user in the chat. Do not write a file.\n\n${warning}`,
109
+ })
110
+ }
111
+ }
109
112
  const note = await updateNote({ directory: ctx.location.directory, sessionID: event.sessionID })
110
113
  if (note) event.system.push({ type: "text", text: note })
111
114
  })
package/src/warning.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { auditSkills } from "./audit.mjs"
1
+ import { auditSkills, formatChat } from "./audit.mjs"
2
2
 
3
3
  const cache = new Map()
4
4
  const warned = new Set()
@@ -15,12 +15,10 @@ export function budgetWarning({ cwd = process.cwd(), sessionID = "global", ttlMs
15
15
  let entry = cache.get(cwd)
16
16
  if (!entry || now - entry.at > ttlMs) {
17
17
  const audit = auditSkills({ cwd, includeGlobal })
18
- const over = audit.counts.errors > 0
18
+ const notable = audit.findings.some((finding) => finding.severity === "error" || finding.severity === "warn")
19
19
  entry = {
20
20
  at: now,
21
- text: over
22
- ? `[skills-doctor] ${audit.summary.unique} skills use ~${audit.summary.advertisedTokens} tokens of descriptions per step (budget ${audit.budget}); ${audit.counts.errors} error(s), ${audit.counts.warnings} warning(s). Use the skills_doctor tool for details, then hide low-frequency skills with \`skills-doctor fix --hide <ids>\`.`
23
- : null,
21
+ text: notable ? formatChat(audit) : null,
24
22
  }
25
23
  cache.set(cwd, entry)
26
24
  }
package/src/html.mjs DELETED
@@ -1,171 +0,0 @@
1
- import fs from "node:fs"
2
- import path from "node:path"
3
- import { spawn } from "node:child_process"
4
- import { planBudget } from "./plan.mjs"
5
-
6
- function esc(value) {
7
- return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char])
8
- }
9
-
10
- const SEVERITY_LABEL = { error: "error", warn: "warn", info: "info" }
11
-
12
- /** 生成自包含 HTML 报告(无外部资源,可直接分享/作 CI artifact)。 */
13
- export function renderHtml(audit, { title = "skills-doctor report", now = new Date() } = {}) {
14
- const summary = audit.summary
15
- const counts = audit.counts
16
- const budget = audit.budget
17
- const plan = planBudget(audit)
18
- const ratio = budget > 0 ? Math.min(1.5, summary.advertisedTokens / (budget * 2)) : 0
19
- const barWidth = Math.min(100, Math.round(ratio * 100))
20
- const barClass = summary.advertisedTokens > budget * 2 ? "over" : summary.advertisedTokens > budget ? "warn" : "ok"
21
-
22
- const findings = audit.findings
23
- .map(
24
- (finding) => `<tr class="finding ${finding.severity}" data-severity="${finding.severity}">
25
- <td><span class="badge ${finding.severity}">${SEVERITY_LABEL[finding.severity] ?? finding.severity}</span></td>
26
- <td class="rule">${esc(finding.rule)}</td>
27
- <td>${esc(finding.message)}</td>
28
- </tr>`,
29
- )
30
- .join("\n")
31
-
32
- const skills = audit.skills
33
- .map(
34
- (skill) => `<tr class="skill">
35
- <td class="mono">${esc(skill.id)}</td>
36
- <td>${esc(skill.source.scope)}</td>
37
- <td>${skill.advertised ? "yes" : "no"}</td>
38
- <td class="num">${skill.descriptionTokens}</td>
39
- <td class="num">${skill.bodyTokens}</td>
40
- <td class="desc">${esc(String(skill.description ?? "").slice(0, 80))}</td>
41
- <td class="mono path">${esc(skill.file)}</td>
42
- </tr>`,
43
- )
44
- .join("\n")
45
-
46
- return `<!doctype html>
47
- <html lang="en">
48
- <head>
49
- <meta charset="utf-8">
50
- <meta name="viewport" content="width=device-width, initial-scale=1">
51
- <title>${esc(title)}</title>
52
- <style>
53
- :root { color-scheme: light dark; --fg:#1f2328; --muted:#656d76; --bg:#fff; --card:#f6f8fa; --border:#d0d7de; --red:#cf222e; --yellow:#9a6700; --green:#1a7f37; --blue:#0969da; }
54
- @media (prefers-color-scheme: dark) { :root { --fg:#e6edf3; --muted:#8d96a0; --bg:#0d1117; --card:#161b22; --border:#30363d; --red:#ff7b72; --yellow:#d29922; --green:#3fb950; --blue:#58a6ff; } }
55
- * { box-sizing: border-box; }
56
- 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); }
57
- h1 { margin:0 0 4px; font-size:24px; }
58
- h2 { margin:32px 0 12px; font-size:18px; }
59
- .sub { color:var(--muted); font-size:12px; }
60
- .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:20px 0; }
61
- .card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px 14px; }
62
- .card .k { color:var(--muted); font-size:12px; }
63
- .card .v { font-size:22px; font-weight:600; margin-top:2px; }
64
- .card.error .v { color:var(--red); } .card.warn .v { color:var(--yellow); } .card.info .v { color:var(--blue); }
65
- .bar { height:10px; border-radius:6px; background:var(--card); border:1px solid var(--border); overflow:hidden; margin:8px 0 4px; }
66
- .bar > span { display:block; height:100%; width:${barWidth}%; }
67
- .bar > span.ok { background:var(--green); } .bar > span.warn { background:var(--yellow); } .bar > span.over { background:var(--red); }
68
- table { width:100%; border-collapse:collapse; background:var(--bg); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
69
- th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
70
- th { background:var(--card); font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.03em; }
71
- tr:last-child td { border-bottom:none; }
72
- .badge { display:inline-block; padding:1px 8px; border-radius:999px; font-size:11px; font-weight:600; border:1px solid currentColor; }
73
- .badge.error { color:var(--red); } .badge.warn { color:var(--yellow); } .badge.info { color:var(--blue); }
74
- .rule { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; white-space:nowrap; }
75
- .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
76
- .path { color:var(--muted); word-break:break-all; }
77
- .num { text-align:right; font-variant-numeric:tabular-nums; }
78
- .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin:12px 0; }
79
- .toolbar button, .toolbar input { font:inherit; padding:6px 10px; border-radius:8px; border:1px solid var(--border); background:var(--card); color:var(--fg); }
80
- .toolbar button.active { border-color:var(--blue); color:var(--blue); }
81
- .empty { color:var(--muted); padding:16px; }
82
- footer { margin-top:40px; color:var(--muted); font-size:12px; }
83
- </style>
84
- </head>
85
- <body>
86
- <h1>${esc(title)}</h1>
87
- <div class="sub">generated ${esc(now.toISOString())} · ${esc(audit.cwd)}</div>
88
-
89
- <div class="cards">
90
- <div class="card"><div class="k">definitions</div><div class="v">${summary.total}</div></div>
91
- <div class="card"><div class="k">unique IDs</div><div class="v">${summary.unique}</div></div>
92
- <div class="card"><div class="k">advertised</div><div class="v">${summary.advertised}</div></div>
93
- <div class="card"><div class="k">description tokens / step</div><div class="v">${summary.advertisedTokens}</div></div>
94
- <div class="card"><div class="k">potential savings / step</div><div class="v">${plan.recommended.savings}</div></div>
95
- <div class="card error"><div class="k">errors</div><div class="v">${counts.errors}</div></div>
96
- <div class="card warn"><div class="k">warnings</div><div class="v">${counts.warnings}</div></div>
97
- <div class="card info"><div class="k">info</div><div class="v">${counts.infos}</div></div>
98
- </div>
99
-
100
- <h2>Context budget</h2>
101
- <div class="bar"><span class="${barClass}"></span></div>
102
- <div class="sub">${summary.advertisedTokens} tokens vs budget ${budget}${summary.advertisedTokens > budget * 2 ? " (over 2x)" : summary.advertisedTokens > budget ? " (over)" : " (ok)"}</div>
103
- <div class="sub">plan: trim ${plan.trim.count} + hide ${plan.recommended.hides.length} → ~${plan.recommended.final} tokens/step (saves ~${plan.recommended.savings}; ~${plan.recommended.stepsSavings} over ${plan.steps} steps)</div>
104
-
105
- <h2>Findings (${audit.findings.length})</h2>
106
- <div class="toolbar">
107
- <button data-filter="all" class="active">all</button>
108
- <button data-filter="error">error</button>
109
- <button data-filter="warn">warn</button>
110
- <button data-filter="info">info</button>
111
- <input id="q" placeholder="filter text…" />
112
- </div>
113
- <table id="findings">
114
- <thead><tr><th>severity</th><th>rule</th><th>message</th></tr></thead>
115
- <tbody>
116
- ${findings || '<tr><td colspan="3" class="empty">No problems found.</td></tr>'}
117
- </tbody>
118
- </table>
119
-
120
- <h2>Skills (${audit.skills.length})</h2>
121
- <table>
122
- <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>
123
- <tbody>
124
- ${skills}
125
- </tbody>
126
- </table>
127
-
128
- <footer>skills-doctor · ${esc(audit.version ?? "")} · updates: re-run <code>skills-doctor check --html</code></footer>
129
-
130
- <script>
131
- const buttons = [...document.querySelectorAll("[data-filter]")]
132
- const q = document.getElementById("q")
133
- let active = "all"
134
- function apply() {
135
- const text = (q.value || "").toLowerCase()
136
- for (const row of document.querySelectorAll("tr.finding")) {
137
- const okSeverity = active === "all" || row.dataset.severity === active
138
- const okText = !text || row.textContent.toLowerCase().includes(text)
139
- row.style.display = okSeverity && okText ? "" : "none"
140
- }
141
- }
142
- for (const button of buttons) {
143
- button.addEventListener("click", () => {
144
- active = button.dataset.filter
145
- for (const other of buttons) other.classList.toggle("active", other === button)
146
- apply()
147
- })
148
- }
149
- q.addEventListener("input", apply)
150
- </script>
151
- </body>
152
- </html>
153
- `
154
- }
155
-
156
- export function writeHtmlReport(audit, file, options = {}) {
157
- const target = path.resolve(file)
158
- fs.mkdirSync(path.dirname(target), { recursive: true })
159
- fs.writeFileSync(target, renderHtml(audit, options), "utf8")
160
- return target
161
- }
162
-
163
- /** 用系统默认程序打开报告;失败时静默(调用方已经知道路径)。 */
164
- export function openReport(file) {
165
- const target = path.resolve(file)
166
- try {
167
- if (process.platform === "win32") spawn("cmd", ["/c", "start", "", target], { detached: true, stdio: "ignore" }).unref()
168
- else if (process.platform === "darwin") spawn("open", [target], { detached: true, stdio: "ignore" }).unref()
169
- else spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref()
170
- } catch {}
171
- }