dsh-skill-mcp 0.2.0

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/lib/index.js ADDED
@@ -0,0 +1,834 @@
1
+ /**
2
+ * dsh-skill-mcp host half (v0.2.0).
3
+ *
4
+ * 技能与MCP引擎,两部分共用同一条 connection RPC 通道(官方推荐的
5
+ * Client→Host 私有通道接缝,authority: 'loopback',取代 v0.1 的
6
+ * /api/cc-skills/* HTTP 路由):
7
+ *
8
+ * 技能管理(v0.1 逻辑原样保留):
9
+ * skills/list 用户级技能 + 链接/置顶/停用/agent 标记
10
+ * skills/link { sourceDirectory } — 目录联接进 ~/.dsh/skills
11
+ * skills/unlink { name } — 只移除联接点
12
+ * skills/delete { name } — agent 创建 + 非置顶 + 路径围栏内才可删
13
+ * skills/disable { name } — frontmatter 双标记停用
14
+ * skills/enable { name } — 移除双标记
15
+ *
16
+ * 按项目 MCP 自动加载(v0.2 新增):
17
+ * mcp/sync { cwd, force? } — client 推送当前会话工作区;
18
+ * host 读取用户级 ~/.dsh/mcp.json + 项目级
19
+ * <cwd>/.dsh/mcp.json(回退 <cwd>/.mcp.json Claude 格式),
20
+ * 通过 ctx.loader 热加载/热卸载 @deepseek-ai/dsh-mcp-client
21
+ * 条目(id 前缀 pmcp-,绝不触碰其他来源的条目)
22
+ * mcp/status 条目 + fiber 阶段 + 工具计数 + 配置文件存在性
23
+ * mcp/setEnabled { name, enabled } — 热启停并持久化到
24
+ * ~/.dsh/project-mcp-state.json
25
+ * mcp/reload 强制重读配置并 reconcile
26
+ *
27
+ * codegraph 场景:command "codegraph"、args ["serve","--mcp"]、cwd=项目根
28
+ * (靠项目根下的 .codegraph/ 定位索引)。
29
+ */
30
+
31
+ import { execFile } from 'node:child_process'
32
+ import { watch as fsWatch } from 'node:fs'
33
+ import { lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises'
34
+ import { promisify } from 'node:util'
35
+ import { homedir } from 'node:os'
36
+ import path from 'node:path'
37
+
38
+ const execFileP = promisify(execFile)
39
+
40
+ /**
41
+ * Last-resort write path. Inside the dsh host, opens under the skills root
42
+ * can be denied with EPERM for stretches of time (the skills watcher seems
43
+ * to hold the tree during/after hot-loads). A powershell child process from
44
+ * the same host usually still writes fine, so callers fall back to it when
45
+ * the in-process atomic write is denied. Content travels base64/UTF-16 to
46
+ * survive quoting. The script reads the file back and diffs — a silent
47
+ * denial must not masquerade as success (the file already exists, so a
48
+ * mere existence check is useless).
49
+ */
50
+ async function powershellWrite(target, content) {
51
+ const b64 = Buffer.from(content, 'utf16le').toString('base64')
52
+ const script =
53
+ `$p='${target}'; ` +
54
+ `$d=[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('${b64}')); ` +
55
+ `[System.IO.File]::WriteAllText($p, $d, (New-Object System.Text.UTF8Encoding $false)); ` +
56
+ `$back=[System.IO.File]::ReadAllText($p); ` +
57
+ `if ($back -ceq $d) { exit 0 } else { Write-Error 'write verification failed'; exit 2 }`
58
+ await execFileP('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script])
59
+ }
60
+
61
+ /**
62
+ * atomicWrite, with the powershell escape hatch when the in-process write is
63
+ * denied (EPERM/EACCES/EBUSY after all retries). Other errors rethrow.
64
+ */
65
+ async function writeTextResilient(target, content) {
66
+ try {
67
+ await atomicWrite(target, content)
68
+ return
69
+ } catch (e) {
70
+ if (e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY') throw e
71
+ // The powershell escape hatch only exists on Windows. On POSIX the
72
+ // watcher lock is not observed in practice, so surface the denial.
73
+ if (process.platform !== 'win32') {
74
+ throw new Error(`write denied under the skills root (watcher lock); retry in a moment — ${e2str(e)}`)
75
+ }
76
+ try {
77
+ await powershellWrite(target, content)
78
+ } catch (e2) {
79
+ throw new Error(`write denied under the skills root (watcher lock); retry in a moment — ${e2str(e2)}`)
80
+ }
81
+ }
82
+ }
83
+
84
+ /** Normalize an error to a short single-line string for error messages. */
85
+ function e2str(e) {
86
+ return String(e?.message || e).slice(0, 160)
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Constants
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const MAX_NAME_LENGTH = 64
94
+ const MAX_DESCRIPTION_LENGTH = 1024
95
+ const VALID_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
96
+ const AGENT_MARKER = 'created_by: agent'
97
+ const DISABLE_KEY = 'disable-model-invocation'
98
+ /** 聊天框选择器的可见性开关:false 时聊天框不再列出该技能。 */
99
+ const USER_INVOCABLE_KEY = 'user-invocable'
100
+ const PIN_KEY = 'pinned'
101
+
102
+ // RPC 通道与按项目 MCP 的命名空间。
103
+ const RPC_CHANNEL = '/skill-mcp'
104
+ const MCP_ENTRY_PREFIX = 'pmcp-'
105
+ const MCP_CLIENT_NAME = '@deepseek-ai/dsh-mcp-client'
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function dshHome() {
112
+ return process.env.DSH_HOME || path.join(homedir(), '.dsh')
113
+ }
114
+
115
+ function skillsRoot() {
116
+ return path.join(dshHome(), 'skills')
117
+ }
118
+
119
+ function fail(error) {
120
+ return { ok: false, error }
121
+ }
122
+
123
+ function ok(message, extra = {}) {
124
+ return { ok: true, message, ...extra }
125
+ }
126
+
127
+ /** connection RPC 的信封(镜像 dsh-skill-mcp-center 的 RpcResult 形状)。 */
128
+ function rpcOk(value) {
129
+ return { ok: true, value }
130
+ }
131
+
132
+ function rpcFail(message) {
133
+ return { ok: false, error: { code: 'internal', message: String(message).slice(0, 300), details: {} } }
134
+ }
135
+
136
+ /**
137
+ * Minimal CRLF-safe YAML frontmatter reader (plain `key: value` lines plus
138
+ * `|` / `>` block scalars) — the subset that appears in real SKILL.md files.
139
+ */
140
+ function parseFrontmatter(content) {
141
+ const text = String(content).replace(/^\uFEFF/, '')
142
+ if (!text.startsWith('---')) return { error: 'SKILL.md must start with YAML frontmatter (---).' }
143
+ const end = text.slice(3).search(/\n---\s*(\n|$)/)
144
+ if (end < 0) return { error: "frontmatter is not closed; ensure a closing '---' line." }
145
+ const raw = text.slice(3, end + 3)
146
+ const body = text.slice(end + 3).replace(/^---\s*\n?/, '')
147
+ const data = {}
148
+ const lines = raw.split('\n')
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const m = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
151
+ if (!m) continue
152
+ const [, key, value] = m
153
+ if (value === '|' || value === '|-' || value === '|+' || value === '>' || value === '>-' || value === '>+') {
154
+ const block = []
155
+ let j = i + 1
156
+ for (; j < lines.length; j++) {
157
+ const next = lines[j]
158
+ if (next.trim() === '') { block.push(''); continue }
159
+ if (/^[ \t]/.test(next)) { block.push(next.replace(/\r$/, '')); continue }
160
+ break
161
+ }
162
+ i = j - 1
163
+ const stripped = block.map((l) => l.replace(/^[ \t]{1,8}/, ''))
164
+ let joined
165
+ if (value.startsWith('|')) joined = stripped.join('\n')
166
+ else joined = stripped.join(' ')
167
+ joined = joined.replace(/\n{3,}/g, '\n\n')
168
+ joined = value.endsWith('-') ? joined.replace(/\s+$/, '') : joined.replace(/\s+$/, '\n')
169
+ data[key] = joined.trim() === '' ? '' : joined
170
+ continue
171
+ }
172
+ data[key] = value.replace(/^['"]|['"]$/g, '')
173
+ }
174
+ return { data, body, raw }
175
+ }
176
+
177
+ function validateName(name) {
178
+ if (!name) return 'Skill name is required.'
179
+ if (name.length > MAX_NAME_LENGTH) return `Skill name exceeds ${MAX_NAME_LENGTH} characters.`
180
+ if (!VALID_NAME_RE.test(name)) return `Invalid skill name '${name}'. Use lowercase letters, numbers, hyphens, dots, underscores; must start with a letter or digit.`
181
+ return null
182
+ }
183
+
184
+ /** Junction/symlink probe: `lstat` reports junctions as directories, so readlink is the reliable test. */
185
+ async function linkTargetOf(p) {
186
+ try { return await readlink(p) } catch { return null }
187
+ }
188
+
189
+ /**
190
+ * Resolve a skill by name in the two layouts the skill-filesystem watcher
191
+ * loads: <root>/<name>/SKILL.md and <root>/<name>.md.
192
+ */
193
+ async function findSkill(name, root) {
194
+ const nameErr = validateName(name)
195
+ if (nameErr) return { error: nameErr }
196
+ const dir = path.join(root, name)
197
+ const skillMd = path.join(dir, 'SKILL.md')
198
+ try {
199
+ await readFile(skillMd, 'utf8')
200
+ return { dir, skillMd, root, layout: 'dir' }
201
+ } catch { /* fall through to the single-file layout */ }
202
+ const fileMd = path.join(root, name + '.md')
203
+ try {
204
+ await readFile(fileMd, 'utf8')
205
+ return { dir: null, skillMd: fileMd, root, layout: 'file' }
206
+ } catch {
207
+ return { error: `Skill '${name}' not found under ${root} (looked for ${name}/SKILL.md and ${name}.md).` }
208
+ }
209
+ }
210
+
211
+ /** Atomic write: temp file + rename, so a watcher never sees a half-written file. */
212
+ async function atomicWrite(target, content) {
213
+ // The skills watcher / Defender can transiently deny opens (EPERM/EBUSY)
214
+ // under the skills root right after a hot-load; back off and retry.
215
+ let lastError
216
+ for (let attempt = 0; attempt < 6; attempt++) {
217
+ await new Promise((r) => setTimeout(r, attempt === 0 ? 0 : 300))
218
+ try {
219
+ await mkdir(path.dirname(target), { recursive: true })
220
+ } catch (e) {
221
+ lastError = e
222
+ if (e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY') throw e
223
+ continue
224
+ }
225
+ const tmp = target + '.tmp-' + process.pid + '-' + Date.now()
226
+ try {
227
+ await writeFile(tmp, content, 'utf8')
228
+ await rename(tmp, target)
229
+ return
230
+ } catch (e) {
231
+ lastError = e
232
+ if (e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY') throw e
233
+ }
234
+ }
235
+ throw lastError
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Skills: list
240
+ // ---------------------------------------------------------------------------
241
+
242
+ async function listSkills() {
243
+ const root = skillsRoot()
244
+ let entries
245
+ try {
246
+ entries = await readdir(root, { withFileTypes: true })
247
+ } catch {
248
+ return ok(`skills root ${root} does not exist yet`, { skills: [], root })
249
+ }
250
+ const rows = []
251
+ const seen = new Set()
252
+ const pushRow = async (name, contentPath, layout, linked, target) => {
253
+ if (seen.has(name)) return
254
+ seen.add(name)
255
+ let content = ''
256
+ try { content = await readFile(contentPath, 'utf8') } catch { content = '' }
257
+ const data = parseFrontmatter(content).data || {}
258
+ rows.push({
259
+ name,
260
+ layout,
261
+ linked,
262
+ target: target || null,
263
+ agent_created: content.includes(AGENT_MARKER),
264
+ pinned: String(data[PIN_KEY]).toLowerCase() === 'true',
265
+ disabled: String(data[DISABLE_KEY]).toLowerCase() === 'true'
266
+ || String(data[USER_INVOCABLE_KEY]).toLowerCase() === 'false',
267
+ description: String(data.description || '').slice(0, 100),
268
+ })
269
+ }
270
+ for (const e of entries) {
271
+ if (e.name.startsWith('.')) continue
272
+ const full = path.join(root, e.name)
273
+ const linked = await linkTargetOf(full) !== null
274
+ const target = linked ? await linkTargetOf(full) : null
275
+ // Junctions/symlinks report neither isDirectory() nor isFile() on Node 24,
276
+ // so anything that is not a plain .md file is treated as a directory skill.
277
+ if (e.isFile()) {
278
+ if (e.name.endsWith('.md')) await pushRow(e.name.slice(0, -3), full, 'file', linked, target)
279
+ } else {
280
+ await pushRow(e.name, path.join(full, 'SKILL.md'), 'dir', linked, target)
281
+ }
282
+ }
283
+ rows.sort((a, b) => a.name.localeCompare(b.name))
284
+ return ok(`${rows.length} skill(s)`, { skills: rows, root })
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Skills: link / unlink (the plugin's own feature)
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Create a junction (Windows) or plain symlink (macOS/Linux — Node ignores
293
+ * the 'junction' type there). Defender and the skills watcher can transiently
294
+ * lock the skills root while it hot-loads the previously linked skill, so
295
+ * each attempt first clears a possible half-created reparse point, then
296
+ * tries symlink 'junction' and — Windows only — `mklink /J` (no privilege
297
+ * needed either).
298
+ */
299
+ async function createJunction(link, target) {
300
+ let lastError
301
+ for (let attempt = 0; attempt < 6; attempt++) {
302
+ await new Promise((r) => setTimeout(r, attempt === 0 ? 0 : 250))
303
+ try { await rm(link, { force: true }) } catch { /* nothing to clear */ }
304
+ try {
305
+ await symlink(target, link, 'junction')
306
+ return
307
+ } catch (e) {
308
+ lastError = e
309
+ if (e.code === 'EEXIST') throw e
310
+ }
311
+ if (process.platform !== 'win32') continue
312
+ try {
313
+ await execFileP('cmd.exe', ['/d', '/c', 'mklink', '/J', link, target])
314
+ return
315
+ } catch (e) {
316
+ lastError = e
317
+ }
318
+ }
319
+ throw lastError
320
+ }
321
+
322
+ /**
323
+ * Junction every subdirectory of `sourceDirectory` into the skills root.
324
+ * Mirrors scripts/link-skills.ps1: an existing reparse point at the target
325
+ * is replaced; an ordinary directory or file aborts the whole run so the
326
+ * user can decide instead of us silently skipping.
327
+ */
328
+ async function linkAllSkills(sourceDirectory) {
329
+ if (!sourceDirectory || !path.isAbsolute(sourceDirectory)) {
330
+ return fail('sourceDirectory must be an absolute path.')
331
+ }
332
+ let stat
333
+ try { stat = await lstat(sourceDirectory) } catch { return fail(`Source directory not found: ${sourceDirectory}`) }
334
+ if (!stat.isDirectory()) return fail(`Not a directory: ${sourceDirectory}`)
335
+ let sources
336
+ try { sources = await readdir(sourceDirectory, { withFileTypes: true }) } catch (e) {
337
+ return fail(`Cannot read ${sourceDirectory}: ${e.message}`)
338
+ }
339
+ const root = skillsRoot()
340
+ await mkdir(root, { recursive: true })
341
+ const linked = []
342
+ for (const entry of sources) {
343
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
344
+ const link = path.join(root, entry.name)
345
+ const existingTarget = await linkTargetOf(link)
346
+ if (existingTarget !== null) {
347
+ // Re-link: drop the old junction (never its target) before recreating.
348
+ await rm(link, { force: true })
349
+ } else {
350
+ let exists
351
+ try { exists = await lstat(link) } catch { exists = null }
352
+ if (exists) return fail(`Target exists and is not a link: ${link} — remove it manually or pick another name. Everything before this entry is already linked.`)
353
+ }
354
+ const target = path.join(await realpath(sourceDirectory), entry.name)
355
+ try {
356
+ await createJunction(link, target)
357
+ } catch (e) {
358
+ return fail(`Failed to link '${entry.name}': ${e.message}. Everything before this entry is already linked.`)
359
+ }
360
+ linked.push(entry.name)
361
+ }
362
+ return ok(linked.length ? `Linked ${linked.length} skill(s) from ${sourceDirectory} into ${root}.` : `No skill directories found under ${sourceDirectory}.`, { linked })
363
+ }
364
+
365
+ /** Remove one junction. Refuses anything that is not a reparse point. */
366
+ async function unlinkSkill(name) {
367
+ const nameErr = validateName(name)
368
+ if (nameErr) return fail(nameErr)
369
+ const link = path.join(skillsRoot(), name)
370
+ if ((await linkTargetOf(link)) === null) {
371
+ return fail(`'${name}' under the skills root is not a junction; refusing to remove it with unlink. Use delete for real skills.`)
372
+ }
373
+ await rm(link, { force: true })
374
+ return ok(`Unlinked '${name}'. The source directory is untouched.`)
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Skills: delete guards (from dsh-skill-manage)
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /**
382
+ * Last-line defense before a recursive delete: refuses the skills root
383
+ * itself, paths resolving outside the root, and junctioned/symlinked dirs
384
+ * (a junction lstat's as a directory, so readlink is checked explicitly).
385
+ */
386
+ async function validateDeleteTarget(dir, root) {
387
+ let st
388
+ try { st = await lstat(dir) } catch { return `Skill directory not found: ${dir}` }
389
+ if (st.isSymbolicLink()) return `Refusing to delete '${dir}': it is a symlink. Remove the link with unlink instead.`
390
+ if ((await linkTargetOf(dir)) !== null) return `Refusing to delete '${dir}': it is a junction. Remove the link with unlink instead.`
391
+ const resolvedRoot = await realpath(root).catch(() => root)
392
+ const resolved = await realpath(dir).catch(() => dir)
393
+ if (resolved === resolvedRoot) return 'Refusing to delete: target resolves to the skills root itself.'
394
+ const rel = path.relative(resolvedRoot, resolved)
395
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return `Refusing to delete '${dir}': path does not resolve inside the skills root.`
396
+ return null
397
+ }
398
+
399
+ /** Ownership guards: only agent-created skills are deletable, pinned skills are refused. */
400
+ async function validateDeleteOwnership(name, skillMd) {
401
+ let content
402
+ try { content = await readFile(skillMd, 'utf8') } catch { return null }
403
+ const data = parseFrontmatter(content).data || {}
404
+ if (String(data[PIN_KEY]).toLowerCase() === 'true') {
405
+ return `Skill '${name}' is pinned (\`pinned: true\`) and cannot be deleted. Unpin it first.`
406
+ }
407
+ if (!content.includes(AGENT_MARKER)) {
408
+ return `Refusing to delete '${name}': not agent-created (no \`${AGENT_MARKER}\` marker). Marketplace and user-authored skills are off-limits.`
409
+ }
410
+ return null
411
+ }
412
+
413
+ async function deleteSkill(name) {
414
+ const found = await findSkill(name, skillsRoot())
415
+ if (found.error) return fail(found.error)
416
+ const root = found.root
417
+ if (found.layout === 'file') {
418
+ const owner = await validateDeleteOwnership(name, found.skillMd)
419
+ if (owner) return fail(owner)
420
+ await rm(found.skillMd, { force: true })
421
+ return ok(`Skill '${name}' deleted (${name}.md).`)
422
+ }
423
+ const unsafe = await validateDeleteTarget(found.dir, root)
424
+ if (unsafe) return fail(unsafe)
425
+ const owner = await validateDeleteOwnership(name, found.skillMd)
426
+ if (owner) return fail(owner)
427
+ await rm(found.dir, { recursive: true, force: true })
428
+ return ok(`Skill '${name}' deleted.`)
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Skills: frontmatter flags (disable / enable, from dsh-skill-manage)
433
+ // ---------------------------------------------------------------------------
434
+
435
+ /**
436
+ * 停用/启用(对齐 dsh-skill-explorer 的 set-enabled 语义,但更彻底):
437
+ * 停用 = `disable-model-invocation: true`(模型目录隐藏)
438
+ * + `user-invocable: false`(聊天框选择器也不列出)
439
+ * 启用 = 两个标记一起移除。
440
+ * 只写 disable-model-invocation 时,聊天框按 user-invocable(默认 true)
441
+ * 过滤,技能依然可选 —— 这正是"面板显示已停用但聊天框还能选到"的原因。
442
+ */
443
+ async function setSkillEnabled(name, enabled) {
444
+ const found = await findSkill(name, skillsRoot())
445
+ if (found.error) return fail(found.error)
446
+ if (found.layout === 'file') return fail(`Skill '${name}' is a single-file skill; edit ${name}.md manually.`)
447
+ // Linked skills: write through the junction's source path. Same file, but
448
+ // the source usually sits outside the skills root — exactly where the
449
+ // watcher's write denials strike.
450
+ let writePath = found.skillMd
451
+ try {
452
+ const real = await realpath(found.dir)
453
+ if (path.resolve(real) !== path.resolve(found.dir)) writePath = path.join(real, 'SKILL.md')
454
+ } catch { /* keep the original path */ }
455
+ let content
456
+ try { content = await readFile(writePath, 'utf8') } catch { return fail(`Cannot read ${writePath}.`) }
457
+ const parsed = parseFrontmatter(content)
458
+ if (parsed.error) return fail(`Refusing to touch '${name}': ${parsed.error}`)
459
+ if (parsed.data.description && parsed.data.description.length > MAX_DESCRIPTION_LENGTH) {
460
+ return fail(`Refusing to touch '${name}': description exceeds ${MAX_DESCRIPTION_LENGTH} chars.`)
461
+ }
462
+ const lines = content.split('\n')
463
+ if (lines.indexOf('---', 1) < 0) return fail(`Refusing to touch '${name}': cannot locate end of frontmatter.`)
464
+ /** 在当前 frontmatter 里删除 key 行;返回是否删除过(下标随删随扫,避免漂移)。 */
465
+ const cutKey = (key) => {
466
+ const end = lines.indexOf('---', 1)
467
+ for (let i = 1; i < end; i++) {
468
+ if (lines[i].startsWith(key + ':')) { lines.splice(i, 1); return true }
469
+ }
470
+ return false
471
+ }
472
+ if (enabled) {
473
+ const changed = [DISABLE_KEY, USER_INVOCABLE_KEY].map(cutKey)
474
+ if (!changed.some(Boolean)) return ok(`Skill '${name}' is already enabled.`)
475
+ } else {
476
+ const disabled = String(parsed.data[DISABLE_KEY]).toLowerCase() === 'true'
477
+ const hidden = String(parsed.data[USER_INVOCABLE_KEY]).toLowerCase() === 'false'
478
+ if (disabled && hidden) return ok(`Skill '${name}' is already disabled.`)
479
+ cutKey(DISABLE_KEY)
480
+ cutKey(USER_INVOCABLE_KEY)
481
+ lines.splice(lines.indexOf('---', 1), 0, `${USER_INVOCABLE_KEY}: false`, `${DISABLE_KEY}: true`)
482
+ }
483
+ await writeTextResilient(writePath, lines.join('\n'))
484
+ return ok(enabled
485
+ ? `Skill '${name}' enabled.`
486
+ : `Skill '${name}' disabled (hidden from the model catalog and the chat picker).`)
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Project MCP: config reading & normalization
491
+ // ---------------------------------------------------------------------------
492
+
493
+ /** mcp-client 的 serverName 约束:[A-Za-z0-9_-]{1,32},全局唯一。 */
494
+ function sanitizeServerName(raw) {
495
+ const name = String(raw ?? '').trim().replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32)
496
+ return /^[A-Za-z0-9_-]{1,32}$/.test(name) ? name : ''
497
+ }
498
+
499
+ function plainStringMap(raw) {
500
+ const out = {}
501
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out
502
+ for (const [k, v] of Object.entries(raw)) {
503
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) continue
504
+ out[k] = {}
505
+ for (const [k2, v2] of Object.entries(v)) {
506
+ if (typeof v2 === 'string' || typeof v2 === 'number' || typeof v2 === 'boolean') out[k][k2] = v2
507
+ }
508
+ }
509
+ return out
510
+ }
511
+
512
+ function numOrDefault(raw, dflt) {
513
+ return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : dflt
514
+ }
515
+
516
+ /**
517
+ * 把一条原始配置(dsh 原生或 Claude 格式)归一化成完整的 mcp-client config。
518
+ * stdio 的 cwd 相对路径按 projectRoot resolve,缺省即项目根 —— codegraph
519
+ * 正是靠 cwd 下的 .codegraph/ 定位索引。
520
+ */
521
+ function normalizeMcpEntry(rawName, raw, projectRoot) {
522
+ const serverName = sanitizeServerName(rawName)
523
+ if (serverName === '') return null
524
+ const type = String(raw.type ?? '').toLowerCase()
525
+ const url = typeof raw.url === 'string' && raw.url !== '' ? raw.url : null
526
+ if (type === 'http' || type === 'sse' || type === 'streamable-http' || (!raw.command && url)) {
527
+ return {
528
+ transport: 'streamable-http',
529
+ serverName,
530
+ url,
531
+ headers: plainStringMap(raw.headers)[Object.keys(plainStringMap(raw.headers))[0] ?? ''] ?? {},
532
+ toolCallTimeoutMs: numOrDefault(raw.toolCallTimeoutMs, 60_000),
533
+ failOnStartupError: false,
534
+ }
535
+ }
536
+ const command = typeof raw.command === 'string' ? raw.command : ''
537
+ if (command === '') return null
538
+ const args = Array.isArray(raw.args) ? raw.args.map(String) : []
539
+ const envEntries = plainStringMap({ env: raw.env }).env ?? {}
540
+ const cwd = typeof raw.cwd === 'string' && raw.cwd !== ''
541
+ ? path.resolve(projectRoot, raw.cwd)
542
+ : projectRoot
543
+ const config = {
544
+ transport: 'stdio',
545
+ serverName,
546
+ command,
547
+ args,
548
+ env: envEntries,
549
+ cwd,
550
+ toolCallTimeoutMs: numOrDefault(raw.toolCallTimeoutMs, 60_000),
551
+ failOnStartupError: false,
552
+ }
553
+ if (raw.reconnect !== null && typeof raw.reconnect === 'object' && !Array.isArray(raw.reconnect)) {
554
+ config.reconnect = raw.reconnect
555
+ }
556
+ return config
557
+ }
558
+
559
+ /**
560
+ * 读一个配置文件并展开为 Map<serverName, config>。
561
+ * 识别两种格式:Claude({ mcpServers: {…} })与 dsh 原生({ servers: {…} }
562
+ * 或顶层直接是 name→config 映射)。解析失败返回 { error }。
563
+ */
564
+ async function readMcpConfigFile(filePath, projectRoot) {
565
+ let text
566
+ try {
567
+ text = await readFile(filePath, 'utf8')
568
+ } catch (e) {
569
+ if (e.code === 'ENOENT') return { servers: {} }
570
+ return { error: `${filePath}: ${e2str(e)}` }
571
+ }
572
+ let doc
573
+ try {
574
+ doc = JSON.parse(text.replace(/^\uFEFF/, ''))
575
+ } catch (e) {
576
+ return { error: `${filePath}: invalid JSON — ${e2str(e)}` }
577
+ }
578
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
579
+ return { error: `${filePath}: top level must be a JSON object.` }
580
+ }
581
+ const rawMap = doc.mcpServers ?? doc.servers ?? doc
582
+ if (rawMap === null || typeof rawMap !== 'object' || Array.isArray(rawMap)) {
583
+ return { error: `${filePath}: no mcpServers/servers map found.` }
584
+ }
585
+ const servers = {}
586
+ for (const [name, raw] of Object.entries(rawMap)) {
587
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
588
+ const config = normalizeMcpEntry(name, raw, projectRoot)
589
+ if (config !== null) servers[config.serverName] = config
590
+ }
591
+ return { servers }
592
+ }
593
+
594
+ async function pathExists(p) {
595
+ try { await lstat(p); return true } catch { return false }
596
+ }
597
+
598
+ /** 启停状态文件:{ disabled: [name, …] }。损坏时按空处理。 */
599
+ function mcpStatePath() {
600
+ return path.join(dshHome(), 'project-mcp-state.json')
601
+ }
602
+
603
+ async function loadMcpDisabled() {
604
+ try {
605
+ const doc = JSON.parse(await readFile(mcpStatePath(), 'utf8'))
606
+ if (Array.isArray(doc?.disabled)) return doc.disabled.map(String)
607
+ } catch { /* absent or corrupt → empty */ }
608
+ return []
609
+ }
610
+
611
+ async function saveMcpDisabled(disabled) {
612
+ await mkdir(dshHome(), { recursive: true })
613
+ await atomicWrite(mcpStatePath(), JSON.stringify({ disabled }, null, 2) + '\n')
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Project MCP: reconcile engine (ctx-bound, defined in apply)
618
+ // ---------------------------------------------------------------------------
619
+
620
+ export const name = 'dsh-skill-mcp'
621
+ export const inject = ['connection', 'loader', 'tools']
622
+
623
+ export function apply(ctx) {
624
+ // ---- MCP engine state ----
625
+ let lastCwd = null
626
+ let desired = new Map() // serverName -> { config, source, disabled }
627
+ let fileStates = { user: null, projectDsh: null, projectClaude: null }
628
+ const watchers = new Map() // watched dir -> { watcher, timer, key }
629
+
630
+ const userMcpPath = () => path.join(dshHome(), 'mcp.json')
631
+ const projectDshPath = (cwd) => path.join(cwd, '.dsh', 'mcp.json')
632
+ const projectClaudePath = (cwd) => path.join(cwd, '.mcp.json')
633
+
634
+ /** 读三层配置并合并:用户级 → 项目 .mcp.json → 项目 .dsh/mcp.json(后者覆盖同名)。 */
635
+ async function readDesired(cwd) {
636
+ const disabledList = new Set(await loadMcpDisabled())
637
+ const merged = new Map()
638
+ const layers = [
639
+ ['user', userMcpPath()],
640
+ ['project-mcp-json', projectClaudePath(cwd)],
641
+ ['project-dsh-json', projectDshPath(cwd)],
642
+ ]
643
+ const files = {}
644
+ for (const [source, filePath] of layers) {
645
+ files[source] = { path: filePath, exists: await pathExists(filePath) }
646
+ const parsed = await readMcpConfigFile(filePath, cwd)
647
+ if (parsed.error) return { error: parsed.error, files }
648
+ for (const [serverName, config] of Object.entries(parsed.servers)) {
649
+ merged.set(serverName, { config, source, disabled: disabledList.has(serverName) })
650
+ }
651
+ }
652
+ return { merged, files }
653
+ }
654
+
655
+ /** 与 ctx.loader 对账:只增删改 id 前缀 pmcp- 的条目。 */
656
+ async function reconcile() {
657
+ const entries = ctx.loader.entries().filter((e) => String(e.id).startsWith(MCP_ENTRY_PREFIX))
658
+ const byId = new Map(entries.map((e) => [e.id, e]))
659
+ for (const [serverName, item] of desired) {
660
+ const id = MCP_ENTRY_PREFIX + serverName
661
+ const existing = byId.get(id)
662
+ if (existing === undefined) {
663
+ await ctx.loader.create({ id, name: MCP_CLIENT_NAME, config: item.config })
664
+ if (item.disabled) await ctx.loader.update(id, { disabled: true })
665
+ continue
666
+ }
667
+ const sameConfig = JSON.stringify(existing.options?.config ?? null) === JSON.stringify(item.config)
668
+ if (!sameConfig) await ctx.loader.update(id, { config: item.config })
669
+ const wantDisabled = item.disabled === true
670
+ if (Boolean(existing.disabled) !== wantDisabled) {
671
+ await ctx.loader.update(id, { disabled: wantDisabled ? true : null })
672
+ }
673
+ }
674
+ for (const [id] of byId) {
675
+ if (!desired.has(id.slice(MCP_ENTRY_PREFIX.length))) await ctx.loader.remove(id)
676
+ }
677
+ }
678
+
679
+ /** 关掉不再属于当前配置集的监听;为存在的配置文件所在目录开新监听。 */
680
+ function rewatchFiles() {
681
+ for (const [dir, entry] of watchers) {
682
+ if (fileStatesBelong(dir)) continue
683
+ entry.close()
684
+ watchers.delete(dir)
685
+ }
686
+ for (const source of ['user', 'project-mcp-json', 'project-dsh-json']) {
687
+ const info = fileStates[source]
688
+ if (!info?.exists) continue
689
+ const dir = path.dirname(info.path)
690
+ if (watchers.has(dir)) continue
691
+ let timer = null
692
+ let watcher = null
693
+ try {
694
+ watcher = fsWatch(dir, (event, filename) => {
695
+ if (filename !== null && path.basename(info.path) !== filename) return
696
+ if (timer !== null) clearTimeout(timer)
697
+ timer = setTimeout(() => {
698
+ timer = null
699
+ void sync(lastCwd, true).catch((e) => console.warn('[dsh-skill-mcp] mcp file-watch sync failed:', e2str(e)))
700
+ }, 500)
701
+ })
702
+ } catch { /* dir may vanish; sync() re-establishes */ }
703
+ watchers.set(dir, {
704
+ close: () => {
705
+ try { watcher?.close() } catch { /* already closed */ }
706
+ if (timer !== null) { clearTimeout(timer); timer = null }
707
+ },
708
+ })
709
+ }
710
+ }
711
+
712
+ function fileStatesBelong(dir) {
713
+ for (const source of ['user', 'project-mcp-json', 'project-dsh-json']) {
714
+ const info = fileStates[source]
715
+ if (info?.exists && path.dirname(info.path) === dir) return true
716
+ }
717
+ return false
718
+ }
719
+
720
+ /** sync 主体:cwd 变化或 force 时重读配置并 reconcile。 */
721
+ async function sync(cwd, force = false) {
722
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
723
+ throw new Error('mcp/sync: cwd must be an absolute path')
724
+ }
725
+ let st
726
+ try { st = await lstat(cwd) } catch { throw new Error(`mcp/sync: cwd not found: ${cwd}`) }
727
+ if (!st.isDirectory()) throw new Error(`mcp/sync: not a directory: ${cwd}`)
728
+ if (!force && cwd === lastCwd) return { changed: false, cwd: lastCwd, names: [...desired.keys()] }
729
+ const parsed = await readDesired(cwd)
730
+ if (parsed.error) throw new Error(parsed.error)
731
+ lastCwd = cwd
732
+ desired = parsed.merged
733
+ fileStates = parsed.files
734
+ await reconcile()
735
+ rewatchFiles()
736
+ return { changed: true, cwd, names: [...desired.keys()], sources: Object.fromEntries([...desired].map(([n, it]) => [n, it.source])) }
737
+ }
738
+
739
+ /** 运行状态:fiber 阶段 + 工具计数(seam 优先,ctx.tools 派生回退)。 */
740
+ async function mcpStatus() {
741
+ const servers = []
742
+ const entries = ctx.loader.entries().filter((e) => String(e.id).startsWith(MCP_ENTRY_PREFIX))
743
+ const byId = new Map(entries.map((e) => [e.id, e]))
744
+ const seam = ctx.get?.('mcpStatus')
745
+ const toolNames = (seam === undefined && typeof ctx.tools?.schemas === 'function')
746
+ ? ctx.tools.schemas().map((s) => s.name)
747
+ : []
748
+ for (const [serverName, item] of desired) {
749
+ const entry = byId.get(MCP_ENTRY_PREFIX + serverName)
750
+ const fiberState = entry?.fiber?.state
751
+ const fiberPhase = fiberState === undefined || fiberState === null ? null
752
+ : ({ 0: 'pending', 1: 'loading', 2: 'active', 3: 'failed', 4: null, 5: 'unloading' })[fiberState] ?? null
753
+ let toolCount = 0
754
+ let connected = false
755
+ if (seam !== undefined && typeof seam?.list === 'function') {
756
+ const st = seam.list().find((s) => s.serverName === serverName)
757
+ toolCount = st?.toolCount ?? 0
758
+ connected = st?.phase === 'connected'
759
+ } else {
760
+ const prefix = `mcp__${serverName}__`
761
+ toolCount = toolNames.filter((n) => n.startsWith(prefix)).length
762
+ connected = !item.disabled && fiberPhase === 'active' && toolCount > 0
763
+ }
764
+ servers.push({
765
+ name: serverName,
766
+ source: item.source,
767
+ disabled: item.disabled,
768
+ transport: item.config.transport,
769
+ command: item.config.command ?? null,
770
+ args: item.config.args ?? null,
771
+ cwd: item.config.cwd ?? null,
772
+ url: item.config.url ?? null,
773
+ fiberPhase,
774
+ toolCount,
775
+ connected,
776
+ })
777
+ }
778
+ servers.sort((a, b) => a.name.localeCompare(b.name))
779
+ const files = {}
780
+ for (const [source, info] of Object.entries(fileStates)) {
781
+ files[source] = { path: info?.path ?? null, exists: info?.exists === true }
782
+ }
783
+ return { cwd: lastCwd, servers, files }
784
+ }
785
+
786
+ /** 热启停并持久化(名字只认当前 desired 集合里的 server)。 */
787
+ async function setMcpEnabled(serverName, enabled) {
788
+ const item = desired.get(String(serverName ?? ''))
789
+ if (item === undefined) return fail(`Unknown project MCP server '${serverName}'.`)
790
+ item.disabled = !enabled
791
+ const disabledList = [...desired.entries()].filter(([, it]) => it.disabled).map(([n]) => n)
792
+ await saveMcpDisabled(disabledList)
793
+ await reconcile()
794
+ return ok(enabled ? `MCP server '${serverName}' enabled.` : `MCP server '${serverName}' disabled.`)
795
+ }
796
+
797
+ // ---- connection RPC surface(官方推荐的 Client→Host 私有通道) ----
798
+
799
+ const endpoints = {
800
+ 'skills/list': { run: async () => listSkills() },
801
+ 'skills/link': { run: async (p) => linkAllSkills(String(p.sourceDirectory || '')) },
802
+ 'skills/unlink': { run: async (p) => unlinkSkill(String(p.name || '')) },
803
+ 'skills/delete': { run: async (p) => deleteSkill(String(p.name || '')) },
804
+ 'skills/disable': { run: async (p) => setSkillEnabled(String(p.name || ''), false) },
805
+ 'skills/enable': { run: async (p) => setSkillEnabled(String(p.name || ''), true) },
806
+ 'mcp/sync': { run: async (p) => sync(p.cwd, p.force === true) },
807
+ 'mcp/status': { run: async () => mcpStatus() },
808
+ 'mcp/setEnabled': { run: async (p) => setMcpEnabled(p.name, p.enabled === true) },
809
+ 'mcp/reload': {
810
+ run: async () => {
811
+ if (lastCwd === null) return ok('No project synced yet — nothing to reload.')
812
+ return sync(lastCwd, true)
813
+ },
814
+ },
815
+ }
816
+
817
+ ctx.connection.rpc.handle(RPC_CHANNEL, async (endpoint, payload) => {
818
+ try {
819
+ const action = endpoints[endpoint]
820
+ if (action === undefined) return rpcFail(`unknown endpoint "${endpoint}"`)
821
+ return rpcOk(await action.run(payload ?? {}))
822
+ } catch (e) {
823
+ return rpcFail(e?.message || String(e))
824
+ }
825
+ }, { authority: 'loopback' })
826
+
827
+ // 文件监听随插件卸载关闭。
828
+ ctx.effect(() => () => {
829
+ for (const [, entry] of watchers) entry.close()
830
+ watchers.clear()
831
+ })
832
+
833
+ console.log(`[dsh-skill-mcp] host ready: connection RPC channel '${RPC_CHANNEL}' (skills + project MCP)`)
834
+ }