opencode-skills-doctor 0.6.0 → 0.6.1

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
@@ -23,7 +23,7 @@ Local scanner, linter, fixer, updater and **OpenCode plugin** for agent skills
23
23
  | **Plan** | exact token savings: trim descriptions and/or hide skills, projected over N steps |
24
24
  | **Cost** | CLI: 0 tokens. Plugin: ~191 tokens/step for the tool definition, plus a one-time warning per session |
25
25
  | **Requirements** | Node ≥ 20, zero runtime dependencies |
26
- | **Quality** | 57 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
26
+ | **Quality** | 58 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
27
27
 
28
28
  ## Why
29
29
 
@@ -348,22 +348,23 @@ One command installs both the MCP tool and a skill that tells the model when to
348
348
  npx -y opencode-skills-doctor install --client dsh
349
349
  ```
350
350
 
351
- It locates `~/.dsh/profiles/<profile>/cordis.patch.yml` (default `desktop`; otherwise `--profile <name>`), appends the `mcp-skills-doctor` entry idempotently, and copies the `skills-doctor-audit` skill into `~/.dsh/skills/`. Restart DSH to load the MCP tool; the skill is watched and appears without a restart.
351
+ It locates `~/.dsh/profiles/<profile>/cordis.patch.yml` (default `desktop`; otherwise `--profile <name>`), inserts the `mcp-skills-doctor` row under an `- insert:` block idempotently, and copies the `skills-doctor-audit` skill into `~/.dsh/skills/`. Restart DSH (or let HMR reload the patch) to load the MCP tool; the skill is watched and appears without a restart.
352
352
 
353
353
  - tool name `mcp__skills_doctor__skills_doctor`, arguments `directory`, `budget`, `lang`, `global`, `html`;
354
354
  - `uninstall --client dsh` removes both the patch entry and the skill;
355
355
  - `--dsh-home <dir>` for a non-standard DSH home.
356
356
 
357
- Manual fallback — add this to the profile patch yourself:
357
+ Manual fallback — DSH patches add new entries under `insert`, so append this block:
358
358
 
359
359
  ```yaml
360
- - id: mcp-skills-doctor
361
- name: "@deepseek-ai/dsh-mcp-client"
362
- config:
363
- serverName: skills_doctor
364
- transport: stdio
365
- command: npx
366
- args: ["-y", "opencode-skills-doctor", "mcp"]
360
+ - insert:
361
+ - id: mcp-skills-doctor
362
+ name: "@deepseek-ai/dsh-mcp-client"
363
+ config:
364
+ serverName: skills_doctor
365
+ transport: stdio
366
+ command: npx
367
+ args: ["-y", "opencode-skills-doctor", "mcp"]
367
368
  ```
368
369
 
369
370
  On Windows, if the stdio spawn cannot resolve `npx`, use `command: cmd` and `args: ["/c", "npx", "-y", "opencode-skills-doctor", "mcp"]`.
@@ -517,7 +518,7 @@ Executing code is free, but the tool definition is part of the model's context.
517
518
  ## Development and contributing
518
519
 
519
520
  ```powershell
520
- npm test # 57 tests (node test/doctor.test.mjs)
521
+ npm test # 58 tests (node test/doctor.test.mjs)
521
522
  npm run bench # lint timing for 100/500/1000 skills
522
523
  ```
523
524
 
@@ -23,7 +23,7 @@ OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **Open
23
23
  | **规划** | 精确 token 节省:精简描述 / 隐藏 skill,按 N 步折算 |
24
24
  | **成本** | CLI 0 token;插件工具定义约 191/步,另有每会话一次预警 |
25
25
  | **要求** | Node ≥ 20,零运行时依赖 |
26
- | **质量** | 57 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
26
+ | **质量** | 58 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
27
27
 
28
28
  ## 为什么需要它
29
29
 
@@ -348,22 +348,23 @@ args = ["-y", "opencode-skills-doctor", "mcp"]
348
348
  npx -y opencode-skills-doctor install --client dsh
349
349
  ```
350
350
 
351
- 它会找到 `~/.dsh/profiles/<profile>/cordis.patch.yml`(默认 `desktop`,否则用 `--profile <name>`),幂等地追加 `mcp-skills-doctor` 条目,并把 `skills-doctor-audit` 技能复制到 `~/.dsh/skills/`。重启 DSH 加载 MCP 工具;skill 目录有监听,无需重启。
351
+ 它会找到 `~/.dsh/profiles/<profile>/cordis.patch.yml`(默认 `desktop`,否则用 `--profile <name>`),把 `mcp-skills-doctor` 这一行放在 `- insert:` 块里幂等写入,并把 `skills-doctor-audit` 技能复制到 `~/.dsh/skills/`。重启 DSH(启用 HMR 时会自动重载 patch)加载 MCP 工具;skill 目录有监听,无需重启。
352
352
 
353
353
  - 工具名 `mcp__skills_doctor__skills_doctor`,参数 `directory`、`budget`、`lang`、`global`、`html`;
354
354
  - `uninstall --client dsh` 同时移除配置条目和 skill;
355
355
  - `--dsh-home <dir>` 用于非默认的 DSH 主目录。
356
356
 
357
- 手动兜底 —— 自己往 profile patch 里加:
357
+ 手动兜底 —— DSH 的 patch 里新增条目必须放在 `insert` 下,追加这一块:
358
358
 
359
359
  ```yaml
360
- - id: mcp-skills-doctor
361
- name: "@deepseek-ai/dsh-mcp-client"
362
- config:
363
- serverName: skills_doctor
364
- transport: stdio
365
- command: npx
366
- args: ["-y", "opencode-skills-doctor", "mcp"]
360
+ - insert:
361
+ - id: mcp-skills-doctor
362
+ name: "@deepseek-ai/dsh-mcp-client"
363
+ config:
364
+ serverName: skills_doctor
365
+ transport: stdio
366
+ command: npx
367
+ args: ["-y", "opencode-skills-doctor", "mcp"]
367
368
  ```
368
369
 
369
370
  Windows 上如果 stdio 起不来 `npx`,改成 `command: cmd`、`args: ["/c", "npx", "-y", "opencode-skills-doctor", "mcp"]`。
@@ -517,7 +518,7 @@ token 是启发式估算,1200 是建议预算。可以用 `--budget` 或 `.ski
517
518
  ## 开发与贡献
518
519
 
519
520
  ```powershell
520
- npm test # 57 个测试(node test/doctor.test.mjs)
521
+ npm test # 58 个测试(node test/doctor.test.mjs)
521
522
  npm run bench # 100/500/1000 个 skill 的 lint 耗时
522
523
  ```
523
524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Audit, fix and update agent skills: token cost, broken references, shadowed duplicates, HTML reports, catalog sync. CLI, OpenCode plugin, MCP server; works with OpenCode, Claude Code, Codex, Grok CLI, Cursor and DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/dsh.mjs CHANGED
@@ -7,13 +7,16 @@ export const ENTRY_ID = "mcp-skills-doctor"
7
7
  export const SERVER_NAME = "skills_doctor"
8
8
  export const SKILL_NAME = "skills-doctor-audit"
9
9
 
10
- const ENTRIES = `- id: ${ENTRY_ID}
11
- name: "@deepseek-ai/dsh-mcp-client"
12
- config:
13
- serverName: ${SERVER_NAME}
14
- transport: stdio
15
- command: npx
16
- args: ["-y", "opencode-skills-doctor", "mcp"]
10
+ // DSH 的 cordis.patch.yml 是 patch 列表:新增条目必须放在 insert 下,
11
+ // 裸条目会被当成「覆盖不存在的 id」而跳过。
12
+ const INSERT_BLOCK = `- insert:
13
+ - id: ${ENTRY_ID}
14
+ name: "@deepseek-ai/dsh-mcp-client"
15
+ config:
16
+ serverName: ${SERVER_NAME}
17
+ transport: stdio
18
+ command: npx
19
+ args: ["-y", "opencode-skills-doctor", "mcp"]
17
20
  `
18
21
 
19
22
  export function dshHome(env = process.env) {
@@ -56,76 +59,136 @@ function skillSourceDir() {
56
59
  return fileURLToPath(new URL("../skills/dsh-skills-doctor", import.meta.url))
57
60
  }
58
61
 
59
- function copySkill(source, target, dryRun, act, dry) {
60
- if (!fs.existsSync(source)) return false
61
- const file = path.join(source, "SKILL.md")
62
- if (!fs.existsSync(file)) return false
63
- const targetFile = path.join(target, "SKILL.md")
64
- if (fs.existsSync(targetFile) && fs.readFileSync(targetFile, "utf8") === fs.readFileSync(file, "utf8")) {
65
- act(`skill already up to date ${target}`)
66
- return true
62
+ function indentOf(line) {
63
+ return (line.match(/^[ \t]*/) ?? [""])[0].length
64
+ }
65
+
66
+ function blockEnd(lines, start) {
67
+ let end = start + 1
68
+ while (end < lines.length) {
69
+ const line = lines[end]
70
+ if (line.trim() === "" || indentOf(line) > 0) end++
71
+ else break
67
72
  }
68
- if (dryRun) {
69
- dry(`install skill ${SKILL_NAME} into ${target}`)
70
- return true
73
+ return end
74
+ }
75
+
76
+ /** 找到包含我们 id 的 insert 块,或旧版裸条目。 */
77
+ function findEntry(lines, id) {
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i]
80
+ if (indentOf(line) === 0 && /^-\s*insert:\s*$/.test(line)) {
81
+ const end = blockEnd(lines, i)
82
+ const block = lines.slice(i, end).join("\n")
83
+ if (block.includes(id)) return { type: "insert", start: i, end }
84
+ i = end - 1
85
+ continue
86
+ }
87
+ if (indentOf(line) === 0 && line.trim() === `- id: ${id}`) {
88
+ return { type: "legacy", start: i, end: blockEnd(lines, i) }
89
+ }
71
90
  }
72
- fs.mkdirSync(target, { recursive: true })
73
- fs.copyFileSync(file, targetFile)
74
- act(`wrote skill ${targetFile}`)
75
- return true
91
+ return null
92
+ }
93
+
94
+ function removeEntryBlock(text, id) {
95
+ const lines = text.split(/\r?\n/)
96
+ const found = findEntry(lines, id)
97
+ if (!found) return { text, changed: false, type: null }
98
+
99
+ if (found.type === "insert") {
100
+ const block = lines.slice(found.start, found.end)
101
+ const rows = block.filter((line) => /^\s+- id:/.test(line))
102
+ if (rows.length > 1) {
103
+ // insert 块里还有别人的条目:只摘掉我们这一行及其子行
104
+ const rowIndex = block.findIndex((line) => line.trim() === `- id: ${id}`)
105
+ let rowEnd = rowIndex + 1
106
+ while (
107
+ rowEnd < block.length &&
108
+ (block[rowEnd].trim() === "" || indentOf(block[rowEnd]) > indentOf(block[rowIndex]))
109
+ ) {
110
+ rowEnd++
111
+ }
112
+ const kept = [...lines.slice(0, found.start), ...block.slice(0, rowIndex), ...block.slice(rowEnd), ...lines.slice(found.end)]
113
+ return { text: kept.join("\n"), changed: true, type: "insert-row" }
114
+ }
115
+ }
116
+
117
+ const kept = [...lines.slice(0, found.start), ...lines.slice(found.end)]
118
+ return { text: kept.join("\n"), changed: true, type: found.type }
119
+ }
120
+
121
+ function appendInsert(text) {
122
+ const header = text.trim() ? (text.endsWith("\n") ? "\n" : "\n\n") : ""
123
+ return `${text}${header}${INSERT_BLOCK}`
76
124
  }
77
125
 
78
- /** 在 cordis.patch.yml 里追加 MCP 条目(幂等)。 */
79
- function appendEntry(patchFile, dryRun, act, dry) {
126
+ function ensureEntry(patchFile, dryRun, act, dry) {
80
127
  const existing = fs.existsSync(patchFile) ? fs.readFileSync(patchFile, "utf8") : ""
81
- if (existing.includes(ENTRY_ID) || existing.includes(`serverName: ${SERVER_NAME}`)) {
82
- act(`MCP entry already present in ${patchFile}`)
128
+ const lines = existing.split(/\r?\n/)
129
+ const found = findEntry(lines, ENTRY_ID)
130
+
131
+ if (found && found.type === "insert") {
132
+ act(`MCP insert entry already present in ${patchFile}`)
83
133
  return false
84
134
  }
85
- const stamp = new Date().toISOString().replace(/[:.]/g, "-")
135
+
136
+ let next = existing
137
+ if (found && found.type === "legacy") {
138
+ next = removeEntryBlock(existing, ENTRY_ID).text
139
+ act(`migrating legacy patch row in ${patchFile}`)
140
+ }
141
+ next = appendInsert(next)
142
+
86
143
  if (dryRun) {
87
- dry(`append ${ENTRY_ID} to ${patchFile}`)
144
+ dry(`write ${ENTRY_ID} insert block to ${patchFile}`)
88
145
  return true
89
146
  }
147
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-")
90
148
  if (existing) fs.copyFileSync(patchFile, `${patchFile}.bak-${stamp}`)
91
- const header = existing
92
- ? (existing.endsWith("\n") ? "" : "\n")
93
- : "# DSH profile patch layer\n"
94
- const next = `${existing}${header}${existing.trim() ? "\n" : ""}${ENTRIES}`
95
149
  fs.writeFileSync(patchFile, next, "utf8")
96
- act(`appended ${ENTRY_ID} to ${patchFile}`)
150
+ act(`wrote ${ENTRY_ID} insert block to ${patchFile}`)
97
151
  return true
98
152
  }
99
153
 
100
154
  function removeEntry(patchFile, dryRun, act, dry) {
101
155
  if (!fs.existsSync(patchFile)) return false
102
156
  const text = fs.readFileSync(patchFile, "utf8")
103
- const lines = text.split(/\r?\n/)
104
- const index = lines.findIndex((line) => line.trim() === `- id: ${ENTRY_ID}`)
105
- if (index < 0) return false
106
-
107
- let end = index + 1
108
- while (end < lines.length) {
109
- const line = lines[end]
110
- if (line.trim() === "" || /^[ \t]/.test(line)) end++
111
- else break
112
- }
157
+ const result = removeEntryBlock(text, ENTRY_ID)
158
+ if (!result.changed) return false
113
159
  if (dryRun) {
114
160
  dry(`remove ${ENTRY_ID} from ${patchFile}`)
115
161
  return true
116
162
  }
117
163
  const stamp = new Date().toISOString().replace(/[:.]/g, "-")
118
164
  fs.copyFileSync(patchFile, `${patchFile}.bak-${stamp}`)
119
- fs.writeFileSync(patchFile, lines.slice(0, index).concat(lines.slice(end)).join("\n"), "utf8")
165
+ fs.writeFileSync(patchFile, result.text, "utf8")
120
166
  act(`removed ${ENTRY_ID} from ${patchFile}`)
121
167
  return true
122
168
  }
123
169
 
170
+ function copySkill(source, target, dryRun, act, dry) {
171
+ const file = path.join(source, "SKILL.md")
172
+ if (!fs.existsSync(file)) return false
173
+ const targetFile = path.join(target, "SKILL.md")
174
+ if (fs.existsSync(targetFile) && fs.readFileSync(targetFile, "utf8") === fs.readFileSync(file, "utf8")) {
175
+ act(`skill already up to date ${target}`)
176
+ return true
177
+ }
178
+ if (dryRun) {
179
+ dry(`install skill ${SKILL_NAME} into ${target}`)
180
+ return true
181
+ }
182
+ fs.mkdirSync(target, { recursive: true })
183
+ fs.copyFileSync(file, targetFile)
184
+ act(`wrote skill ${targetFile}`)
185
+ return true
186
+ }
187
+
124
188
  function removeSkill(target, dryRun, act, dry) {
125
189
  const file = path.join(target, "SKILL.md")
126
190
  if (!fs.existsSync(file)) return false
127
- const text = fs.readFileSync(file, "utf8")
128
- if (!text.includes("Skill audit workflow")) {
191
+ if (!fs.readFileSync(file, "utf8").includes("Skill audit workflow")) {
129
192
  act(`kept ${target} (not the generated skill)`)
130
193
  return false
131
194
  }
@@ -157,7 +220,7 @@ export function installDsh({ home = dshHome(), profile = null, dryRun = false }
157
220
  if (dryRun) dry(`profile: ${picked.profile} (${dir})`)
158
221
  else act(`profile: ${picked.profile}`)
159
222
 
160
- appendEntry(patchFile, dryRun, act, dry)
223
+ ensureEntry(patchFile, dryRun, act, dry)
161
224
  copySkill(skillSourceDir(), skillDir, dryRun, act, dry)
162
225
 
163
226
  return { ok: errors.length === 0, home, profile: picked.profile, patchFile, skillDir, actions, errors, dryRun }
package/src/i18n.mjs CHANGED
@@ -127,7 +127,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
127
127
  "dsh.profile": " profile: {profile}",
128
128
  "dsh.patch": " patch: {file}",
129
129
  "dsh.skill": " skill: {dir}",
130
- "dsh.restart": "Restart DSH to load the MCP tool (the skill updates live).",
130
+ "dsh.restart": "Restart DSH to load the MCP tool (HMR reloads the patch when enabled; the skill updates live).",
131
131
  "dsh.failed": "DSH integration failed.",
132
132
  "update.toolAvailable":
133
133
  " ! skills-doctor {latest} is available (you have {current}); update with: {cmd} · {url}",
@@ -348,7 +348,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
348
348
  "dsh.profile": " profile: {profile}",
349
349
  "dsh.patch": " patch: {file}",
350
350
  "dsh.skill": " skill: {dir}",
351
- "dsh.restart": "重启 DSH 加载 MCP 工具(skill 会即时更新)。",
351
+ "dsh.restart": "重启 DSH 加载 MCP 工具(启用 HMR 时会自动重载 patch;skill 即时更新)。",
352
352
  "dsh.failed": "DSH 集成失败。",
353
353
  "update.toolAvailable": " ! skills-doctor 有新版本 {latest}(当前 {current});更新:{cmd} · {url}",
354
354
  "upgrade.title": "skills-doctor 自更新",