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/discover.mjs
ADDED
|
@@ -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
|
+
}
|