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 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` 数组(相对路径按当前工作目录解析,支持 `~/`、绝对路径、HTTP catalog)
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.0",
4
- "description": "Local scanner, linter, fixer and updater for OpenCode skills, with an OpenCode plugin entry",
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 hidden = options.hide.length ? hideSkills(audit.skills, options.hide, { dryRun: options.dryRun }) : { changed: [], errors: [], missing: [] }
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 = !plan.length && !skipped.length && !hidden.changed.length && !hidden.missing.length
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
- const entries = Array.isArray(config?.skills) ? config.skills : []
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)) addSource("config-path", "config", abs)
172
- else missing.push({ kind: "missing-path", path: abs, entry: value, config: file })
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 }