opencode-skills-doctor 0.0.0-stage → 0.3.0

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,166 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { createHash } from "node:crypto"
4
+ import { translator } from "./i18n.mjs"
5
+
6
+ export const LOCK_NAME = ".skills-doctor.lock.json"
7
+ export const LOCK_VERSION = 2
8
+
9
+ function sha1Buffer(buffer) {
10
+ return createHash("sha1").update(buffer).digest("hex")
11
+ }
12
+
13
+ function safeRelPath(p) {
14
+ if (typeof p !== "string" || !p.trim()) return false
15
+ if (path.isAbsolute(p)) return false
16
+ const normalized = p.replace(/\\/g, "/")
17
+ if (normalized.split("/").some((seg) => seg === "..")) return false
18
+ if (/^[a-z][a-z0-9+.-]*:/i.test(normalized)) return false
19
+ return true
20
+ }
21
+
22
+ function resolveInside(baseDir, rel, t) {
23
+ const base = path.resolve(baseDir)
24
+ const target = path.resolve(base, rel)
25
+ if (target !== base && !target.startsWith(base + path.sep)) {
26
+ throw new Error(t("catalog.unsafePath", { rel }))
27
+ }
28
+ return target
29
+ }
30
+
31
+ export function validateCatalogEntry(entry, t = translator("en")) {
32
+ const problems = []
33
+ if (!entry || typeof entry !== "object") return [t("catalog.entryNotObject")]
34
+ if (typeof entry.name !== "string" || !entry.name.trim()) problems.push(t("catalog.missingName"))
35
+ if (typeof entry.version !== "string" && typeof entry.version !== "number") {
36
+ problems.push(t("catalog.missingVersion"))
37
+ }
38
+ if (!Array.isArray(entry.files) || entry.files.length === 0) {
39
+ problems.push(t("catalog.missingFiles"))
40
+ } else {
41
+ for (const file of entry.files) {
42
+ if (!safeRelPath(file)) problems.push(t("catalog.safePath", { file }))
43
+ }
44
+ const hasEntryFile =
45
+ entry.files.includes("SKILL.md") ||
46
+ (typeof entry.name === "string" && entry.files.includes(`${entry.name}.md`))
47
+ if (!hasEntryFile) problems.push(t("catalog.missingEntryFile"))
48
+ }
49
+ return problems
50
+ }
51
+
52
+ /** 读取某目录下的 lock(容忍旧版本、未来版本、BOM、损坏)。 */
53
+ export function readLockFile(dir) {
54
+ const file = path.join(dir, LOCK_NAME)
55
+ if (!fs.existsSync(file)) return null
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""))
58
+ if (!parsed || typeof parsed !== "object" || typeof parsed.catalogs !== "object" || !parsed.catalogs) return null
59
+ return parsed
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ function lockEntryVersion(entry) {
66
+ if (typeof entry === "string") return entry
67
+ if (entry && typeof entry === "object" && entry.version != null) return String(entry.version)
68
+ return null
69
+ }
70
+
71
+ /** 本地文件是否仍与 lock 里的哈希一致(无哈希时视为一致)。 */
72
+ function filesMatch(into, name, files, t) {
73
+ if (!files || typeof files !== "object") return true
74
+ for (const [rel, expected] of Object.entries(files)) {
75
+ const target = resolveInside(path.join(into, name), rel, t)
76
+ if (!fs.existsSync(target)) return false
77
+ if (sha1Buffer(fs.readFileSync(target)) !== expected) return false
78
+ }
79
+ return true
80
+ }
81
+
82
+ /** 抓取 <url>/index.json。未知字段一律忽略(向上兼容)。 */
83
+ export async function fetchCatalogIndex(url, { fetchImpl = fetch, timeoutMs = 15000, t = translator("en") } = {}) {
84
+ const base = url.replace(/\/+$/, "")
85
+ const res = await fetchImpl(`${base}/index.json`, { signal: AbortSignal.timeout(timeoutMs) })
86
+ if (!res.ok) throw new Error(t("catalog.indexHttp", { status: res.status }))
87
+ const json = await res.json()
88
+ if (!json || !Array.isArray(json.skills)) throw new Error(t("catalog.indexShape"))
89
+ return { base, skills: json.skills }
90
+ }
91
+
92
+ /**
93
+ * 更新器:对比 HTTP catalog 的 version,必要时下载到 into 目录并写 lock。
94
+ * lock 里记录每个文件的 sha1,检测器据此判断「更新后是否被改过」。
95
+ */
96
+ export async function updateCatalogs(catalogs, { into = process.cwd(), checkOnly = false, fetchImpl = fetch, t = translator("en") } = {}) {
97
+ const lockPath = path.join(into, LOCK_NAME)
98
+ const lock = readLockFile(into) ?? { version: LOCK_VERSION, catalogs: {} }
99
+ const lockTooNew = Number(lock.version ?? 1) > LOCK_VERSION
100
+ const results = []
101
+
102
+ for (const catalog of catalogs) {
103
+ const result = { url: catalog.url, error: null, updated: [], unchanged: [], invalid: [], files: 0 }
104
+ try {
105
+ const { base, skills } = await fetchCatalogIndex(catalog.url, { fetchImpl, t })
106
+ const baseUrl = new URL(base + "/")
107
+ const previousSkills = lock.catalogs[catalog.url]?.skills ?? {}
108
+ const nextSkills = {}
109
+
110
+ for (const entry of skills) {
111
+ const problems = validateCatalogEntry(entry, t)
112
+ if (problems.length) {
113
+ result.invalid.push({ name: String(entry?.name ?? "?"), problems })
114
+ continue
115
+ }
116
+ const version = String(entry.version)
117
+ const previousVersion = lockEntryVersion(previousSkills[entry.name])
118
+ const prevEntry = previousSkills[entry.name]
119
+ const prevHashes = prevEntry && typeof prevEntry === "object" ? prevEntry.files : null
120
+ const drifted = previousVersion === version && prevHashes ? !filesMatch(into, entry.name, prevHashes, t) : false
121
+
122
+ if (previousVersion === version && !drifted) {
123
+ result.unchanged.push(entry.name)
124
+ nextSkills[entry.name] = typeof prevEntry === "string" ? { version: prevEntry, files: {} } : prevEntry
125
+ continue
126
+ }
127
+
128
+ if (checkOnly) {
129
+ result.updated.push({ name: entry.name, version, previous: previousVersion, reason: drifted ? "drift" : null })
130
+ continue
131
+ }
132
+
133
+ const hashes = {}
134
+ for (const file of entry.files) {
135
+ const encodedPath = file.split("/").map(encodeURIComponent).join("/")
136
+ const fileUrl = new URL(`${encodeURIComponent(entry.name)}/${encodedPath}`, baseUrl)
137
+ if (fileUrl.origin !== baseUrl.origin) throw new Error(t("catalog.crossOrigin", { file }))
138
+ const res = await fetchImpl(fileUrl, { signal: AbortSignal.timeout(30000) })
139
+ if (!res.ok) throw new Error(t("catalog.fileHttp", { name: entry.name, file, status: res.status }))
140
+ const buffer = Buffer.from(await res.arrayBuffer())
141
+ const target = resolveInside(path.join(into, entry.name), file, t)
142
+ fs.mkdirSync(path.dirname(target), { recursive: true })
143
+ fs.writeFileSync(target, buffer)
144
+ hashes[file] = sha1Buffer(buffer)
145
+ result.files++
146
+ }
147
+ nextSkills[entry.name] = { version, files: hashes }
148
+ result.updated.push({ name: entry.name, version, previous: previousVersion, reason: drifted ? "drift" : null })
149
+ }
150
+
151
+ if (!checkOnly) lock.catalogs[catalog.url] = { skills: nextSkills }
152
+ } catch (err) {
153
+ result.error = String(err?.message ?? err)
154
+ }
155
+ results.push(result)
156
+ }
157
+
158
+ if (!checkOnly && results.some((r) => !r.error)) {
159
+ lock.version = LOCK_VERSION
160
+ lock.generatedAt = new Date().toISOString()
161
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true })
162
+ fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n", "utf8")
163
+ }
164
+
165
+ return { lockPath, lockTooNew, results }
166
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,484 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { discoverSources, scanAll } from "./discover.mjs"
4
+ import { resolveWinners, SEVERITY_ORDER } from "./lint.mjs"
5
+ import { updateCatalogs, LOCK_NAME } from "./catalog.mjs"
6
+ import { inspectManaged } from "./managed.mjs"
7
+ import { planFixes, applyFixes, hideSkills } from "./fix.mjs"
8
+ import { auditSkills } from "./audit.mjs"
9
+ import { loadConfig } from "./config.mjs"
10
+ import { padDisplay } from "./parse.mjs"
11
+ import { detectLang, normalizeLang, translator } from "./i18n.mjs"
12
+
13
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"))
14
+
15
+ const color = process.stdout.isTTY && !process.env.NO_COLOR
16
+ const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text)
17
+ const red = (t) => paint("31", t)
18
+ const yellow = (t) => paint("33", t)
19
+ const green = (t) => paint("32", t)
20
+ const cyan = (t) => paint("36", t)
21
+ const dim = (t) => paint("2", t)
22
+ const bold = (t) => paint("1", t)
23
+
24
+ const HINT_KEYS = {
25
+ shadowed: "hint.shadowed",
26
+ "flat-dir-collision": "hint.flat-dir-collision",
27
+ "duplicate-content": "hint.duplicate-content",
28
+ "near-duplicate-description": "hint.near-duplicate-description",
29
+ "missing-description": "hint.missing-description",
30
+ "long-description": "hint.long-description",
31
+ "large-body": "hint.large-body",
32
+ "token-budget": "hint.token-budget",
33
+ "too-many-skills": "hint.too-many-skills",
34
+ "invalid-id": "hint.invalid-id",
35
+ "hidden-skill": "hint.hidden-skill",
36
+ "stale-skill": "hint.stale-skill",
37
+ }
38
+
39
+ function parseArgs(argv) {
40
+ const options = {
41
+ command: "check",
42
+ root: process.cwd(),
43
+ global: true,
44
+ budget: null,
45
+ staleDays: null,
46
+ strict: false,
47
+ json: false,
48
+ lang: null,
49
+ config: null,
50
+ dryRun: false,
51
+ hide: [],
52
+ into: null,
53
+ checkOnly: false,
54
+ help: false,
55
+ version: false,
56
+ }
57
+
58
+ const args = [...argv]
59
+ const commands = new Set(["check", "scan", "update", "fix", "help"])
60
+ if (args[0] && commands.has(args[0])) {
61
+ options.command = args.shift()
62
+ }
63
+
64
+ for (let i = 0; i < args.length; i++) {
65
+ const arg = args[i]
66
+ switch (arg) {
67
+ case "--root":
68
+ options.root = path.resolve(args[++i] ?? ".")
69
+ break
70
+ case "--no-global":
71
+ options.global = false
72
+ break
73
+ case "--budget":
74
+ options.budget = Number(args[++i])
75
+ break
76
+ case "--stale-days":
77
+ options.staleDays = Number(args[++i])
78
+ break
79
+ case "--strict":
80
+ options.strict = true
81
+ break
82
+ case "--json":
83
+ options.json = true
84
+ break
85
+ case "--lang":
86
+ options.lang = args[++i] ?? null
87
+ break
88
+ case "--config":
89
+ options.config = args[++i] ?? null
90
+ break
91
+ case "--dry-run":
92
+ options.dryRun = true
93
+ break
94
+ case "--hide":
95
+ options.hide.push(
96
+ ...String(args[++i] ?? "")
97
+ .split(",")
98
+ .map((id) => id.trim())
99
+ .filter(Boolean),
100
+ )
101
+ break
102
+ case "--into":
103
+ options.into = path.resolve(args[++i] ?? ".")
104
+ break
105
+ case "--check-only":
106
+ options.checkOnly = true
107
+ break
108
+ case "-h":
109
+ case "--help":
110
+ options.help = true
111
+ break
112
+ case "-v":
113
+ case "--version":
114
+ options.version = true
115
+ break
116
+ default:
117
+ if (arg.startsWith("-")) {
118
+ console.error(`unknown option: ${arg}`)
119
+ process.exit(2)
120
+ }
121
+ }
122
+ }
123
+
124
+ if (options.budget !== null && (!Number.isFinite(options.budget) || options.budget <= 0)) options.budget = null
125
+ if (options.staleDays !== null && (!Number.isFinite(options.staleDays) || options.staleDays < 0)) options.staleDays = null
126
+ return options
127
+ }
128
+
129
+ function serializeSkills(skills, managed = new Map()) {
130
+ return skills.map((s) => ({
131
+ id: s.id,
132
+ form: s.form,
133
+ file: s.file,
134
+ kind: s.source.kind,
135
+ scope: s.source.scope,
136
+ order: s.order,
137
+ advertised: s.advertised,
138
+ hidden: s.hidden,
139
+ descriptionChars: s.description.length,
140
+ descriptionTokens: s.descriptionTokens,
141
+ bodyTokens: s.bodyTokens,
142
+ bytes: s.bytes,
143
+ mtime: s.mtime ? new Date(s.mtime).toISOString() : null,
144
+ managed: managed.get(s.file) ?? null,
145
+ }))
146
+ }
147
+
148
+ function buildCheckResult(audit, options) {
149
+ return {
150
+ tool: "skills-doctor",
151
+ version: pkg.version,
152
+ lang: audit.lang,
153
+ cwd: audit.cwd,
154
+ projectRoot: audit.projectRoot,
155
+ config: audit.config,
156
+ budget: audit.budget,
157
+ staleDays: audit.staleDays,
158
+ sources: audit.sources.map((s) => ({
159
+ kind: s.kind,
160
+ scope: s.scope,
161
+ dir: s.dir,
162
+ order: s.order,
163
+ exists: fs.existsSync(s.dir),
164
+ })),
165
+ catalogs: audit.catalogs,
166
+ missing: audit.missing,
167
+ summary: audit.summary,
168
+ managed: { count: audit.managed.size },
169
+ findings: audit.findings,
170
+ counts: audit.counts,
171
+ exitCode: audit.counts.errors > 0 || (options.strict && audit.counts.warnings > 0) ? 1 : 0,
172
+ }
173
+ }
174
+
175
+ function printCheck(audit, result, options, t) {
176
+ const { summary, counts } = audit
177
+ console.log(bold(t("check.title", { version: result.version })))
178
+ console.log(dim(t("check.workdir", { dir: result.cwd })))
179
+ console.log(dim(t("check.projectRoot", { dir: result.projectRoot })))
180
+ if (result.config.error) {
181
+ console.log(yellow(t("config.error", { file: result.config.file, error: result.config.error })))
182
+ } else if (fs.existsSync(result.config.file)) {
183
+ console.log(dim(t("check.config", { file: result.config.file })))
184
+ }
185
+ console.log(
186
+ t("check.sources", {
187
+ total: result.sources.length,
188
+ existing: result.sources.filter((s) => s.exists).length,
189
+ definitions: summary.total,
190
+ unique: summary.unique,
191
+ advertised: summary.advertised,
192
+ tokens: summary.advertisedTokens,
193
+ }),
194
+ )
195
+ if (result.catalogs.length) console.log(dim(t("check.catalogs", { count: result.catalogs.length })))
196
+ if (result.managed.count > 0) console.log(dim(t("check.managed", { count: result.managed.count })))
197
+ for (const missing of result.missing) {
198
+ if (missing.kind === "config-error") {
199
+ console.log(yellow(t("check.configError", { path: missing.path, error: missing.error })))
200
+ } else if (missing.kind === "missing-path") {
201
+ console.log(yellow(t("check.missingPath", { path: missing.path })))
202
+ }
203
+ }
204
+ console.log("")
205
+
206
+ if (!result.findings.length) {
207
+ console.log(bold(t("check.none")))
208
+ } else {
209
+ const badge = { error: red("[E]"), warn: yellow("[W]"), info: cyan("[i]") }
210
+ for (const finding of result.findings) {
211
+ console.log(`${badge[finding.severity]} ${dim(finding.rule)} ${finding.message}`)
212
+ }
213
+ console.log("")
214
+ console.log(
215
+ t("check.summary", { errors: counts.errors, warnings: counts.warnings, infos: counts.infos }) +
216
+ (options.strict ? t("check.strict") : ""),
217
+ )
218
+ }
219
+
220
+ const usedRules = [...new Set(result.findings.map((f) => f.rule))].filter((r) => HINT_KEYS[r])
221
+ if (usedRules.length) {
222
+ console.log("")
223
+ console.log(bold(t("check.hints")))
224
+ for (const rule of usedRules) console.log(dim(` - ${t(HINT_KEYS[rule])}`))
225
+ }
226
+ }
227
+
228
+ function printScan(discovered, options, managed, t) {
229
+ const winners = resolveWinners(discovered.skills)
230
+ const rows = discovered.skills
231
+ .slice()
232
+ .sort((a, b) => a.id.localeCompare(b.id) || a.order - b.order)
233
+ .map((s) => {
234
+ const info = managed.get(s.file)
235
+ return {
236
+ id: s.id + (winners.get(s.id) === s ? " *" : "") + (info ? ` @${info.version}` : ""),
237
+ scope: s.source.scope,
238
+ adv: s.advertised ? "yes" : "no",
239
+ tokens: String(s.descriptionTokens),
240
+ body: String(s.bodyTokens),
241
+ file: s.file,
242
+ }
243
+ })
244
+
245
+ console.log(bold(t("scan.title", { version: pkg.version })))
246
+ console.log(dim(t("scan.workdir", { dir: path.resolve(options.root) })))
247
+ console.log(dim(t("scan.projectRoot", { dir: discovered.projectRoot })))
248
+ console.log("")
249
+
250
+ const widths = {
251
+ id: Math.max(4, ...rows.map((r) => r.id.length)) + 2,
252
+ scope: 8,
253
+ adv: 4,
254
+ tokens: 7,
255
+ body: 6,
256
+ }
257
+ console.log(
258
+ dim(
259
+ padDisplay(t("scan.col.id"), widths.id) +
260
+ padDisplay(t("scan.col.scope"), widths.scope) +
261
+ padDisplay(t("scan.col.adv"), widths.adv) +
262
+ padDisplay(t("scan.col.desc"), widths.tokens) +
263
+ padDisplay(t("scan.col.body"), widths.body) +
264
+ t("scan.col.file"),
265
+ ),
266
+ )
267
+ for (const row of rows) {
268
+ console.log(
269
+ padDisplay(row.id, widths.id) +
270
+ padDisplay(row.scope, widths.scope) +
271
+ padDisplay(row.adv, widths.adv) +
272
+ padDisplay(row.tokens, widths.tokens) +
273
+ padDisplay(row.body, widths.body) +
274
+ dim(row.file),
275
+ )
276
+ }
277
+ console.log("")
278
+ console.log(dim(t("scan.winner")))
279
+ if (discovered.catalogs.length) {
280
+ console.log(dim(t("scan.catalogs", { urls: discovered.catalogs.map((c) => c.url).join(", ") })))
281
+ }
282
+ }
283
+
284
+ async function runUpdate(discovered, options, cwd, t) {
285
+ const into = options.into ? path.resolve(options.into) : path.join(cwd, ".opencode", "skills")
286
+ if (!discovered.catalogs.length) {
287
+ console.log(bold(t("update.title")))
288
+ console.log(` ${t("update.noCatalogs")}`)
289
+ return 0
290
+ }
291
+
292
+ const { lockPath, results, lockTooNew } = await updateCatalogs(discovered.catalogs, {
293
+ into,
294
+ checkOnly: options.checkOnly,
295
+ t,
296
+ })
297
+
298
+ if (options.json) {
299
+ console.log(JSON.stringify({ lockPath, checkOnly: options.checkOnly, results }, null, 2))
300
+ return results.some((r) => r.error) ? 1 : 0
301
+ }
302
+
303
+ console.log(bold(t(options.checkOnly ? "update.titleCheckOnly" : "update.title")))
304
+ console.log(dim(t("update.into", { dir: into })))
305
+ console.log("")
306
+ for (const result of results) {
307
+ console.log(cyan(result.url))
308
+ if (result.error) {
309
+ console.log(red(t("update.failed", { error: result.error })))
310
+ continue
311
+ }
312
+ if (result.updated.length) {
313
+ const label = t(options.checkOnly ? "update.labelAvailable" : "update.labelUpdated")
314
+ const items = result.updated
315
+ .map((u) => {
316
+ if (u.reason === "drift") return t("update.driftRepair", { name: u.name, version: u.version })
317
+ if (u.previous) return t("update.versionBump", { name: u.name, previous: u.previous, version: u.version })
318
+ return t("update.versioned", { name: u.name, version: u.version })
319
+ })
320
+ .join(", ")
321
+ console.log(green(t("update.updated", { label, count: result.updated.length, items })))
322
+ }
323
+ if (result.unchanged.length) console.log(dim(t("update.unchanged", { count: result.unchanged.length })))
324
+ if (result.files) console.log(dim(t("update.files", { count: result.files })))
325
+ for (const invalid of result.invalid) {
326
+ console.log(yellow(t("update.skipped", { name: invalid.name, problems: invalid.problems.join("; ") })))
327
+ }
328
+ if (!result.updated.length && !result.unchanged.length && !result.invalid.length) {
329
+ console.log(dim(t("update.empty")))
330
+ }
331
+ }
332
+ console.log("")
333
+ console.log(dim(t("update.lock", { path: lockPath, name: LOCK_NAME })))
334
+ if (lockTooNew) console.log(yellow(t("update.lockTooNew")))
335
+ if (!options.checkOnly && results.some((r) => !r.error)) {
336
+ console.log(dim(t("update.detector", { dir: into })))
337
+ }
338
+ return results.some((r) => r.error) ? 1 : 0
339
+ }
340
+
341
+ function runFix(audit, options, t) {
342
+ const { plan, skipped } = planFixes(audit.skills, audit.managed)
343
+ const archived = applyFixes(plan, { dryRun: options.dryRun })
344
+ const hidden = options.hide.length ? hideSkills(audit.skills, options.hide, { dryRun: options.dryRun }) : { changed: [], errors: [], missing: [] }
345
+
346
+ const errors = [...archived.errors, ...hidden.errors]
347
+
348
+ if (options.json) {
349
+ console.log(
350
+ JSON.stringify(
351
+ {
352
+ tool: "skills-doctor",
353
+ version: pkg.version,
354
+ lang: audit.lang,
355
+ dryRun: options.dryRun,
356
+ archived: archived.moved.map((m) => ({ id: m.id, from: m.from, to: m.target })),
357
+ hidden: hidden.changed,
358
+ skipped: skipped.map((s) => ({ id: s.id, file: s.file })),
359
+ missing: hidden.missing,
360
+ errors: errors.map((e) => ({ id: e.id, error: e.error })),
361
+ },
362
+ null,
363
+ 2,
364
+ ),
365
+ )
366
+ return errors.length ? 1 : 0
367
+ }
368
+
369
+ const nothing = !plan.length && !skipped.length && !hidden.changed.length && !hidden.missing.length
370
+ if (nothing) {
371
+ console.log(bold(t("fix.title")))
372
+ console.log(` ${t("fix.none")}`)
373
+ return 0
374
+ }
375
+
376
+ console.log(bold(t("fix.title")))
377
+ if (options.dryRun) console.log(dim(t("fix.dryRun")))
378
+ for (const item of archived.moved) console.log(green(t("fix.moved", { from: item.from, to: item.target })))
379
+ for (const item of hidden.changed) console.log(green(t("fix.hidden", { id: item.id, file: item.file })))
380
+ for (const item of skipped) console.log(dim(t("fix.skippedManaged", { path: item.file })))
381
+ for (const id of hidden.missing) console.log(yellow(t("fix.unknownHide", { id })))
382
+ for (const item of errors) console.log(red(t("fix.error", { path: item.file ?? item.from, error: item.error })))
383
+ console.log("")
384
+ console.log(
385
+ t("fix.summary", {
386
+ moved: archived.moved.length,
387
+ hidden: hidden.changed.length,
388
+ skipped: skipped.length,
389
+ }),
390
+ )
391
+ console.log(dim(t("fix.rerun")))
392
+ return errors.length ? 1 : 0
393
+ }
394
+
395
+ export async function main(argv) {
396
+ const options = parseArgs(argv)
397
+
398
+ if (options.lang && !normalizeLang(options.lang)) {
399
+ console.error(`unsupported --lang: ${options.lang} (expected en or zh)`)
400
+ process.exit(2)
401
+ }
402
+
403
+ const cwd = path.resolve(options.root)
404
+ const loaded = loadConfig(cwd, options.config)
405
+ const lang = normalizeLang(options.lang) ?? normalizeLang(loaded.config.lang) ?? detectLang()
406
+ const t = translator(lang)
407
+
408
+ if (options.help || options.command === "help") {
409
+ console.log(t("help", { version: pkg.version }))
410
+ return
411
+ }
412
+ if (options.version) {
413
+ console.log(pkg.version)
414
+ return
415
+ }
416
+
417
+ if (options.command === "check") {
418
+ const audit = auditSkills({
419
+ cwd,
420
+ includeGlobal: options.global,
421
+ lang,
422
+ configPath: options.config,
423
+ budget: options.budget,
424
+ staleDays: options.staleDays,
425
+ })
426
+ if (options.json) {
427
+ console.log(
428
+ JSON.stringify(
429
+ { ...buildCheckResult(audit, options), skills: serializeSkills(audit.skills, audit.managed) },
430
+ null,
431
+ 2,
432
+ ),
433
+ )
434
+ } else {
435
+ const result = buildCheckResult(audit, options)
436
+ printCheck(audit, result, options, t)
437
+ process.exitCode = result.exitCode
438
+ return
439
+ }
440
+ process.exitCode = buildCheckResult(audit, options).exitCode
441
+ return
442
+ }
443
+
444
+ const discovered = scanAll(discoverSources({ cwd, includeGlobal: options.global }))
445
+ const managedInfo = inspectManaged(discovered.scans, { t })
446
+
447
+ if (options.command === "scan") {
448
+ if (options.json) {
449
+ console.log(
450
+ JSON.stringify(
451
+ {
452
+ tool: "skills-doctor",
453
+ version: pkg.version,
454
+ lang,
455
+ cwd,
456
+ skills: serializeSkills(discovered.skills, managedInfo.managed),
457
+ },
458
+ null,
459
+ 2,
460
+ ),
461
+ )
462
+ return
463
+ }
464
+ printScan(discovered, options, managedInfo.managed, t)
465
+ return
466
+ }
467
+
468
+ if (options.command === "update") {
469
+ process.exitCode = await runUpdate(discovered, options, cwd, t)
470
+ return
471
+ }
472
+
473
+ if (options.command === "fix") {
474
+ const audit = auditSkills({
475
+ cwd,
476
+ includeGlobal: options.global,
477
+ lang,
478
+ configPath: options.config,
479
+ budget: options.budget,
480
+ staleDays: options.staleDays,
481
+ })
482
+ process.exitCode = runFix(audit, options, t)
483
+ }
484
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export const CONFIG_NAME = ".skills-doctor.json"
5
+
6
+ function normalize(raw) {
7
+ const config = {}
8
+ if (Number.isFinite(raw?.budget) && raw.budget > 0) config.budget = raw.budget
9
+ if (Number.isFinite(raw?.staleDays) && raw.staleDays >= 0) config.staleDays = raw.staleDays
10
+ if (typeof raw?.lang === "string") config.lang = raw.lang
11
+ if (raw?.rules && typeof raw.rules === "object") {
12
+ config.rules = {}
13
+ for (const [rule, level] of Object.entries(raw.rules)) {
14
+ if (level === "off" || level === "info" || level === "warn" || level === "error") config.rules[rule] = level
15
+ }
16
+ }
17
+ if (Array.isArray(raw?.ignore)) config.ignore = raw.ignore.filter((id) => typeof id === "string")
18
+ return config
19
+ }
20
+
21
+ /**
22
+ * 读取配置(默认 <cwd>/.skills-doctor.json)。
23
+ * 解析失败时返回 { error, file },调用方决定是否继续。
24
+ */
25
+ export function loadConfig(cwd, configPath = null) {
26
+ const file = configPath ? path.resolve(cwd, configPath) : path.join(cwd, CONFIG_NAME)
27
+ if (!fs.existsSync(file)) return { file, config: {} }
28
+ try {
29
+ const raw = JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""))
30
+ return { file, config: normalize(raw) }
31
+ } catch (err) {
32
+ return { file, config: {}, error: String(err?.message ?? err) }
33
+ }
34
+ }