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/README.md +140 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +537 -0
- package/lib/index.js +834 -0
- package/package.json +57 -0
- package/scripts/link-skills.mjs +43 -0
- package/scripts/link-skills.ps1 +20 -0
- package/scripts/link-skills.sh +32 -0
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
|
+
}
|