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/LICENSE +21 -0
- package/README.md +196 -2
- package/README.zh-CN.md +197 -0
- package/bin/skills-doctor.mjs +7 -0
- package/package.json +50 -4
- package/src/audit.mjs +93 -0
- package/src/catalog.mjs +166 -0
- package/src/cli.mjs +495 -0
- package/src/config.mjs +34 -0
- package/src/discover.mjs +308 -0
- package/src/fix.mjs +166 -0
- package/src/i18n.mjs +306 -0
- package/src/lint.mjs +218 -0
- package/src/managed.mjs +76 -0
- package/src/parse.mjs +132 -0
- package/src/plugin-v1.mjs +31 -0
- package/src/plugin.mjs +52 -0
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
|
+
})
|