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/src/i18n.mjs ADDED
@@ -0,0 +1,306 @@
1
+ // 中英双语文案。所有用户可见文本都在这里,代码只引用 key。
2
+ // 新增语言时复制一份字典即可;缺失的 key 回退到英文。
3
+
4
+ const dict = {
5
+ en: {
6
+ help: `skills-doctor {version} — local scanner, linter, fixer, updater and plugin for OpenCode skills
7
+
8
+ Usage:
9
+ skills-doctor check [options] check sources, conflicts and context budget (default)
10
+ skills-doctor scan [options] list every skill with its source and precedence
11
+ skills-doctor fix [options] archive shadowed definitions (safe structural fixes)
12
+ skills-doctor update [options] compare and download skills from HTTP catalogs
13
+
14
+ Options:
15
+ --root <dir> working directory (default: current directory)
16
+ --no-global skip ~/.claude, ~/.agents and ~/.config/opencode
17
+ --budget <n> tokens allowed for advertised descriptions (default 1200)
18
+ --stale-days <n> report skills not updated in n days (default: off)
19
+ --strict exit non-zero on warnings too
20
+ --json print JSON
21
+ --lang <en|zh> output language (default: auto from system locale)
22
+ --config <file> config file (default: .skills-doctor.json)
23
+ --dry-run fix: only print what would change
24
+ --all fix: also touch global sources (default: project only)
25
+ --hide <ids> fix: hide skills with metadata.opencode/autoinvoke: false (comma separated)
26
+ --into <dir> update: output directory (default: .opencode/skills)
27
+ --check-only update: only report available updates
28
+ -h, --help show this help
29
+ -v, --version print version
30
+
31
+ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runtime error`,
32
+
33
+ "check.title": "skills-doctor {version}",
34
+ "check.workdir": " working dir: {dir}",
35
+ "check.projectRoot": " project root: {dir}",
36
+ "check.config": " config: {file}",
37
+ "check.sources":
38
+ " sources: {total} ({existing} exist) definitions: {definitions} unique IDs: {unique} advertised: {advertised} description budget: ~{tokens} tokens",
39
+ "check.catalogs": " HTTP catalogs: {count} (use update --check-only for versions)",
40
+ "check.managed": " managed by updater: {count} (lock-verified; see managed-* notices)",
41
+ "check.configError": " ! cannot parse config: {path} ({error})",
42
+ "check.missingPath": " ! path in config does not exist: {path}",
43
+ "check.none": "No problems found.",
44
+ "check.summary": "{errors} error, {warnings} warn, {infos} info",
45
+ "check.strict": " (--strict: warnings also fail)",
46
+ "check.hints": "Hints:",
47
+
48
+ "scan.title": "skills-doctor {version} — scan",
49
+ "scan.workdir": " working dir: {dir}",
50
+ "scan.projectRoot": " project root: {dir}",
51
+ "scan.col.id": "ID",
52
+ "scan.col.scope": "scope",
53
+ "scan.col.adv": "adv",
54
+ "scan.col.desc": "desc",
55
+ "scan.col.body": "body",
56
+ "scan.col.file": "file",
57
+ "scan.winner": " * = effective definition for this ID (later registration wins)",
58
+ "scan.catalogs": " HTTP catalogs: {urls}",
59
+
60
+ "update.title": "Updater",
61
+ "update.titleCheckOnly": "Updater (check only)",
62
+ "update.into": " output dir: {dir}",
63
+ "update.failed": " ✗ failed: {error}",
64
+ "update.updated": " ✓ {label} {count}: {items}",
65
+ "update.labelUpdated": "updated",
66
+ "update.labelAvailable": "available",
67
+ "update.unchanged": " · unchanged {count}",
68
+ "update.files": " · files written {count}",
69
+ "update.skipped": " ! skipped {name}: {problems}",
70
+ "update.empty": " · catalog is empty",
71
+ "update.lock": " lock: {path} ({name})",
72
+ "update.lockTooNew": " ! lock is newer than this tool; parsed in compatibility mode (unknown fields ignored)",
73
+ "update.detector": " check now sees these skills ({dir} is where opencode reads)",
74
+ "update.noCatalogs": "No HTTP catalog configured. Add https://... to the skills array in opencode.json(c).",
75
+ "update.driftRepair": "{name} {version} (drift repaired)",
76
+ "update.versioned": "{name} {version}",
77
+ "update.versionBump": "{name} {previous}→{version}",
78
+
79
+ "fix.title": "Fixer",
80
+ "fix.dryRun": " (dry run: nothing is moved)",
81
+ "fix.moved": " ✓ archived {from} → {to}",
82
+ "fix.skippedManaged": " · skipped {path} (installed by the updater; run update instead)",
83
+ "fix.skippedGlobal": " · skipped {path} (global skill; pass --all to include)",
84
+ "fix.none": "Nothing to fix.",
85
+ "fix.summary": "{moved} archived, {hidden} hidden, {skipped} skipped",
86
+ "fix.error": " ✗ failed to archive {path}: {error}",
87
+ "fix.rerun": "Run `skills-doctor check` again to confirm.",
88
+ "fix.hidden": " ✓ hid {id} ({file})",
89
+ "fix.unknownHide": " ! unknown skill id: {id}",
90
+ "config.error": " ! cannot read config: {file} ({error})",
91
+
92
+ "hint.shadowed":
93
+ "Delete the shadowed definitions or give them distinct IDs; otherwise you may be editing a file that never loads.",
94
+ "hint.flat-dir-collision": "Keep only one of the two forms in the same source.",
95
+ "hint.duplicate-content": "Merge into one skill and reference it from the other.",
96
+ "hint.near-duplicate-description": "Make descriptions more specific, or merge the skills.",
97
+ "hint.missing-description": "Add a description — the model only sees ID, name and description.",
98
+ "hint.long-description": "Keep the description to one or two sentences; move details into the body or references/.",
99
+ "hint.large-body": "Move details into references/ and keep SKILL.md to the workflow.",
100
+ "hint.token-budget":
101
+ "Trim descriptions, or hide low-frequency skills with metadata.opencode/autoinvoke: false.",
102
+ "hint.too-many-skills": "Hide low-frequency skills with autoinvoke: false and load them explicitly with @id.",
103
+ "hint.invalid-id": "Use lowercase kebab-case and keep it aligned with the directory name.",
104
+ "hint.hidden-skill": "Confirm the skill is intentionally hidden; hidden skills are not advertised.",
105
+ "hint.stale-skill": "Confirm the content is still current, or delete it.",
106
+
107
+ "lint.shadowed":
108
+ 'ID "{id}" is defined {count} times; the effective definition is {winner}; the other {losers} are shadowed (dead weight, easy to edit the wrong file)',
109
+ "lint.flatDirCollision":
110
+ 'Both {flat} and {dir} exist in the same source (ID "{id}"), semantics are ambiguous',
111
+ "lint.duplicateContent": "These skills have identical bodies: {ids}",
112
+ "lint.nearDuplicate":
113
+ '"{a}" and "{b}" descriptions are {score}% similar; the model may pick the wrong one',
114
+ "lint.missingDescription":
115
+ '"{id}" has no description, so the model will not discover it (only explicit @{id})',
116
+ "lint.longDescription": '"{id}" description is {chars} chars; it is paid on every model step',
117
+ "lint.hiddenSkill": '"{id}" is hidden (autoinvoke/disable-model-invocation); it is not advertised',
118
+ "lint.invalidId": '"{id}" does not follow the recommended kebab-case ID pattern (1-64 chars, lowercase)',
119
+ "lint.largeBody": '"{id}" body is about {tokens} tokens; invoking it pastes the whole body into context',
120
+ "lint.staleSkill": '"{id}" has not been updated for {days} days',
121
+ "lint.tokenBudget": "Advertised skill descriptions cost about {tokens} tokens per model step, over the {budget} budget",
122
+ "lint.tokenBudgetError":
123
+ "Advertised skill descriptions cost about {tokens} tokens per model step, over 2x the {budget} budget",
124
+ "lint.tooManySkills": "{count} skills are advertised to the model (recommended ≤ 60)",
125
+
126
+ "managed.lockUnsupported":
127
+ "Lock version {version} is newer than the supported {supported}; parsing in compatibility mode (unknown fields ignored)",
128
+ "managed.missing": "Catalog records {name}@{version}, but the local file is gone (deleted or renamed)",
129
+ "managed.unsafe": "Unsafe path in the lock for {name}: {rel}",
130
+ "managed.driftMissing": '"{name}" is missing {rel} (deleted after update?)',
131
+ "managed.driftModified": '"{name}" {rel} was modified after update (drift); the next update will overwrite it',
132
+
133
+ "catalog.indexHttp": "index.json returned HTTP {status}",
134
+ "catalog.indexShape": "index.json is missing the skills array",
135
+ "catalog.crossOrigin": "cross-origin file reference: {file}",
136
+ "catalog.fileHttp": "{name}/{file} returned HTTP {status}",
137
+ "catalog.unsafePath": "unsafe path: {rel}",
138
+ "catalog.entryNotObject": "entry is not an object",
139
+ "catalog.missingName": "missing name",
140
+ "catalog.missingVersion": "missing version",
141
+ "catalog.missingFiles": "missing files",
142
+ "catalog.safePath": "unsafe file path: {file}",
143
+ "catalog.missingEntryFile": "files has no SKILL.md or <name>.md",
144
+ },
145
+
146
+ zh: {
147
+ help: `skills-doctor {version} —— OpenCode skill 本地检测器 / 更新器 / 修复器 / 插件
148
+
149
+ 用法:
150
+ skills-doctor check [选项] 检查来源、冲突与上下文预算(默认命令)
151
+ skills-doctor scan [选项] 列出所有 skill 及其来源/优先级
152
+ skills-doctor fix [选项] 归档被遮蔽的定义(安全的结构性修复)
153
+ skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
154
+
155
+ 选项:
156
+ --root <dir> 以该目录为工作目录(默认当前目录)
157
+ --no-global 不扫描 ~/.claude、~/.agents、~/.config/opencode
158
+ --budget <n> 模型每步可看的 description token 预算(默认 1200)
159
+ --stale-days <n> 超过 n 天未更新的 skill 给出提示(默认关闭)
160
+ --strict 有 warning 也返回非 0
161
+ --json 输出 JSON
162
+ --lang <en|zh> 输出语言(默认按系统 locale 自动选择)
163
+ --config <file> 配置文件(默认 .skills-doctor.json)
164
+ --dry-run fix: 只打印将要变更的内容,不实际修改
165
+ --all fix: 同时处理全局来源的 skill(默认只动项目内)
166
+ --hide <ids> fix: 给指定 skill 加 metadata.opencode/autoinvoke: false(逗号分隔)
167
+ --into <dir> update 的落盘目录(默认 .opencode/skills)
168
+ --check-only update 只报告可用更新,不下载
169
+ -h, --help 帮助
170
+ -v, --version 版本
171
+
172
+ 退出码: 0 正常;1 有 error(--strict 时含 warning);2 参数/运行错误`,
173
+
174
+ "check.title": "skills-doctor {version}",
175
+ "check.workdir": " 工作目录: {dir}",
176
+ "check.projectRoot": " 项目根: {dir}",
177
+ "check.config": " 配置: {file}",
178
+ "check.sources":
179
+ " 来源: {total} 个(存在 {existing} 个) 定义: {definitions} 唯一 ID: {unique} 会展示: {advertised} 描述预算: ~{tokens} tokens",
180
+ "check.catalogs": " HTTP catalog: {count} 个(用 update --check-only 查版本)",
181
+ "check.managed": " 由更新器管理: {count} 个(lock 版本校验,见 managed-* 提示)",
182
+ "check.configError": " ! 配置文件无法解析: {path}({error})",
183
+ "check.missingPath": " ! 配置里引用的路径不存在: {path}",
184
+ "check.none": "没有发现问题。",
185
+ "check.summary": "{errors} error, {warnings} warn, {infos} info",
186
+ "check.strict": "(--strict:warning 也会非 0 退出)",
187
+ "check.hints": "建议:",
188
+
189
+ "scan.title": "skills-doctor {version} — scan",
190
+ "scan.workdir": " 工作目录: {dir}",
191
+ "scan.projectRoot": " 项目根: {dir}",
192
+ "scan.col.id": "ID",
193
+ "scan.col.scope": "scope",
194
+ "scan.col.adv": "adv",
195
+ "scan.col.desc": "desc",
196
+ "scan.col.body": "body",
197
+ "scan.col.file": "file",
198
+ "scan.winner": " * = 该 ID 的实际生效定义(注册顺序越后优先级越高)",
199
+ "scan.catalogs": " HTTP catalog: {urls}",
200
+
201
+ "update.title": "更新器",
202
+ "update.titleCheckOnly": "更新器(仅检查)",
203
+ "update.into": " 落盘目录: {dir}",
204
+ "update.failed": " ✗ 失败: {error}",
205
+ "update.updated": " ✓ {label} {count}: {items}",
206
+ "update.labelUpdated": "已更新",
207
+ "update.labelAvailable": "可更新",
208
+ "update.unchanged": " · 未变 {count}",
209
+ "update.files": " · 写入文件 {count}",
210
+ "update.skipped": " ! 跳过 {name}: {problems}",
211
+ "update.empty": " · catalog 为空",
212
+ "update.lock": " lock: {path}({name})",
213
+ "update.lockTooNew": " ! lock 版本比本工具新,已按兼容模式解析(未知字段忽略)",
214
+ "update.detector": " check 现在可以直接检测到这些 skill({dir} 是 opencode 会读取的目录)",
215
+ "update.noCatalogs": "没有配置 HTTP catalog。在 opencode.json(c) 的 skills 数组里写 https://... 即可。",
216
+ "update.driftRepair": "{name} {version}(修复 drift)",
217
+ "update.versioned": "{name} {version}",
218
+ "update.versionBump": "{name} {previous}→{version}",
219
+
220
+ "fix.title": "修复器",
221
+ "fix.dryRun": " (dry run:不会移动任何文件)",
222
+ "fix.moved": " ✓ 已归档 {from} → {to}",
223
+ "fix.skippedManaged": " · 跳过 {path}(由更新器管理,请用 update)",
224
+ "fix.skippedGlobal": " · 跳过 {path}(全局 skill,加 --all 才会处理)",
225
+ "fix.none": "没有需要修复的内容。",
226
+ "fix.summary": "归档 {moved} 个,隐藏 {hidden} 个,跳过 {skipped} 个",
227
+ "fix.error": " ✗ 归档 {path} 失败: {error}",
228
+ "fix.rerun": "再跑一次 `skills-doctor check` 确认。",
229
+ "fix.hidden": " ✓ 已隐藏 {id}({file})",
230
+ "fix.unknownHide": " ! 找不到 skill ID: {id}",
231
+ "config.error": " ! 无法读取配置: {file}({error})",
232
+
233
+ "hint.shadowed": "删掉被遮蔽的旧定义,或改用不同 ID;否则你改的和实际加载的可能不是同一份。",
234
+ "hint.flat-dir-collision": "同一来源里两种形式二选一,避免 ID 语义不清。",
235
+ "hint.duplicate-content": "合并成一份,另一处改为引用。",
236
+ "hint.near-duplicate-description": "把 description 写得更具体,或直接合并成一个 skill。",
237
+ "hint.missing-description": "补 description —— 模型只能看到 ID、name、description。",
238
+ "hint.long-description": "description 压到一两句话,细节放正文或 references/。",
239
+ "hint.large-body": "正文拆到 references/,SKILL.md 只保留流程。",
240
+ "hint.token-budget": "精简 description,或把低频 skill 设为 metadata.opencode/autoinvoke: false 隐藏。",
241
+ "hint.too-many-skills": "低频 skill 关掉 autoinvoke,用 @id 手动加载。",
242
+ "hint.invalid-id": "改成小写 kebab-case,并与目录名对齐。",
243
+ "hint.hidden-skill": "确认是刻意隐藏;隐藏的 skill 不会出现在模型列表里。",
244
+ "hint.stale-skill": "确认内容没过期,或删掉。",
245
+
246
+ "lint.shadowed":
247
+ 'ID "{id}" 有 {count} 处定义,实际生效的是 {winner};其余 {losers} 处被遮蔽(白占体积、容易改错文件)',
248
+ "lint.flatDirCollision": '同一来源同时存在 {flat} 与 {dir}(ID 都是 "{id}"),语义不明确',
249
+ "lint.duplicateContent": "这些 skill 的正文完全相同:{ids}",
250
+ "lint.nearDuplicate": '"{a}" 与 "{b}" 的描述相似度 {score}%,模型容易选错',
251
+ "lint.missingDescription": '"{id}" 没有 description,模型不会发现它(只能 @{id} 手动加载)',
252
+ "lint.longDescription": '"{id}" 的 description 有 {chars} 字符,每个模型步骤都会占上下文',
253
+ "lint.hiddenSkill": '"{id}" 被隐藏(autoinvoke/disable-model-invocation),不会出现在模型列表里',
254
+ "lint.invalidId": '"{id}" 不符合推荐的 kebab-case ID 规范(1-64 字符、小写)',
255
+ "lint.largeBody": '"{id}" 正文约 {tokens} tokens,被调用时会整段塞进上下文',
256
+ "lint.staleSkill": '"{id}" 已经 {days} 天没有更新',
257
+ "lint.tokenBudget": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget}",
258
+ "lint.tokenBudgetError": "模型每步要看到的 skill 描述约 {tokens} tokens,超过预算 {budget} 的 2 倍",
259
+ "lint.tooManySkills": "有 {count} 个 skill 会展示给模型(建议 ≤ 60)",
260
+
261
+ "managed.lockUnsupported":
262
+ "lock 版本 {version} 高于本工具支持的 {supported},已按兼容模式解析(未知字段忽略)",
263
+ "managed.missing": "catalog 记录 {name}@{version},但本地没有对应文件(可能被删除或改名)",
264
+ "managed.unsafe": "lock 里 {name} 的文件路径不安全: {rel}",
265
+ "managed.driftMissing": '"{name}" 的 {rel} 缺失(更新后被删除了?)',
266
+ "managed.driftModified": '"{name}" 的 {rel} 在更新后被修改过(drift),下次 update 会覆盖',
267
+
268
+ "catalog.indexHttp": "index.json 返回 HTTP {status}",
269
+ "catalog.indexShape": "index.json 缺少 skills 数组",
270
+ "catalog.crossOrigin": "跨域文件引用: {file}",
271
+ "catalog.fileHttp": "{name}/{file} 返回 HTTP {status}",
272
+ "catalog.unsafePath": "不安全路径: {rel}",
273
+ "catalog.entryNotObject": "条目不是对象",
274
+ "catalog.missingName": "缺少 name",
275
+ "catalog.missingVersion": "缺少 version",
276
+ "catalog.missingFiles": "缺少 files",
277
+ "catalog.safePath": "不安全的文件路径: {file}",
278
+ "catalog.missingEntryFile": "files 里没有 SKILL.md 或 <name>.md",
279
+ },
280
+ }
281
+
282
+ export const SUPPORTED_LANGS = Object.keys(dict)
283
+
284
+ export function normalizeLang(value) {
285
+ if (!value) return null
286
+ const v = String(value).trim().toLowerCase()
287
+ if (v.startsWith("zh")) return "zh"
288
+ if (v.startsWith("en")) return "en"
289
+ return null
290
+ }
291
+
292
+ export function detectLang() {
293
+ try {
294
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale || ""
295
+ if (locale.toLowerCase().startsWith("zh")) return "zh"
296
+ } catch {}
297
+ return "en"
298
+ }
299
+
300
+ export function translator(lang) {
301
+ const primary = dict[lang] ?? dict.en
302
+ return (key, params = {}) => {
303
+ const template = primary[key] ?? dict.en[key] ?? key
304
+ return template.replace(/\{(\w+)\}/g, (_, name) => (params[name] ?? "").toString())
305
+ }
306
+ }
package/src/lint.mjs ADDED
@@ -0,0 +1,218 @@
1
+ import { estimateTokens } from "./parse.mjs"
2
+ import { translator } from "./i18n.mjs"
3
+
4
+ export const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 }
5
+
6
+ const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
7
+ const NEAR_DUPLICATE_THRESHOLD = 0.75
8
+
9
+ function tokenSet(text) {
10
+ const tokens = String(text ?? "")
11
+ .toLowerCase()
12
+ .split(/[^\p{L}\p{N}]+/u)
13
+ .filter((t) => t.length >= 2)
14
+ return new Set(tokens)
15
+ }
16
+
17
+ function jaccard(a, b) {
18
+ if (!a.size || !b.size) return 0
19
+ let inter = 0
20
+ for (const t of a) if (b.has(t)) inter++
21
+ return inter / (a.size + b.size - inter)
22
+ }
23
+
24
+ function byId(skills) {
25
+ const map = new Map()
26
+ for (const skill of skills) {
27
+ if (!map.has(skill.id)) map.set(skill.id, [])
28
+ map.get(skill.id).push(skill)
29
+ }
30
+ return map
31
+ }
32
+
33
+ /** 每个 ID 的实际生效定义(注册顺序越后优先级越高;同序时 SKILL.md 目录形式优先)。 */
34
+ export function resolveWinners(skills) {
35
+ const winners = new Map()
36
+ for (const skill of skills) {
37
+ const current = winners.get(skill.id)
38
+ const better =
39
+ !current ||
40
+ skill.order > current.order ||
41
+ (skill.order === current.order && skill.form === "dir" && current.form !== "dir")
42
+ if (better) winners.set(skill.id, skill)
43
+ }
44
+ return winners
45
+ }
46
+
47
+ export function lint(skills, options = {}) {
48
+ const t = options.t ?? translator("en")
49
+ const budget = options.budget ?? 1200
50
+ const staleDays = options.staleDays ?? 0
51
+ const now = options.now ?? Date.now()
52
+ const findings = []
53
+ const add = (rule, severity, message, extra = {}) => findings.push({ rule, severity, message, ...extra })
54
+
55
+ const winners = resolveWinners(skills)
56
+
57
+ // 1) 同一 ID 多处定义 → 遮蔽
58
+ for (const [id, defs] of byId(skills)) {
59
+ if (defs.length <= 1) continue
60
+ const winner = winners.get(id)
61
+ const losers = defs.filter((d) => d !== winner)
62
+ add(
63
+ "shadowed",
64
+ "warn",
65
+ t("lint.shadowed", {
66
+ id,
67
+ count: defs.length,
68
+ winner: winner.file,
69
+ losers: losers.length,
70
+ }),
71
+ { skills: [id], files: defs.map((d) => d.file) },
72
+ )
73
+
74
+ const flat = defs.filter((d) => d.form === "flat")
75
+ const dirs = defs.filter((d) => d.form === "dir")
76
+ for (const f of flat) {
77
+ for (const d of dirs) {
78
+ if (f.source.dir !== d.source.dir) continue
79
+ add("flat-dir-collision", "warn", t("lint.flatDirCollision", { flat: f.file, dir: d.file, id }), {
80
+ skills: [id],
81
+ files: [f.file, d.file],
82
+ })
83
+ }
84
+ }
85
+ }
86
+
87
+ // 2) 正文完全重复
88
+ const byHash = new Map()
89
+ for (const skill of skills) {
90
+ if (!byHash.has(skill.hash)) byHash.set(skill.hash, [])
91
+ byHash.get(skill.hash).push(skill)
92
+ }
93
+ let dupGroups = 0
94
+ for (const [, group] of byHash) {
95
+ const ids = [...new Set(group.map((s) => s.id))]
96
+ if (ids.length <= 1) continue
97
+ dupGroups++
98
+ if (dupGroups > 10) break
99
+ add("duplicate-content", "warn", t("lint.duplicateContent", { ids: ids.join(", ") }), { skills: ids })
100
+ }
101
+
102
+ // 3) 单条检查
103
+ for (const skill of skills) {
104
+ if (!skill.description) {
105
+ add("missing-description", "info", t("lint.missingDescription", { id: skill.id }), { skills: [skill.id] })
106
+ } else if (skill.description.length > 240) {
107
+ add("long-description", "warn", t("lint.longDescription", { id: skill.id, chars: skill.description.length }), {
108
+ skills: [skill.id],
109
+ })
110
+ }
111
+
112
+ if (skill.hidden) {
113
+ add("hidden-skill", "info", t("lint.hiddenSkill", { id: skill.id }), { skills: [skill.id] })
114
+ }
115
+
116
+ if (!ID_PATTERN.test(skill.id) || skill.id.length > 64) {
117
+ add("invalid-id", "info", t("lint.invalidId", { id: skill.id }), { skills: [skill.id] })
118
+ }
119
+
120
+ if (skill.bodyTokens > 4000) {
121
+ add("large-body", "warn", t("lint.largeBody", { id: skill.id, tokens: skill.bodyTokens }), {
122
+ skills: [skill.id],
123
+ })
124
+ }
125
+
126
+ if (staleDays > 0 && skill.mtime > 0 && now - skill.mtime > staleDays * 86400000) {
127
+ const days = Math.round((now - skill.mtime) / 86400000)
128
+ add("stale-skill", "info", t("lint.staleSkill", { id: skill.id, days }), { skills: [skill.id] })
129
+ }
130
+ }
131
+
132
+ // 4) 上下文预算(只算会被模型看到的)
133
+ const advertised = [...winners.values()].filter((s) => s.advertised)
134
+ const described = advertised.filter((s) => s.description)
135
+ const advertisedTokens = described.reduce(
136
+ (sum, s) => sum + estimateTokens(`${s.id} ${s.displayName} ${s.description}`),
137
+ 0,
138
+ )
139
+
140
+ if (advertisedTokens > budget * 2) {
141
+ add("token-budget", "error", t("lint.tokenBudgetError", { tokens: advertisedTokens, budget }), {
142
+ value: advertisedTokens,
143
+ })
144
+ } else if (advertisedTokens > budget) {
145
+ add("token-budget", "warn", t("lint.tokenBudget", { tokens: advertisedTokens, budget }), {
146
+ value: advertisedTokens,
147
+ })
148
+ }
149
+
150
+ if (advertised.length > 100) {
151
+ add("too-many-skills", "error", t("lint.tooManySkills", { count: advertised.length }), {
152
+ value: advertised.length,
153
+ })
154
+ } else if (advertised.length > 60) {
155
+ add("too-many-skills", "warn", t("lint.tooManySkills", { count: advertised.length }), {
156
+ value: advertised.length,
157
+ })
158
+ }
159
+
160
+ // 5) 描述近似
161
+ const withDesc = described
162
+ const pairs = []
163
+ for (let i = 0; i < withDesc.length; i++) {
164
+ for (let j = i + 1; j < withDesc.length; j++) {
165
+ const a = withDesc[i]
166
+ const b = withDesc[j]
167
+ if (a.id === b.id) continue
168
+ const score = jaccard(tokenSet(a.description), tokenSet(b.description))
169
+ if (score >= NEAR_DUPLICATE_THRESHOLD) pairs.push({ a: a.id, b: b.id, score })
170
+ }
171
+ }
172
+ for (const pair of pairs.slice(0, 15)) {
173
+ add(
174
+ "near-duplicate-description",
175
+ "warn",
176
+ t("lint.nearDuplicate", { a: pair.a, b: pair.b, score: (pair.score * 100).toFixed(0) }),
177
+ { skills: [pair.a, pair.b] },
178
+ )
179
+ }
180
+
181
+ return applyOverrides(findings, options)
182
+ }
183
+
184
+ const SEVERITIES = new Set(["error", "warn", "info"])
185
+
186
+ function applyOverrides(findings, options) {
187
+ const overrides = options.ruleOverrides ?? {}
188
+ const ignore = new Set(options.ignore ?? [])
189
+
190
+ const result = []
191
+ for (const finding of findings) {
192
+ const override = overrides[finding.rule]
193
+ if (override === "off") continue
194
+ const severity = SEVERITIES.has(override) ? override : finding.severity
195
+ if (ignore.size && Array.isArray(finding.skills) && finding.skills.every((id) => ignore.has(id))) {
196
+ if (severity !== "error") continue
197
+ }
198
+ result.push(severity === finding.severity ? finding : { ...finding, severity })
199
+ }
200
+
201
+ return result.sort((x, y) => SEVERITY_ORDER[x.severity] - SEVERITY_ORDER[y.severity])
202
+ }
203
+
204
+ export function summarize(skills) {
205
+ const winners = resolveWinners(skills)
206
+ const advertised = [...winners.values()].filter((s) => s.advertised)
207
+ return {
208
+ total: skills.length,
209
+ unique: winners.size,
210
+ advertised: advertised.length,
211
+ hidden: [...winners.values()].filter((s) => s.hidden).length,
212
+ shadowed: skills.length - winners.size,
213
+ advertisedTokens: advertised
214
+ .filter((s) => s.description)
215
+ .reduce((sum, s) => sum + estimateTokens(`${s.id} ${s.displayName} ${s.description}`), 0),
216
+ bodyTokens: skills.reduce((sum, s) => sum + s.bodyTokens, 0),
217
+ }
218
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { createHash } from "node:crypto"
4
+ import { LOCK_VERSION, readLockFile } from "./catalog.mjs"
5
+ import { translator } from "./i18n.mjs"
6
+
7
+ function sha1Buffer(buffer) {
8
+ return createHash("sha1").update(buffer).digest("hex")
9
+ }
10
+
11
+ /**
12
+ * 检测「由更新器管理」的 skill:
13
+ * - lock 里的版本号(供 scan/check 展示)
14
+ * - lock 里记录的文件 sha1 是否仍与本地一致(drift)
15
+ * - lock 版本高于本工具支持版本时,按兼容模式继续解析
16
+ */
17
+ export function inspectManaged(scans, options = {}) {
18
+ const t = options.t ?? translator("en")
19
+ const findings = []
20
+ const managed = new Map()
21
+ const add = (rule, severity, message, extra = {}) => findings.push({ rule, severity, message, ...extra })
22
+
23
+ for (const scan of scans) {
24
+ if (!scan.exists) continue
25
+ const lock = readLockFile(scan.source.dir)
26
+ if (!lock) continue
27
+
28
+ const lockVersion = Number(lock.version ?? 1)
29
+ if (Number.isFinite(lockVersion) && lockVersion > LOCK_VERSION) {
30
+ add(
31
+ "lock-version-unsupported",
32
+ "info",
33
+ t("managed.lockUnsupported", { version: lockVersion, supported: LOCK_VERSION }),
34
+ { file: path.join(scan.source.dir, ".skills-doctor.lock.json") },
35
+ )
36
+ }
37
+
38
+ for (const [url, catalog] of Object.entries(lock.catalogs)) {
39
+ const skills = catalog && typeof catalog === "object" ? catalog.skills ?? {} : {}
40
+ for (const [name, entry] of Object.entries(skills)) {
41
+ const version = typeof entry === "string" ? entry : entry?.version
42
+ const skill = scan.skills.find((s) => s.id === name)
43
+
44
+ if (!skill) {
45
+ add("managed-missing", "warn", t("managed.missing", { name, version }), { skills: [name] })
46
+ continue
47
+ }
48
+
49
+ managed.set(skill.file, { version, url, root: scan.source.dir })
50
+
51
+ const files = entry && typeof entry === "object" && entry.files && typeof entry.files === "object" ? entry.files : null
52
+ if (!files) continue
53
+
54
+ const base = path.dirname(skill.file)
55
+ const resolvedBase = path.resolve(base)
56
+ for (const [rel, expected] of Object.entries(files)) {
57
+ const target = path.resolve(base, rel)
58
+ if (target !== resolvedBase && !target.startsWith(resolvedBase + path.sep)) {
59
+ add("managed-unsafe", "warn", t("managed.unsafe", { name, rel }), { skills: [name] })
60
+ continue
61
+ }
62
+ if (!fs.existsSync(target)) {
63
+ add("managed-drift", "warn", t("managed.driftMissing", { name, rel }), { skills: [name] })
64
+ continue
65
+ }
66
+ const actual = sha1Buffer(fs.readFileSync(target))
67
+ if (actual !== expected) {
68
+ add("managed-drift", "warn", t("managed.driftModified", { name, rel }), { skills: [name] })
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ return { findings, managed, lockVersion: LOCK_VERSION }
76
+ }