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,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,495 @@
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
+ all: false,
52
+ hide: [],
53
+ into: null,
54
+ checkOnly: false,
55
+ help: false,
56
+ version: false,
57
+ }
58
+
59
+ const args = [...argv]
60
+ const commands = new Set(["check", "scan", "update", "fix", "help"])
61
+ if (args[0] && commands.has(args[0])) {
62
+ options.command = args.shift()
63
+ }
64
+
65
+ for (let i = 0; i < args.length; i++) {
66
+ const arg = args[i]
67
+ switch (arg) {
68
+ case "--root":
69
+ options.root = path.resolve(args[++i] ?? ".")
70
+ break
71
+ case "--no-global":
72
+ options.global = false
73
+ break
74
+ case "--budget":
75
+ options.budget = Number(args[++i])
76
+ break
77
+ case "--stale-days":
78
+ options.staleDays = Number(args[++i])
79
+ break
80
+ case "--strict":
81
+ options.strict = true
82
+ break
83
+ case "--json":
84
+ options.json = true
85
+ break
86
+ case "--lang":
87
+ options.lang = args[++i] ?? null
88
+ break
89
+ case "--config":
90
+ options.config = args[++i] ?? null
91
+ break
92
+ case "--dry-run":
93
+ options.dryRun = true
94
+ break
95
+ case "--all":
96
+ options.all = true
97
+ break
98
+ case "--hide":
99
+ options.hide.push(
100
+ ...String(args[++i] ?? "")
101
+ .split(",")
102
+ .map((id) => id.trim())
103
+ .filter(Boolean),
104
+ )
105
+ break
106
+ case "--into":
107
+ options.into = path.resolve(args[++i] ?? ".")
108
+ break
109
+ case "--check-only":
110
+ options.checkOnly = true
111
+ break
112
+ case "-h":
113
+ case "--help":
114
+ options.help = true
115
+ break
116
+ case "-v":
117
+ case "--version":
118
+ options.version = true
119
+ break
120
+ default:
121
+ if (arg.startsWith("-")) {
122
+ console.error(`unknown option: ${arg}`)
123
+ process.exit(2)
124
+ }
125
+ }
126
+ }
127
+
128
+ if (options.budget !== null && (!Number.isFinite(options.budget) || options.budget <= 0)) options.budget = null
129
+ if (options.staleDays !== null && (!Number.isFinite(options.staleDays) || options.staleDays < 0)) options.staleDays = null
130
+ return options
131
+ }
132
+
133
+ function serializeSkills(skills, managed = new Map()) {
134
+ return skills.map((s) => ({
135
+ id: s.id,
136
+ form: s.form,
137
+ file: s.file,
138
+ kind: s.source.kind,
139
+ scope: s.source.scope,
140
+ order: s.order,
141
+ advertised: s.advertised,
142
+ hidden: s.hidden,
143
+ descriptionChars: s.description.length,
144
+ descriptionTokens: s.descriptionTokens,
145
+ bodyTokens: s.bodyTokens,
146
+ bytes: s.bytes,
147
+ mtime: s.mtime ? new Date(s.mtime).toISOString() : null,
148
+ managed: managed.get(s.file) ?? null,
149
+ }))
150
+ }
151
+
152
+ function buildCheckResult(audit, options) {
153
+ return {
154
+ tool: "skills-doctor",
155
+ version: pkg.version,
156
+ lang: audit.lang,
157
+ cwd: audit.cwd,
158
+ projectRoot: audit.projectRoot,
159
+ config: audit.config,
160
+ budget: audit.budget,
161
+ staleDays: audit.staleDays,
162
+ sources: audit.sources.map((s) => ({
163
+ kind: s.kind,
164
+ scope: s.scope,
165
+ dir: s.dir,
166
+ order: s.order,
167
+ exists: fs.existsSync(s.dir),
168
+ })),
169
+ catalogs: audit.catalogs,
170
+ missing: audit.missing,
171
+ summary: audit.summary,
172
+ managed: { count: audit.managed.size },
173
+ findings: audit.findings,
174
+ counts: audit.counts,
175
+ exitCode: audit.counts.errors > 0 || (options.strict && audit.counts.warnings > 0) ? 1 : 0,
176
+ }
177
+ }
178
+
179
+ function printCheck(audit, result, options, t) {
180
+ const { summary, counts } = audit
181
+ console.log(bold(t("check.title", { version: result.version })))
182
+ console.log(dim(t("check.workdir", { dir: result.cwd })))
183
+ console.log(dim(t("check.projectRoot", { dir: result.projectRoot })))
184
+ if (result.config.error) {
185
+ console.log(yellow(t("config.error", { file: result.config.file, error: result.config.error })))
186
+ } else if (fs.existsSync(result.config.file)) {
187
+ console.log(dim(t("check.config", { file: result.config.file })))
188
+ }
189
+ console.log(
190
+ t("check.sources", {
191
+ total: result.sources.length,
192
+ existing: result.sources.filter((s) => s.exists).length,
193
+ definitions: summary.total,
194
+ unique: summary.unique,
195
+ advertised: summary.advertised,
196
+ tokens: summary.advertisedTokens,
197
+ }),
198
+ )
199
+ if (result.catalogs.length) console.log(dim(t("check.catalogs", { count: result.catalogs.length })))
200
+ if (result.managed.count > 0) console.log(dim(t("check.managed", { count: result.managed.count })))
201
+ for (const missing of result.missing) {
202
+ if (missing.kind === "config-error") {
203
+ console.log(yellow(t("check.configError", { path: missing.path, error: missing.error })))
204
+ } else if (missing.kind === "missing-path") {
205
+ console.log(yellow(t("check.missingPath", { path: missing.path })))
206
+ }
207
+ }
208
+ console.log("")
209
+
210
+ if (!result.findings.length) {
211
+ console.log(bold(t("check.none")))
212
+ } else {
213
+ const badge = { error: red("[E]"), warn: yellow("[W]"), info: cyan("[i]") }
214
+ for (const finding of result.findings) {
215
+ console.log(`${badge[finding.severity]} ${dim(finding.rule)} ${finding.message}`)
216
+ }
217
+ console.log("")
218
+ console.log(
219
+ t("check.summary", { errors: counts.errors, warnings: counts.warnings, infos: counts.infos }) +
220
+ (options.strict ? t("check.strict") : ""),
221
+ )
222
+ }
223
+
224
+ const usedRules = [...new Set(result.findings.map((f) => f.rule))].filter((r) => HINT_KEYS[r])
225
+ if (usedRules.length) {
226
+ console.log("")
227
+ console.log(bold(t("check.hints")))
228
+ for (const rule of usedRules) console.log(dim(` - ${t(HINT_KEYS[rule])}`))
229
+ }
230
+ }
231
+
232
+ function printScan(discovered, options, managed, t) {
233
+ const winners = resolveWinners(discovered.skills)
234
+ const rows = discovered.skills
235
+ .slice()
236
+ .sort((a, b) => a.id.localeCompare(b.id) || a.order - b.order)
237
+ .map((s) => {
238
+ const info = managed.get(s.file)
239
+ return {
240
+ id: s.id + (winners.get(s.id) === s ? " *" : "") + (info ? ` @${info.version}` : ""),
241
+ scope: s.source.scope,
242
+ adv: s.advertised ? "yes" : "no",
243
+ tokens: String(s.descriptionTokens),
244
+ body: String(s.bodyTokens),
245
+ file: s.file,
246
+ }
247
+ })
248
+
249
+ console.log(bold(t("scan.title", { version: pkg.version })))
250
+ console.log(dim(t("scan.workdir", { dir: path.resolve(options.root) })))
251
+ console.log(dim(t("scan.projectRoot", { dir: discovered.projectRoot })))
252
+ console.log("")
253
+
254
+ const widths = {
255
+ id: Math.max(4, ...rows.map((r) => r.id.length)) + 2,
256
+ scope: 8,
257
+ adv: 4,
258
+ tokens: 7,
259
+ body: 6,
260
+ }
261
+ console.log(
262
+ dim(
263
+ padDisplay(t("scan.col.id"), widths.id) +
264
+ padDisplay(t("scan.col.scope"), widths.scope) +
265
+ padDisplay(t("scan.col.adv"), widths.adv) +
266
+ padDisplay(t("scan.col.desc"), widths.tokens) +
267
+ padDisplay(t("scan.col.body"), widths.body) +
268
+ t("scan.col.file"),
269
+ ),
270
+ )
271
+ for (const row of rows) {
272
+ console.log(
273
+ padDisplay(row.id, widths.id) +
274
+ padDisplay(row.scope, widths.scope) +
275
+ padDisplay(row.adv, widths.adv) +
276
+ padDisplay(row.tokens, widths.tokens) +
277
+ padDisplay(row.body, widths.body) +
278
+ dim(row.file),
279
+ )
280
+ }
281
+ console.log("")
282
+ console.log(dim(t("scan.winner")))
283
+ if (discovered.catalogs.length) {
284
+ console.log(dim(t("scan.catalogs", { urls: discovered.catalogs.map((c) => c.url).join(", ") })))
285
+ }
286
+ }
287
+
288
+ async function runUpdate(discovered, options, cwd, t) {
289
+ const into = options.into ? path.resolve(options.into) : path.join(cwd, ".opencode", "skills")
290
+ if (!discovered.catalogs.length) {
291
+ console.log(bold(t("update.title")))
292
+ console.log(` ${t("update.noCatalogs")}`)
293
+ return 0
294
+ }
295
+
296
+ const { lockPath, results, lockTooNew } = await updateCatalogs(discovered.catalogs, {
297
+ into,
298
+ checkOnly: options.checkOnly,
299
+ t,
300
+ })
301
+
302
+ if (options.json) {
303
+ console.log(JSON.stringify({ lockPath, checkOnly: options.checkOnly, results }, null, 2))
304
+ return results.some((r) => r.error) ? 1 : 0
305
+ }
306
+
307
+ console.log(bold(t(options.checkOnly ? "update.titleCheckOnly" : "update.title")))
308
+ console.log(dim(t("update.into", { dir: into })))
309
+ console.log("")
310
+ for (const result of results) {
311
+ console.log(cyan(result.url))
312
+ if (result.error) {
313
+ console.log(red(t("update.failed", { error: result.error })))
314
+ continue
315
+ }
316
+ if (result.updated.length) {
317
+ const label = t(options.checkOnly ? "update.labelAvailable" : "update.labelUpdated")
318
+ const items = result.updated
319
+ .map((u) => {
320
+ if (u.reason === "drift") return t("update.driftRepair", { name: u.name, version: u.version })
321
+ if (u.previous) return t("update.versionBump", { name: u.name, previous: u.previous, version: u.version })
322
+ return t("update.versioned", { name: u.name, version: u.version })
323
+ })
324
+ .join(", ")
325
+ console.log(green(t("update.updated", { label, count: result.updated.length, items })))
326
+ }
327
+ if (result.unchanged.length) console.log(dim(t("update.unchanged", { count: result.unchanged.length })))
328
+ if (result.files) console.log(dim(t("update.files", { count: result.files })))
329
+ for (const invalid of result.invalid) {
330
+ console.log(yellow(t("update.skipped", { name: invalid.name, problems: invalid.problems.join("; ") })))
331
+ }
332
+ if (!result.updated.length && !result.unchanged.length && !result.invalid.length) {
333
+ console.log(dim(t("update.empty")))
334
+ }
335
+ }
336
+ console.log("")
337
+ console.log(dim(t("update.lock", { path: lockPath, name: LOCK_NAME })))
338
+ if (lockTooNew) console.log(yellow(t("update.lockTooNew")))
339
+ if (!options.checkOnly && results.some((r) => !r.error)) {
340
+ console.log(dim(t("update.detector", { dir: into })))
341
+ }
342
+ return results.some((r) => r.error) ? 1 : 0
343
+ }
344
+
345
+ function runFix(audit, options, t) {
346
+ const { plan, skipped, skippedGlobal } = planFixes(audit.skills, audit.managed, { includeGlobal: options.all })
347
+ const archived = applyFixes(plan, { dryRun: options.dryRun })
348
+ const hideCandidates = options.all ? audit.skills : audit.skills.filter((s) => s.source.scope !== "global")
349
+ const hidden = options.hide.length
350
+ ? hideSkills(hideCandidates, options.hide, { dryRun: options.dryRun })
351
+ : { changed: [], errors: [], missing: [] }
352
+
353
+ const errors = [...archived.errors, ...hidden.errors]
354
+
355
+ if (options.json) {
356
+ console.log(
357
+ JSON.stringify(
358
+ {
359
+ tool: "skills-doctor",
360
+ version: pkg.version,
361
+ lang: audit.lang,
362
+ dryRun: options.dryRun,
363
+ all: options.all,
364
+ archived: archived.moved.map((m) => ({ id: m.id, from: m.from, to: m.target })),
365
+ hidden: hidden.changed,
366
+ skipped: skipped.map((s) => ({ id: s.id, file: s.file })),
367
+ skippedGlobal: skippedGlobal.map((s) => ({ id: s.id, file: s.file })),
368
+ missing: hidden.missing,
369
+ errors: errors.map((e) => ({ id: e.id, error: e.error })),
370
+ },
371
+ null,
372
+ 2,
373
+ ),
374
+ )
375
+ return errors.length ? 1 : 0
376
+ }
377
+
378
+ const nothing =
379
+ !plan.length && !skipped.length && !skippedGlobal.length && !hidden.changed.length && !hidden.missing.length
380
+ if (nothing) {
381
+ console.log(bold(t("fix.title")))
382
+ console.log(` ${t("fix.none")}`)
383
+ return 0
384
+ }
385
+
386
+ console.log(bold(t("fix.title")))
387
+ if (options.dryRun) console.log(dim(t("fix.dryRun")))
388
+ for (const item of archived.moved) console.log(green(t("fix.moved", { from: item.from, to: item.target })))
389
+ for (const item of hidden.changed) console.log(green(t("fix.hidden", { id: item.id, file: item.file })))
390
+ for (const item of skipped) console.log(dim(t("fix.skippedManaged", { path: item.file })))
391
+ for (const item of skippedGlobal) console.log(dim(t("fix.skippedGlobal", { path: item.file })))
392
+ for (const id of hidden.missing) console.log(yellow(t("fix.unknownHide", { id })))
393
+ for (const item of errors) console.log(red(t("fix.error", { path: item.file ?? item.from, error: item.error })))
394
+ console.log("")
395
+ console.log(
396
+ t("fix.summary", {
397
+ moved: archived.moved.length,
398
+ hidden: hidden.changed.length,
399
+ skipped: skipped.length + skippedGlobal.length,
400
+ }),
401
+ )
402
+ console.log(dim(t("fix.rerun")))
403
+ return errors.length ? 1 : 0
404
+ }
405
+
406
+ export async function main(argv) {
407
+ const options = parseArgs(argv)
408
+
409
+ if (options.lang && !normalizeLang(options.lang)) {
410
+ console.error(`unsupported --lang: ${options.lang} (expected en or zh)`)
411
+ process.exit(2)
412
+ }
413
+
414
+ const cwd = path.resolve(options.root)
415
+ const loaded = loadConfig(cwd, options.config)
416
+ const lang = normalizeLang(options.lang) ?? normalizeLang(loaded.config.lang) ?? detectLang()
417
+ const t = translator(lang)
418
+
419
+ if (options.help || options.command === "help") {
420
+ console.log(t("help", { version: pkg.version }))
421
+ return
422
+ }
423
+ if (options.version) {
424
+ console.log(pkg.version)
425
+ return
426
+ }
427
+
428
+ if (options.command === "check") {
429
+ const audit = auditSkills({
430
+ cwd,
431
+ includeGlobal: options.global,
432
+ lang,
433
+ configPath: options.config,
434
+ budget: options.budget,
435
+ staleDays: options.staleDays,
436
+ })
437
+ if (options.json) {
438
+ console.log(
439
+ JSON.stringify(
440
+ { ...buildCheckResult(audit, options), skills: serializeSkills(audit.skills, audit.managed) },
441
+ null,
442
+ 2,
443
+ ),
444
+ )
445
+ } else {
446
+ const result = buildCheckResult(audit, options)
447
+ printCheck(audit, result, options, t)
448
+ process.exitCode = result.exitCode
449
+ return
450
+ }
451
+ process.exitCode = buildCheckResult(audit, options).exitCode
452
+ return
453
+ }
454
+
455
+ const discovered = scanAll(discoverSources({ cwd, includeGlobal: options.global }))
456
+ const managedInfo = inspectManaged(discovered.scans, { t })
457
+
458
+ if (options.command === "scan") {
459
+ if (options.json) {
460
+ console.log(
461
+ JSON.stringify(
462
+ {
463
+ tool: "skills-doctor",
464
+ version: pkg.version,
465
+ lang,
466
+ cwd,
467
+ skills: serializeSkills(discovered.skills, managedInfo.managed),
468
+ },
469
+ null,
470
+ 2,
471
+ ),
472
+ )
473
+ return
474
+ }
475
+ printScan(discovered, options, managedInfo.managed, t)
476
+ return
477
+ }
478
+
479
+ if (options.command === "update") {
480
+ process.exitCode = await runUpdate(discovered, options, cwd, t)
481
+ return
482
+ }
483
+
484
+ if (options.command === "fix") {
485
+ const audit = auditSkills({
486
+ cwd,
487
+ includeGlobal: options.global,
488
+ lang,
489
+ configPath: options.config,
490
+ budget: options.budget,
491
+ staleDays: options.staleDays,
492
+ })
493
+ process.exitCode = runFix(audit, options, t)
494
+ }
495
+ }
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
+ }