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.
@@ -0,0 +1,308 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import os from "node:os"
4
+ import { parseFrontmatter, estimateTokens, sha1 } from "./parse.mjs"
5
+
6
+ const CONFIG_FILES = ["opencode.jsonc", "opencode.json"]
7
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".hg", ".svn", "dist", "build", ".venv", "__pycache__"])
8
+
9
+ export function expandHome(p, home = os.homedir()) {
10
+ if (p === "~") return home
11
+ if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(home, p.slice(2))
12
+ return p
13
+ }
14
+
15
+ /** 去掉 JSONC 的注释和尾逗号,字符串内的内容不动。 */
16
+ export function stripJsonc(text) {
17
+ let out = ""
18
+ let inString = false
19
+ let escaped = false
20
+ let inLine = false
21
+ let inBlock = false
22
+
23
+ for (let i = 0; i < text.length; i++) {
24
+ const ch = text[i]
25
+ const next = text[i + 1]
26
+
27
+ if (inLine) {
28
+ if (ch === "\n") {
29
+ inLine = false
30
+ out += ch
31
+ }
32
+ continue
33
+ }
34
+ if (inBlock) {
35
+ if (ch === "*" && next === "/") {
36
+ inBlock = false
37
+ i++
38
+ }
39
+ continue
40
+ }
41
+ if (inString) {
42
+ out += ch
43
+ if (escaped) escaped = false
44
+ else if (ch === "\\") escaped = true
45
+ else if (ch === '"') inString = false
46
+ continue
47
+ }
48
+ if (ch === '"') {
49
+ inString = true
50
+ out += ch
51
+ continue
52
+ }
53
+ if (ch === "/" && next === "/") {
54
+ inLine = true
55
+ i++
56
+ continue
57
+ }
58
+ if (ch === "/" && next === "*") {
59
+ inBlock = true
60
+ i++
61
+ continue
62
+ }
63
+ out += ch
64
+ }
65
+
66
+ return out.replace(/,(\s*[}\]])/g, "$1")
67
+ }
68
+
69
+ export function readJsoncFile(file) {
70
+ const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "")
71
+ return JSON.parse(stripJsonc(text))
72
+ }
73
+
74
+ export function findProjectRoot(start) {
75
+ let dir = path.resolve(start)
76
+ for (;;) {
77
+ if (fs.existsSync(path.join(dir, ".git"))) return dir
78
+ const parent = path.dirname(dir)
79
+ if (parent === dir) return null
80
+ dir = parent
81
+ }
82
+ }
83
+
84
+ /** root 到 cwd 的路径链(远端在前,cwd 在最后)。 */
85
+ export function pathChain(root, cwd) {
86
+ const stop = path.resolve(root)
87
+ const start = path.resolve(cwd)
88
+ const rel = path.relative(stop, start)
89
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return [start]
90
+
91
+ const chain = []
92
+ let dir = start
93
+ for (;;) {
94
+ chain.push(dir)
95
+ if (dir === stop) break
96
+ const parent = path.dirname(dir)
97
+ if (parent === dir) break
98
+ dir = parent
99
+ }
100
+ return chain.reverse()
101
+ }
102
+
103
+ function configCandidates(dir) {
104
+ return CONFIG_FILES.map((name) => path.join(dir, name))
105
+ }
106
+
107
+ function isUnder(parent, child) {
108
+ const rel = path.relative(path.resolve(parent), path.resolve(child))
109
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
110
+ }
111
+
112
+ /**
113
+ * 按官方文档的注册顺序返回 skill 来源(顺序即优先级,越后越高):
114
+ * 1. ~/.claude/skills
115
+ * 2. 项目 .claude/skills(远端 → cwd)
116
+ * 3. ~/.agents/skills
117
+ * 4. 项目 .agents/skills(远端 → cwd)
118
+ * 5. ~/.config/opencode/skills
119
+ * 6. 项目 .opencode/skills(项目根 → cwd)
120
+ * 7. opencode.json(c) 里的 skills 数组
121
+ */
122
+ export function discoverSources({ cwd = process.cwd(), home = os.homedir(), includeGlobal = true } = {}) {
123
+ const projectRoot = findProjectRoot(cwd) ?? path.resolve(cwd)
124
+ const projectChain = pathChain(projectRoot, cwd)
125
+ const sources = []
126
+ const catalogs = []
127
+ const missing = []
128
+ const seen = new Set()
129
+
130
+ const addSource = (kind, scope, dir) => {
131
+ const abs = path.resolve(dir)
132
+ if (seen.has(abs)) return
133
+ seen.add(abs)
134
+ sources.push({ kind, scope, dir: abs, order: sources.length })
135
+ }
136
+
137
+ if (includeGlobal) addSource("claude-global", "global", path.join(home, ".claude", "skills"))
138
+ for (const dir of projectChain) addSource("claude-project", "project", path.join(dir, ".claude", "skills"))
139
+
140
+ if (includeGlobal) addSource("agents-global", "global", path.join(home, ".agents", "skills"))
141
+ for (const dir of projectChain) addSource("agents-project", "project", path.join(dir, ".agents", "skills"))
142
+
143
+ if (includeGlobal) addSource("opencode-global", "global", path.join(home, ".config", "opencode", "skills"))
144
+ for (const dir of projectChain) addSource("opencode-project", "project", path.join(dir, ".opencode", "skills"))
145
+
146
+ const configFiles = []
147
+ if (includeGlobal) {
148
+ for (const file of configCandidates(path.join(home, ".config", "opencode"))) {
149
+ if (fs.existsSync(file)) configFiles.push(file)
150
+ }
151
+ }
152
+ for (const dir of projectChain) {
153
+ for (const file of configCandidates(dir)) {
154
+ if (fs.existsSync(file)) configFiles.push(file)
155
+ }
156
+ }
157
+
158
+ for (const file of configFiles) {
159
+ let config
160
+ try {
161
+ config = readJsoncFile(file)
162
+ } catch (err) {
163
+ missing.push({ kind: "config-error", path: file, error: err.message })
164
+ continue
165
+ }
166
+ // 2.x: skills: [...];1.x: skills: { paths: [...] }
167
+ const entries = Array.isArray(config?.skills)
168
+ ? config.skills
169
+ : Array.isArray(config?.skills?.paths)
170
+ ? config.skills.paths
171
+ : []
172
+ for (const entry of entries) {
173
+ if (typeof entry !== "string" || !entry.trim()) continue
174
+ const value = entry.trim()
175
+ if (/^https?:\/\//i.test(value)) {
176
+ catalogs.push({ url: value, config: file })
177
+ continue
178
+ }
179
+ const expanded = expandHome(value, home)
180
+ const abs = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded)
181
+ if (fs.existsSync(abs)) {
182
+ addSource("config-path", isUnder(projectRoot, file) ? "project" : "global", abs)
183
+ } else missing.push({ kind: "missing-path", path: abs, entry: value, config: file })
184
+ }
185
+ }
186
+
187
+ return { sources, catalogs, missing, configs: configFiles, projectRoot }
188
+ }
189
+
190
+ function safeReaddir(dir) {
191
+ try {
192
+ return fs.readdirSync(dir, { withFileTypes: true })
193
+ } catch {
194
+ return []
195
+ }
196
+ }
197
+
198
+ function isSkippable(name) {
199
+ return name.startsWith(".") || SKIP_DIRS.has(name)
200
+ }
201
+
202
+ /** 跟随符号链接判断实际类型;坏链接返回 "broken"。 */
203
+ function direntKind(fullPath, entry) {
204
+ if (entry.isSymbolicLink()) {
205
+ try {
206
+ const stat = fs.statSync(fullPath)
207
+ if (stat.isDirectory()) return "dir"
208
+ if (stat.isFile()) return "file"
209
+ return "other"
210
+ } catch {
211
+ return "broken"
212
+ }
213
+ }
214
+ if (entry.isDirectory()) return "dir"
215
+ if (entry.isFile()) return "file"
216
+ return "other"
217
+ }
218
+
219
+ function walkForSkillMd(dir, source, out, depth, seen) {
220
+ if (depth > 8) return
221
+ let real
222
+ try {
223
+ real = fs.realpathSync(dir)
224
+ } catch {
225
+ return
226
+ }
227
+ if (seen.has(real)) return
228
+ seen.add(real)
229
+
230
+ for (const entry of safeReaddir(dir)) {
231
+ if (isSkippable(entry.name)) continue
232
+ const full = path.join(dir, entry.name)
233
+ const kind = direntKind(full, entry)
234
+ if (kind === "dir") {
235
+ walkForSkillMd(full, source, out, depth + 1, seen)
236
+ } else if (kind === "file" && entry.name === "SKILL.md") {
237
+ out.push(buildSkill({ id: path.basename(dir), file: full, source, form: "dir" }))
238
+ }
239
+ }
240
+ }
241
+
242
+ function buildSkill({ id, file, source, form }) {
243
+ let text = ""
244
+ try {
245
+ text = fs.readFileSync(file, "utf8")
246
+ } catch {
247
+ text = ""
248
+ }
249
+ const { data, body } = parseFrontmatter(text)
250
+ const autoinvoke = data?.metadata?.["opencode/autoinvoke"]
251
+ const disabled = data?.["disable-model-invocation"]
252
+ const description = typeof data.description === "string" ? data.description.trim() : ""
253
+ const displayName = typeof data.name === "string" && data.name.trim() ? data.name.trim() : id
254
+ const hidden = autoinvoke === false || disabled === true
255
+ let mtime = 0
256
+ try {
257
+ mtime = fs.statSync(file).mtimeMs
258
+ } catch {}
259
+
260
+ return {
261
+ id,
262
+ form,
263
+ file,
264
+ source,
265
+ order: source.order,
266
+ displayName,
267
+ description,
268
+ autoinvoke,
269
+ disabled,
270
+ hidden,
271
+ advertised: Boolean(description) && !hidden,
272
+ body,
273
+ bodyTokens: estimateTokens(body),
274
+ bytes: Buffer.byteLength(text),
275
+ descriptionTokens: estimateTokens(description),
276
+ hash: sha1(body.replace(/\s+/g, " ").trim()),
277
+ mtime,
278
+ }
279
+ }
280
+
281
+ export function scanSource(source) {
282
+ if (!fs.existsSync(source.dir)) return { source, skills: [], exists: false }
283
+ const stat = fs.statSync(source.dir)
284
+ if (!stat.isDirectory()) return { source, skills: [], exists: false }
285
+
286
+ const skills = []
287
+ for (const entry of safeReaddir(source.dir)) {
288
+ if (isSkippable(entry.name)) continue
289
+ const full = path.join(source.dir, entry.name)
290
+ const kind = direntKind(full, entry)
291
+ if (kind === "file" && /\.md$/i.test(entry.name)) {
292
+ const id = entry.name.replace(/\.md$/i, "")
293
+ skills.push(buildSkill({ id, file: full, source, form: "flat" }))
294
+ } else if (kind === "dir") {
295
+ walkForSkillMd(full, source, skills, 1, new Set())
296
+ }
297
+ }
298
+ return { source, skills, exists: true }
299
+ }
300
+
301
+ export function scanAll(discovered) {
302
+ const scans = discovered.sources.map(scanSource)
303
+ return {
304
+ ...discovered,
305
+ scans,
306
+ skills: scans.flatMap((s) => s.skills),
307
+ }
308
+ }
package/src/fix.mjs ADDED
@@ -0,0 +1,166 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { resolveWinners } from "./lint.mjs"
4
+
5
+ export const ARCHIVE_DIR = ".skills-doctor-archive"
6
+
7
+ function collectSkillMds(dir, out = []) {
8
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue
10
+ const full = path.join(dir, entry.name)
11
+ let kind = entry.isDirectory() ? "dir" : entry.isFile() ? "file" : "other"
12
+ if (entry.isSymbolicLink()) {
13
+ try {
14
+ kind = fs.statSync(full).isDirectory() ? "dir" : "file"
15
+ } catch {
16
+ continue
17
+ }
18
+ }
19
+ if (kind === "dir") collectSkillMds(full, out)
20
+ else if (entry.name === "SKILL.md") out.push(full)
21
+ }
22
+ return out
23
+ }
24
+
25
+ function archiveTarget(skill, stamp) {
26
+ const sourceDir = skill.source.dir
27
+ const inner = skill.form === "dir" ? path.dirname(skill.file) : skill.file
28
+ const rel = path.relative(sourceDir, inner)
29
+ return path.join(sourceDir, ARCHIVE_DIR, stamp, rel)
30
+ }
31
+
32
+ /**
33
+ * 规划安全的结构性修复:只归档「被遮蔽、永远不会加载」的定义。
34
+ * 由更新器管理的文件不碰(应通过 update 恢复)。
35
+ */
36
+ export function planFixes(skills, managed = new Map(), { now = new Date(), includeGlobal = false } = {}) {
37
+ const winners = resolveWinners(skills)
38
+ const byId = new Map()
39
+ for (const skill of skills) {
40
+ if (!byId.has(skill.id)) byId.set(skill.id, [])
41
+ byId.get(skill.id).push(skill)
42
+ }
43
+
44
+ const stamp = now.toISOString().replace(/[:.]/g, "-")
45
+ const plan = []
46
+ const skipped = []
47
+ const skippedGlobal = []
48
+
49
+ for (const [id, defs] of byId) {
50
+ if (defs.length <= 1) continue
51
+ const winner = winners.get(id)
52
+ for (const loser of defs) {
53
+ if (loser === winner) continue
54
+ if (!includeGlobal && loser.source.scope === "global") {
55
+ skippedGlobal.push(loser)
56
+ continue
57
+ }
58
+ if (managed.has(loser.file)) {
59
+ skipped.push(loser)
60
+ continue
61
+ }
62
+ let from = loser.file
63
+ if (loser.form === "dir") {
64
+ const dir = path.dirname(loser.file)
65
+ // 目录里若还有别的 SKILL.md,只挪这一个文件,避免误伤其他 skill
66
+ from = collectSkillMds(dir).length > 1 ? loser.file : dir
67
+ }
68
+ plan.push({ id, from, file: loser.file, winner: winner.file, target: archiveTarget(loser, stamp) })
69
+ }
70
+ }
71
+
72
+ return { plan, skipped, skippedGlobal }
73
+ }
74
+
75
+ export function applyFixes(plan, { dryRun = false } = {}) {
76
+ const moved = []
77
+ const errors = []
78
+
79
+ for (const fix of plan) {
80
+ if (dryRun) {
81
+ moved.push(fix)
82
+ continue
83
+ }
84
+ try {
85
+ let target = fix.target
86
+ let n = 1
87
+ while (fs.existsSync(target)) target = `${fix.target}-${n++}`
88
+ fs.mkdirSync(path.dirname(target), { recursive: true })
89
+ fs.renameSync(fix.from, target)
90
+ moved.push({ ...fix, target })
91
+ } catch (err) {
92
+ errors.push({ ...fix, error: String(err?.message ?? err) })
93
+ }
94
+ }
95
+
96
+ return { moved, errors }
97
+ }
98
+
99
+ /** 在 frontmatter 里把 metadata.opencode/autoinvoke 设为 false。 */
100
+ export function setAutoinvokeFalse(text) {
101
+ const lines = text.split(/\r?\n/)
102
+ const snippet = ["metadata:", " opencode/autoinvoke: false"]
103
+ if (lines[0]?.trim() !== "---") return ["---", ...snippet, "---", ...lines].join("\n")
104
+
105
+ let end = -1
106
+ for (let i = 1; i < lines.length; i++) {
107
+ if (lines[i].trim() === "---") {
108
+ end = i
109
+ break
110
+ }
111
+ }
112
+ if (end === -1) return ["---", ...snippet, "---", ...lines].join("\n")
113
+
114
+ const fm = lines.slice(1, end)
115
+ const metaIdx = fm.findIndex((line) => /^metadata\s*:/.test(line.trim()))
116
+ if (metaIdx === -1) {
117
+ return [lines[0], ...fm, ...snippet, ...lines.slice(end)].join("\n")
118
+ }
119
+
120
+ const metaIndent = fm[metaIdx].length - fm[metaIdx].trimStart().length
121
+ let j = metaIdx + 1
122
+ while (j < fm.length && (fm[j].trim() === "" || fm[j].length - fm[j].trimStart().length > metaIndent)) j++
123
+
124
+ const block = fm.slice(metaIdx + 1, j)
125
+ let replaced = false
126
+ for (let k = 0; k < block.length; k++) {
127
+ if (/^\s*opencode\/autoinvoke\s*:/.test(block[k])) {
128
+ block[k] = " ".repeat(metaIndent + 2) + "opencode/autoinvoke: false"
129
+ replaced = true
130
+ }
131
+ }
132
+ if (!replaced) block.unshift(" ".repeat(metaIndent + 2) + "opencode/autoinvoke: false")
133
+
134
+ return [lines[0], ...fm.slice(0, metaIdx + 1), ...block, ...fm.slice(j), ...lines.slice(end)].join("\n")
135
+ }
136
+
137
+ /**
138
+ * 隐藏指定 ID 的 skill(给生效版本加 autoinvoke: false)。
139
+ * 用于给上下文预算减压:skill 仍可用 @id 手动加载。
140
+ */
141
+ export function hideSkills(skills, ids, { dryRun = false } = {}) {
142
+ const winners = resolveWinners(skills)
143
+ const wanted = [...new Set(ids)]
144
+ const changed = []
145
+ const errors = []
146
+ const missing = []
147
+
148
+ for (const id of wanted) {
149
+ const skill = winners.get(id)
150
+ if (!skill) {
151
+ missing.push(id)
152
+ continue
153
+ }
154
+ try {
155
+ if (!dryRun) {
156
+ const text = fs.readFileSync(skill.file, "utf8")
157
+ fs.writeFileSync(skill.file, setAutoinvokeFalse(text), "utf8")
158
+ }
159
+ changed.push({ id, file: skill.file })
160
+ } catch (err) {
161
+ errors.push({ id, file: skill.file, error: String(err?.message ?? err) })
162
+ }
163
+ }
164
+
165
+ return { changed, errors, missing }
166
+ }