opencode-skills-doctor 0.0.0-stage → 0.3.2

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/parse.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // 极简 frontmatter / YAML 子集解析 + 文本度量工具。
2
+ // 只服务 skills 场景:name、description、metadata.opencode/autoinvoke、
3
+ // disable-model-invocation 等,不追求完整 YAML。
4
+
5
+ import { createHash } from "node:crypto"
6
+
7
+ /** @param {string} text */
8
+ export function parseFrontmatter(text) {
9
+ const empty = { data: {}, body: text, hasFrontmatter: false }
10
+ if (!text.startsWith("---")) return empty
11
+
12
+ const lines = text.split(/\r?\n/)
13
+ if (lines[0].trim() !== "---") return empty
14
+
15
+ let end = -1
16
+ for (let i = 1; i < lines.length; i++) {
17
+ if (lines[i].trim() === "---") {
18
+ end = i
19
+ break
20
+ }
21
+ }
22
+ if (end === -1) return empty
23
+
24
+ return {
25
+ data: parseYamlLite(lines.slice(1, end)),
26
+ body: lines.slice(end + 1).join("\n"),
27
+ hasFrontmatter: true,
28
+ }
29
+ }
30
+
31
+ function leadingSpaces(line) {
32
+ return line.length - line.replace(/^[ \t]*/, "").length
33
+ }
34
+
35
+ function coerce(value) {
36
+ const v = value.trim()
37
+ if (v === "true") return true
38
+ if (v === "false") return false
39
+ if (v === "null" || v === "~") return null
40
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
41
+ return v.slice(1, -1).replace(/\\"/g, '"')
42
+ }
43
+ return v
44
+ }
45
+
46
+ /** @param {string[]} lines */
47
+ export function parseYamlLite(lines) {
48
+ const root = {}
49
+ const stack = [{ indent: -1, obj: root }]
50
+
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const raw = lines[i]
53
+ if (!raw.trim() || raw.trim().startsWith("#")) continue
54
+
55
+ const indent = leadingSpaces(raw)
56
+ const content = raw.trim()
57
+
58
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
59
+ const parent = stack[stack.length - 1].obj
60
+
61
+ // key 不含冒号;value 可以含冒号(如 "Prepare: notes")
62
+ const m = /^([^:#]+?)\s*:\s*(.*)$/.exec(content)
63
+ if (!m) continue
64
+ const key = m[1].trim()
65
+ const rest = m[2].trim()
66
+
67
+ if (rest === "") {
68
+ const child = {}
69
+ parent[key] = child
70
+ stack.push({ indent, obj: child })
71
+ continue
72
+ }
73
+
74
+ // 块标量:| 或 >
75
+ if (/^[|>][+-]?$/.test(rest)) {
76
+ const block = []
77
+ let j = i + 1
78
+ while (j < lines.length) {
79
+ const next = lines[j]
80
+ if (next.trim() === "") {
81
+ block.push("")
82
+ j++
83
+ continue
84
+ }
85
+ if (leadingSpaces(next) > indent) {
86
+ block.push(next)
87
+ j++
88
+ } else break
89
+ }
90
+ const nonEmpty = block.filter((l) => l.trim())
91
+ const minIndent = nonEmpty.length ? Math.min(...nonEmpty.map(leadingSpaces)) : 0
92
+ const joined = block.map((l) => l.slice(minIndent)).join("\n")
93
+ parent[key] = rest.startsWith(">") ? joined.replace(/\n+/g, " ").trim() : joined.trim()
94
+ i = j - 1
95
+ continue
96
+ }
97
+
98
+ parent[key] = coerce(rest)
99
+ }
100
+
101
+ return root
102
+ }
103
+
104
+ const CJK_CHAR = /[\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/
105
+ const CJK_ALL = /[\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g
106
+
107
+ /** 粗略 token 估算:CJK 约 1.7 字/token,其余约 4 字符/token。 */
108
+ export function estimateTokens(text) {
109
+ const str = String(text ?? "")
110
+ const cjk = (str.match(CJK_ALL) ?? []).length
111
+ return Math.ceil(cjk * 1.7 + (str.length - cjk) / 4)
112
+ }
113
+
114
+ /** 终端显示宽度(CJK 记 2 列) */
115
+ export function displayWidth(text) {
116
+ let width = 0
117
+ for (const ch of String(text ?? "")) {
118
+ width += CJK_CHAR.test(ch) ? 2 : 1
119
+ }
120
+ return width
121
+ }
122
+
123
+ export function padDisplay(text, width) {
124
+ const str = String(text ?? "")
125
+ const diff = width - displayWidth(str)
126
+ return diff > 0 ? str + " ".repeat(diff) : str
127
+ }
128
+
129
+ /** @param {string} text */
130
+ export function sha1(text) {
131
+ return createHash("sha1").update(text).digest("hex")
132
+ }
@@ -0,0 +1,31 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { auditSkills, formatMarkdown } from "./audit.mjs"
3
+
4
+ const z = tool.schema
5
+
6
+ /**
7
+ * OpenCode 1.x 插件入口(V1 API:`server` 函数 + tool())。
8
+ * 在 1.x 里通过 opencode.json 配置:
9
+ * { "plugin": ["opencode-skills-doctor/v1"] }
10
+ * 2.x 请使用包根导出(V2 的 Plugin.define)。
11
+ */
12
+ export const SkillsDoctorPlugin = async ({ directory }) => ({
13
+ tool: {
14
+ skills_doctor: tool({
15
+ description:
16
+ "Audit local OpenCode skills: duplicates, shadowing, context budget and fix hints. Returns a markdown report.",
17
+ args: z.object({
18
+ directory: z.string().optional().describe("Directory to audit (defaults to the current project)"),
19
+ budget: z.number().optional().describe("Description token budget (default 1200)"),
20
+ }),
21
+ execute: async (args) => {
22
+ const cwd = args.directory || directory || process.cwd()
23
+ const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null, lang: "en" })
24
+ return formatMarkdown(audit)
25
+ },
26
+ }),
27
+ },
28
+ })
29
+
30
+ export const server = SkillsDoctorPlugin
31
+ export default { id: "skills-doctor", server: SkillsDoctorPlugin }
package/src/plugin.mjs ADDED
@@ -0,0 +1,52 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { auditSkills, formatMarkdown } from "./audit.mjs"
3
+
4
+ /**
5
+ * OpenCode 插件入口:把 skills-doctor 暴露成 skills_doctor 工具和 /skills-doctor 命令。
6
+ * 通过 opencode.json(c) 加载:
7
+ * { "plugins": ["opencode-skills-doctor"] }
8
+ * 或本地路径。
9
+ */
10
+ export default Plugin.define({
11
+ id: "skills-doctor",
12
+
13
+ async setup(ctx) {
14
+ await ctx.tool.transform((editor) => {
15
+ editor.namespace({ name: "skills", description: "OpenCode skill diagnostics" })
16
+ editor.add({
17
+ name: "doctor",
18
+ description: "Audit local OpenCode skills: duplicates, shadowing, context budget and fix hints",
19
+ input: {
20
+ type: "object",
21
+ properties: {
22
+ directory: { type: "string", description: "Directory to audit (defaults to the current project)" },
23
+ budget: { type: "number", description: "Description token budget (default 1200)" },
24
+ },
25
+ additionalProperties: false,
26
+ },
27
+ options: { namespace: "skills", codemode: true },
28
+ execute: async (input) => {
29
+ const directory = (input && typeof input.directory === "string" && input.directory) || ctx.location.directory
30
+ const budget = input && typeof input.budget === "number" ? input.budget : null
31
+ const audit = auditSkills({ cwd: directory, budget, lang: "en" })
32
+ return { content: formatMarkdown(audit) }
33
+ },
34
+ })
35
+ })
36
+
37
+ await ctx.command.transform((editor) => {
38
+ editor.add({
39
+ name: "skills-doctor",
40
+ description: "Audit local OpenCode skills",
41
+ execute: async ({ sessionID, prompt, delivery }) => {
42
+ await ctx.session.prompt({
43
+ ...prompt,
44
+ sessionID,
45
+ text: "Run a skills audit with the skills_doctor tool, then summarize the top issues and propose concrete fixes.",
46
+ delivery,
47
+ })
48
+ },
49
+ })
50
+ })
51
+ },
52
+ })