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