opencode-skills-doctor 0.6.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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 自更新",