opencode-skills-doctor 0.3.0 → 0.3.3
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/README.md +30 -1
- package/README.zh-CN.md +30 -1
- package/package.json +11 -4
- package/src/cli.mjs +15 -4
- package/src/discover.mjs +14 -3
- package/src/fix.mjs +7 -2
- package/src/i18n.mjs +4 -0
- package/src/plugin-v1.mjs +31 -0
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ skills-doctor fix --hide academic-paper,deep-research --dry-run # hide low-fre
|
|
|
60
60
|
|
|
61
61
|
- Only shadowed duplicates are touched; the effective definition is never moved;
|
|
62
62
|
- Files installed by `update` are skipped — use `update` to bring them back;
|
|
63
|
+
- Default scope is project-only; pass `--all` to also archive global sources;
|
|
63
64
|
- After fixing, `check` should report no `shadowed` findings.
|
|
64
65
|
|
|
65
66
|
### `update` (updater)
|
|
@@ -111,6 +112,34 @@ It registers:
|
|
|
111
112
|
|
|
112
113
|
`@opencode/plugin` is an optional peer dependency; OpenCode provides it at runtime.
|
|
113
114
|
|
|
115
|
+
### OpenCode 1.x
|
|
116
|
+
|
|
117
|
+
1.x uses a different plugin API: it resolves the `./server` export for npm plugins and needs `@opencode-ai/plugin` at runtime. The reliable setup is a one-line local plugin file.
|
|
118
|
+
|
|
119
|
+
1. Install the package where OpenCode resolves plugins:
|
|
120
|
+
|
|
121
|
+
```powershell
|
|
122
|
+
npm i --prefix ~/.config/opencode opencode-skills-doctor
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
2. Create `~/.config/opencode/plugins/skills-doctor.js`:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
export { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
3. Reference it from `opencode.json` (1.x uses `plugin`, not `plugins`):
|
|
132
|
+
|
|
133
|
+
```jsonc
|
|
134
|
+
{
|
|
135
|
+
"plugin": ["C:\\Users\\<you>\\.config\\opencode\\plugins\\skills-doctor.js"]
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
It registers the same `skills_doctor` tool (1.x has no `/skills-doctor` command hook).
|
|
140
|
+
|
|
141
|
+
> The package root entry is the V2 plugin and requires OpenCode 2.x. The CLI works on both lines.
|
|
142
|
+
|
|
114
143
|
## Rules
|
|
115
144
|
|
|
116
145
|
| Rule | Level | Meaning |
|
|
@@ -148,7 +177,7 @@ It registers:
|
|
|
148
177
|
4. project `.agents/skills`
|
|
149
178
|
5. `~/.config/opencode/skills`
|
|
150
179
|
6. project `.opencode/skills`
|
|
151
|
-
7. the `skills` array in `opencode.json(c)` (relative paths resolve from the working directory; supports `~/`, absolute paths and HTTP catalogs
|
|
180
|
+
7. the `skills` array in `opencode.json(c)` (2.x) or `skills.paths` (1.x); relative paths resolve from the working directory; supports `~/`, absolute paths and HTTP catalogs
|
|
152
181
|
|
|
153
182
|
## JSON output
|
|
154
183
|
|
package/README.zh-CN.md
CHANGED
|
@@ -60,6 +60,7 @@ skills-doctor fix --hide academic-paper,deep-research --dry-run # 隐藏低频
|
|
|
60
60
|
|
|
61
61
|
- 只动被遮蔽的重复定义,生效版本永远不会被移动;
|
|
62
62
|
- 由 `update` 安装的文件会跳过——用 `update` 恢复;
|
|
63
|
+
- **默认只处理项目内的 skill**,要动全局来源(`~/.config/opencode/skills` 等)加 `--all`;
|
|
63
64
|
- 修完再跑 `check`,`shadowed` 应该消失。
|
|
64
65
|
|
|
65
66
|
### `update`(更新器)
|
|
@@ -111,6 +112,34 @@ skills-doctor update --into .\team-skills
|
|
|
111
112
|
|
|
112
113
|
`@opencode/plugin` 是可选 peer 依赖,运行时由 OpenCode 提供。
|
|
113
114
|
|
|
115
|
+
### OpenCode 1.x
|
|
116
|
+
|
|
117
|
+
1.x 用的是另一套插件 API:npm 插件会解析 `./server` 导出,运行时需要 `@opencode-ai/plugin`。最稳的接法是加一个一行的本地插件文件。
|
|
118
|
+
|
|
119
|
+
1. 先把包装到 OpenCode 解析插件的位置:
|
|
120
|
+
|
|
121
|
+
```powershell
|
|
122
|
+
npm i --prefix ~/.config/opencode opencode-skills-doctor
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
2. 新建 `~/.config/opencode/plugins/skills-doctor.js`:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
export { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
3. 在 `opencode.json` 里引用(1.x 用 `plugin`,不是 `plugins`):
|
|
132
|
+
|
|
133
|
+
```jsonc
|
|
134
|
+
{
|
|
135
|
+
"plugin": ["C:\\Users\\<you>\\.config\\opencode\\plugins\\skills-doctor.js"]
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
它会注册同样的 `skills_doctor` 工具(1.x 没有 `/skills-doctor` 命令钩子)。
|
|
140
|
+
|
|
141
|
+
> 包根入口是 V2 插件,需要 OpenCode 2.x;CLI 两条线都能用。
|
|
142
|
+
|
|
114
143
|
## 检测规则
|
|
115
144
|
|
|
116
145
|
| 规则 | 级别 | 含义 |
|
|
@@ -148,7 +177,7 @@ skills-doctor update --into .\team-skills
|
|
|
148
177
|
4. 项目 `.agents/skills`
|
|
149
178
|
5. `~/.config/opencode/skills`
|
|
150
179
|
6. 项目 `.opencode/skills`
|
|
151
|
-
7. `opencode.json(c)` 的 `skills`
|
|
180
|
+
7. `opencode.json(c)` 的 `skills` 数组(2.x)或 `skills.paths`(1.x);相对路径按当前工作目录解析,支持 `~/`、绝对路径、HTTP catalog
|
|
152
181
|
|
|
153
182
|
## JSON 输出
|
|
154
183
|
|
package/package.json
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-skills-doctor",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Local scanner, linter, fixer and updater for OpenCode skills, with
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Local scanner, linter, fixer and updater for OpenCode skills, with OpenCode 2.x and 1.x plugin entries",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/plugin.mjs",
|
|
8
|
-
"./plugin": "./src/plugin.mjs"
|
|
8
|
+
"./plugin": "./src/plugin.mjs",
|
|
9
|
+
"./server": "./src/plugin-v1.mjs",
|
|
10
|
+
"./v1": "./src/plugin-v1.mjs",
|
|
11
|
+
"./audit": "./src/audit.mjs"
|
|
9
12
|
},
|
|
10
13
|
"bin": {
|
|
11
14
|
"skills-doctor": "./bin/skills-doctor.mjs"
|
|
12
15
|
},
|
|
13
16
|
"peerDependencies": {
|
|
14
|
-
"@opencode/plugin": ">=2.0.0"
|
|
17
|
+
"@opencode/plugin": ">=2.0.0",
|
|
18
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
15
19
|
},
|
|
16
20
|
"peerDependenciesMeta": {
|
|
17
21
|
"@opencode/plugin": {
|
|
18
22
|
"optional": true
|
|
23
|
+
},
|
|
24
|
+
"@opencode-ai/plugin": {
|
|
25
|
+
"optional": true
|
|
19
26
|
}
|
|
20
27
|
},
|
|
21
28
|
"engines": {
|
package/src/cli.mjs
CHANGED
|
@@ -48,6 +48,7 @@ function parseArgs(argv) {
|
|
|
48
48
|
lang: null,
|
|
49
49
|
config: null,
|
|
50
50
|
dryRun: false,
|
|
51
|
+
all: false,
|
|
51
52
|
hide: [],
|
|
52
53
|
into: null,
|
|
53
54
|
checkOnly: false,
|
|
@@ -91,6 +92,9 @@ function parseArgs(argv) {
|
|
|
91
92
|
case "--dry-run":
|
|
92
93
|
options.dryRun = true
|
|
93
94
|
break
|
|
95
|
+
case "--all":
|
|
96
|
+
options.all = true
|
|
97
|
+
break
|
|
94
98
|
case "--hide":
|
|
95
99
|
options.hide.push(
|
|
96
100
|
...String(args[++i] ?? "")
|
|
@@ -339,9 +343,12 @@ async function runUpdate(discovered, options, cwd, t) {
|
|
|
339
343
|
}
|
|
340
344
|
|
|
341
345
|
function runFix(audit, options, t) {
|
|
342
|
-
const { plan, skipped } = planFixes(audit.skills, audit.managed)
|
|
346
|
+
const { plan, skipped, skippedGlobal } = planFixes(audit.skills, audit.managed, { includeGlobal: options.all })
|
|
343
347
|
const archived = applyFixes(plan, { dryRun: options.dryRun })
|
|
344
|
-
const
|
|
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: [] }
|
|
345
352
|
|
|
346
353
|
const errors = [...archived.errors, ...hidden.errors]
|
|
347
354
|
|
|
@@ -353,9 +360,11 @@ function runFix(audit, options, t) {
|
|
|
353
360
|
version: pkg.version,
|
|
354
361
|
lang: audit.lang,
|
|
355
362
|
dryRun: options.dryRun,
|
|
363
|
+
all: options.all,
|
|
356
364
|
archived: archived.moved.map((m) => ({ id: m.id, from: m.from, to: m.target })),
|
|
357
365
|
hidden: hidden.changed,
|
|
358
366
|
skipped: skipped.map((s) => ({ id: s.id, file: s.file })),
|
|
367
|
+
skippedGlobal: skippedGlobal.map((s) => ({ id: s.id, file: s.file })),
|
|
359
368
|
missing: hidden.missing,
|
|
360
369
|
errors: errors.map((e) => ({ id: e.id, error: e.error })),
|
|
361
370
|
},
|
|
@@ -366,7 +375,8 @@ function runFix(audit, options, t) {
|
|
|
366
375
|
return errors.length ? 1 : 0
|
|
367
376
|
}
|
|
368
377
|
|
|
369
|
-
const nothing =
|
|
378
|
+
const nothing =
|
|
379
|
+
!plan.length && !skipped.length && !skippedGlobal.length && !hidden.changed.length && !hidden.missing.length
|
|
370
380
|
if (nothing) {
|
|
371
381
|
console.log(bold(t("fix.title")))
|
|
372
382
|
console.log(` ${t("fix.none")}`)
|
|
@@ -378,6 +388,7 @@ function runFix(audit, options, t) {
|
|
|
378
388
|
for (const item of archived.moved) console.log(green(t("fix.moved", { from: item.from, to: item.target })))
|
|
379
389
|
for (const item of hidden.changed) console.log(green(t("fix.hidden", { id: item.id, file: item.file })))
|
|
380
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 })))
|
|
381
392
|
for (const id of hidden.missing) console.log(yellow(t("fix.unknownHide", { id })))
|
|
382
393
|
for (const item of errors) console.log(red(t("fix.error", { path: item.file ?? item.from, error: item.error })))
|
|
383
394
|
console.log("")
|
|
@@ -385,7 +396,7 @@ function runFix(audit, options, t) {
|
|
|
385
396
|
t("fix.summary", {
|
|
386
397
|
moved: archived.moved.length,
|
|
387
398
|
hidden: hidden.changed.length,
|
|
388
|
-
skipped: skipped.length,
|
|
399
|
+
skipped: skipped.length + skippedGlobal.length,
|
|
389
400
|
}),
|
|
390
401
|
)
|
|
391
402
|
console.log(dim(t("fix.rerun")))
|
package/src/discover.mjs
CHANGED
|
@@ -104,6 +104,11 @@ function configCandidates(dir) {
|
|
|
104
104
|
return CONFIG_FILES.map((name) => path.join(dir, name))
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
function isUnder(parent, child) {
|
|
108
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child))
|
|
109
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
|
110
|
+
}
|
|
111
|
+
|
|
107
112
|
/**
|
|
108
113
|
* 按官方文档的注册顺序返回 skill 来源(顺序即优先级,越后越高):
|
|
109
114
|
* 1. ~/.claude/skills
|
|
@@ -158,7 +163,12 @@ export function discoverSources({ cwd = process.cwd(), home = os.homedir(), incl
|
|
|
158
163
|
missing.push({ kind: "config-error", path: file, error: err.message })
|
|
159
164
|
continue
|
|
160
165
|
}
|
|
161
|
-
|
|
166
|
+
// 2.x: skills: [...];1.x: skills: { paths: [...] }
|
|
167
|
+
const entries = Array.isArray(config?.skills)
|
|
168
|
+
? config.skills
|
|
169
|
+
: Array.isArray(config?.skills?.paths)
|
|
170
|
+
? config.skills.paths
|
|
171
|
+
: []
|
|
162
172
|
for (const entry of entries) {
|
|
163
173
|
if (typeof entry !== "string" || !entry.trim()) continue
|
|
164
174
|
const value = entry.trim()
|
|
@@ -168,8 +178,9 @@ export function discoverSources({ cwd = process.cwd(), home = os.homedir(), incl
|
|
|
168
178
|
}
|
|
169
179
|
const expanded = expandHome(value, home)
|
|
170
180
|
const abs = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded)
|
|
171
|
-
if (fs.existsSync(abs))
|
|
172
|
-
|
|
181
|
+
if (fs.existsSync(abs)) {
|
|
182
|
+
addSource("config-path", isUnder(projectRoot, file) ? "project" : "global", abs)
|
|
183
|
+
} else missing.push({ kind: "missing-path", path: abs, entry: value, config: file })
|
|
173
184
|
}
|
|
174
185
|
}
|
|
175
186
|
|
package/src/fix.mjs
CHANGED
|
@@ -33,7 +33,7 @@ function archiveTarget(skill, stamp) {
|
|
|
33
33
|
* 规划安全的结构性修复:只归档「被遮蔽、永远不会加载」的定义。
|
|
34
34
|
* 由更新器管理的文件不碰(应通过 update 恢复)。
|
|
35
35
|
*/
|
|
36
|
-
export function planFixes(skills, managed = new Map(), { now = new Date() } = {}) {
|
|
36
|
+
export function planFixes(skills, managed = new Map(), { now = new Date(), includeGlobal = false } = {}) {
|
|
37
37
|
const winners = resolveWinners(skills)
|
|
38
38
|
const byId = new Map()
|
|
39
39
|
for (const skill of skills) {
|
|
@@ -44,12 +44,17 @@ export function planFixes(skills, managed = new Map(), { now = new Date() } = {}
|
|
|
44
44
|
const stamp = now.toISOString().replace(/[:.]/g, "-")
|
|
45
45
|
const plan = []
|
|
46
46
|
const skipped = []
|
|
47
|
+
const skippedGlobal = []
|
|
47
48
|
|
|
48
49
|
for (const [id, defs] of byId) {
|
|
49
50
|
if (defs.length <= 1) continue
|
|
50
51
|
const winner = winners.get(id)
|
|
51
52
|
for (const loser of defs) {
|
|
52
53
|
if (loser === winner) continue
|
|
54
|
+
if (!includeGlobal && loser.source.scope === "global") {
|
|
55
|
+
skippedGlobal.push(loser)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
53
58
|
if (managed.has(loser.file)) {
|
|
54
59
|
skipped.push(loser)
|
|
55
60
|
continue
|
|
@@ -64,7 +69,7 @@ export function planFixes(skills, managed = new Map(), { now = new Date() } = {}
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
return { plan, skipped }
|
|
72
|
+
return { plan, skipped, skippedGlobal }
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
export function applyFixes(plan, { dryRun = false } = {}) {
|
package/src/i18n.mjs
CHANGED
|
@@ -21,6 +21,7 @@ Options:
|
|
|
21
21
|
--lang <en|zh> output language (default: auto from system locale)
|
|
22
22
|
--config <file> config file (default: .skills-doctor.json)
|
|
23
23
|
--dry-run fix: only print what would change
|
|
24
|
+
--all fix: also touch global sources (default: project only)
|
|
24
25
|
--hide <ids> fix: hide skills with metadata.opencode/autoinvoke: false (comma separated)
|
|
25
26
|
--into <dir> update: output directory (default: .opencode/skills)
|
|
26
27
|
--check-only update: only report available updates
|
|
@@ -79,6 +80,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
79
80
|
"fix.dryRun": " (dry run: nothing is moved)",
|
|
80
81
|
"fix.moved": " ✓ archived {from} → {to}",
|
|
81
82
|
"fix.skippedManaged": " · skipped {path} (installed by the updater; run update instead)",
|
|
83
|
+
"fix.skippedGlobal": " · skipped {path} (global skill; pass --all to include)",
|
|
82
84
|
"fix.none": "Nothing to fix.",
|
|
83
85
|
"fix.summary": "{moved} archived, {hidden} hidden, {skipped} skipped",
|
|
84
86
|
"fix.error": " ✗ failed to archive {path}: {error}",
|
|
@@ -160,6 +162,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
160
162
|
--lang <en|zh> 输出语言(默认按系统 locale 自动选择)
|
|
161
163
|
--config <file> 配置文件(默认 .skills-doctor.json)
|
|
162
164
|
--dry-run fix: 只打印将要变更的内容,不实际修改
|
|
165
|
+
--all fix: 同时处理全局来源的 skill(默认只动项目内)
|
|
163
166
|
--hide <ids> fix: 给指定 skill 加 metadata.opencode/autoinvoke: false(逗号分隔)
|
|
164
167
|
--into <dir> update 的落盘目录(默认 .opencode/skills)
|
|
165
168
|
--check-only update 只报告可用更新,不下载
|
|
@@ -218,6 +221,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
218
221
|
"fix.dryRun": " (dry run:不会移动任何文件)",
|
|
219
222
|
"fix.moved": " ✓ 已归档 {from} → {to}",
|
|
220
223
|
"fix.skippedManaged": " · 跳过 {path}(由更新器管理,请用 update)",
|
|
224
|
+
"fix.skippedGlobal": " · 跳过 {path}(全局 skill,加 --all 才会处理)",
|
|
221
225
|
"fix.none": "没有需要修复的内容。",
|
|
222
226
|
"fix.summary": "归档 {moved} 个,隐藏 {hidden} 个,跳过 {skipped} 个",
|
|
223
227
|
"fix.error": " ✗ 归档 {path} 失败: {error}",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { auditSkills, formatMarkdown } from "./audit.mjs"
|
|
3
|
+
|
|
4
|
+
const z = tool.schema
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OpenCode 1.x 插件入口(V1 API:`server` 函数 + tool())。
|
|
8
|
+
* 在 1.x 里通过 opencode.json 配置:
|
|
9
|
+
* { "plugin": ["opencode-skills-doctor/v1"] }
|
|
10
|
+
* 2.x 请使用包根导出(V2 的 Plugin.define)。
|
|
11
|
+
*/
|
|
12
|
+
export const SkillsDoctorPlugin = async ({ directory }) => ({
|
|
13
|
+
tool: {
|
|
14
|
+
skills_doctor: tool({
|
|
15
|
+
description:
|
|
16
|
+
"Audit local OpenCode skills: duplicates, shadowing, context budget and fix hints. Returns a markdown report.",
|
|
17
|
+
args: z.object({
|
|
18
|
+
directory: z.string().optional().describe("Directory to audit (defaults to the current project)"),
|
|
19
|
+
budget: z.number().optional().describe("Description token budget (default 1200)"),
|
|
20
|
+
}),
|
|
21
|
+
execute: async (args) => {
|
|
22
|
+
const cwd = args.directory || directory || process.cwd()
|
|
23
|
+
const audit = auditSkills({ cwd, budget: typeof args.budget === "number" ? args.budget : null, lang: "en" })
|
|
24
|
+
return formatMarkdown(audit)
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const server = SkillsDoctorPlugin
|
|
31
|
+
export default { id: "skills-doctor", server: SkillsDoctorPlugin }
|