@zhangchi835/skill-manager 1.0.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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * dsh-skills-hub — permanent host plugin.
3
+ *
4
+ * - Scans a configured folder recursively; EVERY .md file is a skill
5
+ * (no SKILL.md / kebab-case / frontmatter naming rules).
6
+ * - Registers a `skills-hub` provider on ctx.skills that exposes EXACTLY the
7
+ * skills the user selected in the composer picker (select-to-enable).
8
+ * - Persists { folder, selected } in ~/.dsh/skills-hub.json across restarts.
9
+ * - Serves JSON endpoints under /skills-hub for the web client:
10
+ * GET /skills-hub/state
11
+ * POST /skills-hub/set-folder { path }
12
+ * POST /skills-hub/set-selected { names: string[] }
13
+ * POST /skills-hub/reload
14
+ */
15
+ import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises'
16
+ import { join, resolve, dirname } from 'node:path'
17
+ import { homedir } from 'node:os'
18
+
19
+ const NAME = 'dsh-skills-hub'
20
+ const DEFAULT_FOLDER = join(homedir(), '.dsh', 'skills')
21
+ const CONFIG_PATH = join(homedir(), '.dsh', 'skills-hub.json')
22
+
23
+ export const inject = ['skills', 'webServer']
24
+
25
+ export function apply(ctx) {
26
+ // inject 硬依赖保证 apply 时这两个服务必已注册(修复启动并发竞态:
27
+ // 之前用 ctx.get() 判空,若本行先于服务挂载则静默空返回、贡献为零)
28
+ const skills = ctx.skills
29
+ const webServer = ctx.webServer
30
+
31
+ const config = { folder: DEFAULT_FOLDER, selected: [] }
32
+ const state = {
33
+ folderExists: false,
34
+ scanned: false,
35
+ scanning: false,
36
+ scanTime: null,
37
+ scanError: '',
38
+ skills: [],
39
+ otherSkills: [],
40
+ }
41
+ let generation = 0
42
+ let scanPromise = null
43
+ let invalidate = function () {}
44
+
45
+ async function loadConfig() {
46
+ try {
47
+ const parsed = JSON.parse(await readFile(CONFIG_PATH, 'utf8'))
48
+ if (typeof parsed.folder === 'string' && parsed.folder.trim() !== '') config.folder = parsed.folder.trim()
49
+ if (Array.isArray(parsed.selected)) config.selected = parsed.selected.filter((name) => typeof name === 'string')
50
+ } catch (_) {
51
+ // 首次运行或配置损坏:使用默认值
52
+ }
53
+ }
54
+ async function persistConfig() {
55
+ try {
56
+ await mkdir(dirname(CONFIG_PATH), { recursive: true })
57
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8')
58
+ } catch (error) {
59
+ ctx.logger.warn(`${NAME}: failed to persist config: ${String(error)}`)
60
+ }
61
+ }
62
+
63
+ // ---------- 无命名规则解析(与动态版验证过的逻辑一致) ----------
64
+ function kebab(value) {
65
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').replace(/-+$/, '')
66
+ }
67
+ function hashText(text) {
68
+ let h = 5381
69
+ for (let i = 0; i < text.length; i++) h = (((h << 5) + h) + text.charCodeAt(i)) >>> 0
70
+ return h.toString(36)
71
+ }
72
+ function firstLine(text) {
73
+ const match = /^\s*([^\n\r]*)/.exec(text === undefined || text === null ? '' : String(text))
74
+ return match === null ? '' : match[1].trim()
75
+ }
76
+ function parseFrontmatterFields(raw) {
77
+ const data = Object.create(null)
78
+ const lines = raw.split('\n')
79
+ for (let i = 0; i < lines.length; i++) {
80
+ const line = lines[i].replace(/\r$/, '')
81
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line)
82
+ if (match === null) continue
83
+ const key = match[1]
84
+ let value = match[2].trim()
85
+ const first = value.charAt(0)
86
+ const last = value.charAt(value.length - 1)
87
+ if ((first === '"' && last === '"' && value.length >= 2) || (first === "'" && last === "'" && value.length >= 2)) value = value.slice(1, -1)
88
+ else if (value === 'true' || value === 'false') value = value === 'true'
89
+ else if (/^-?\d+$/.test(value)) value = Number(value)
90
+ data[key] = value
91
+ }
92
+ return data
93
+ }
94
+ function parseFrontmatter(text) {
95
+ if (typeof text !== 'string' || text.length === 0) return null
96
+ const firstEnd = text.indexOf('\n')
97
+ if (firstEnd < 0) return null
98
+ if (text.slice(0, firstEnd).replace(/\r$/, '') !== '---') return null
99
+ let lineStart = firstEnd + 1
100
+ while (lineStart <= text.length) {
101
+ const nextNewline = text.indexOf('\n', lineStart)
102
+ const lineEnd = nextNewline < 0 ? text.length : nextNewline
103
+ if (text.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
104
+ const bodyStart = nextNewline < 0 ? text.length : nextNewline + 1
105
+ return { data: parseFrontmatterFields(text.slice(firstEnd + 1, lineStart)), body: text.slice(bodyStart) }
106
+ }
107
+ if (nextNewline < 0) return null
108
+ lineStart = nextNewline + 1
109
+ }
110
+ return null
111
+ }
112
+ function parseSkill(text, rel) {
113
+ const fm = parseFrontmatter(text)
114
+ const fileName = rel.replace(/^.*[\\/]/, '')
115
+ const stem = fileName.replace(/\.md$/i, '')
116
+ let name = null
117
+ let description = null
118
+ let whenToUse
119
+ let metadata
120
+ if (fm !== null) {
121
+ if (typeof fm.data.name === 'string' && fm.data.name.trim() !== '') name = fm.data.name.trim()
122
+ if (typeof fm.data.description === 'string' && fm.data.description.trim() !== '') description = fm.data.description.trim()
123
+ if (typeof fm.data['whenToUse'] === 'string' && fm.data['whenToUse'].trim() !== '') whenToUse = fm.data['whenToUse'].trim()
124
+ if (fm.data.metadata !== null && typeof fm.data.metadata === 'object' && !Array.isArray(fm.data.metadata)) metadata = fm.data.metadata
125
+ }
126
+ if (name === null) name = stem
127
+ let finalName = kebab(name)
128
+ if (finalName === '') finalName = 'skill-' + hashText(rel)
129
+ if (finalName.length > 64) finalName = finalName.slice(0, 64).replace(/-+$/, '')
130
+ const body = fm === null ? text : fm.body
131
+ if (description === null) {
132
+ description = firstLine(body)
133
+ if (description === '') description = 'Skill loaded from ' + rel
134
+ }
135
+ return { name: finalName, description, whenToUse, metadata, content: String(body).trim() }
136
+ }
137
+
138
+ // ---------- 递归扫描 ----------
139
+ async function walk(dir, rel, found, errors, depth, budget) {
140
+ if (depth > 24 || budget.count >= 4000) return
141
+ let entries
142
+ try {
143
+ entries = await readdir(dir, { withFileTypes: true })
144
+ } catch (error) {
145
+ errors.push(`listDir failed ${rel}: ${String(error)}`)
146
+ return
147
+ }
148
+ for (const entry of entries) {
149
+ if (budget.count >= 4000) break
150
+ budget.count++
151
+ const childRel = rel === '' ? entry.name : rel + '/' + entry.name
152
+ const full = join(dir, entry.name)
153
+ if (entry.isDirectory()) {
154
+ await walk(full, childRel, found, errors, depth + 1, budget)
155
+ } else if (entry.isFile() && /\.md$/i.test(entry.name)) {
156
+ let text
157
+ try {
158
+ text = await readFile(full, 'utf8')
159
+ } catch (error) {
160
+ errors.push(`read failed ${childRel}: ${String(error)}`)
161
+ continue
162
+ }
163
+ const parsed = parseSkill(text, childRel)
164
+ found.push({ name: parsed.name, description: parsed.description, whenToUse: parsed.whenToUse, rel: childRel, path: full, metadata: parsed.metadata })
165
+ }
166
+ }
167
+ }
168
+
169
+ async function collectOtherSkills() {
170
+ try {
171
+ const list = await skills.list({})
172
+ const out = []
173
+ for (const s of list) {
174
+ if (s === null || typeof s !== 'object' || s.provider === 'skills-hub') continue
175
+ out.push({
176
+ name: typeof s.name === 'string' ? s.name : '',
177
+ description: typeof s.description === 'string' ? s.description : '',
178
+ provider: typeof s.provider === 'string' ? s.provider : '',
179
+ source: typeof s.source === 'string' ? s.source : '',
180
+ })
181
+ }
182
+ return out
183
+ } catch (_) {
184
+ return []
185
+ }
186
+ }
187
+
188
+ async function scanNow() {
189
+ const gen = ++generation
190
+ state.scanning = true
191
+ state.scanError = ''
192
+ const errors = []
193
+ const found = []
194
+ try {
195
+ const root = resolve(config.folder)
196
+ let info
197
+ try {
198
+ info = await stat(root)
199
+ } catch (_) {
200
+ info = undefined
201
+ }
202
+ if (info === undefined) {
203
+ state.folderExists = false
204
+ errors.push(`folder not found: ${config.folder}`)
205
+ } else if (!info.isDirectory()) {
206
+ state.folderExists = false
207
+ errors.push(`not a directory: ${config.folder}`)
208
+ } else {
209
+ state.folderExists = true
210
+ await walk(root, '', found, errors, 0, { count: 0 })
211
+ }
212
+ } catch (error) {
213
+ errors.push(String(error))
214
+ }
215
+ if (gen !== generation) return
216
+ const seen = Object.create(null)
217
+ const out = []
218
+ for (const s of found) {
219
+ if (seen[s.name] === true) continue
220
+ seen[s.name] = true
221
+ out.push(s)
222
+ }
223
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
224
+ config.selected = config.selected.filter((name) => seen[name] === true)
225
+ state.skills = out
226
+ state.scanError = errors.join('; ')
227
+ state.scanned = true
228
+ state.scanning = false
229
+ state.scanTime = new Date().toISOString()
230
+ state.otherSkills = await collectOtherSkills()
231
+ try { invalidate() } catch (_) {}
232
+ ctx.logger.info(`[${NAME}] scan complete: ${out.length} skills, errors: ${errors.length === 0 ? 'none' : errors.join('; ')}`)
233
+ }
234
+
235
+ function scheduleScan() {
236
+ if (scanPromise !== null) return scanPromise
237
+ const promise = scanNow().catch((error) => {
238
+ state.scanError = String(error)
239
+ state.scanning = false
240
+ ctx.logger.error(`[${NAME}] scan failed: ${String(error)}`)
241
+ }).finally(() => {
242
+ scanPromise = null
243
+ })
244
+ scanPromise = promise
245
+ return promise
246
+ }
247
+
248
+ // ---------- skills 提供者:只暴露已勾选技能 ----------
249
+ ctx.effect(() => skills.registerProvider((control) => {
250
+ invalidate = () => {
251
+ try { control.invalidate() } catch (_) {}
252
+ }
253
+ return {
254
+ name: 'skills-hub',
255
+ async list(options) {
256
+ const signal = options === undefined ? undefined : options.signal
257
+ const selectedSet = Object.create(null)
258
+ for (const name of config.selected) selectedSet[name] = true
259
+ const out = []
260
+ for (const s of state.skills) {
261
+ if (signal !== undefined && signal.aborted) break
262
+ if (selectedSet[s.name] !== true) continue
263
+ const candidate = {
264
+ name: s.name,
265
+ description: s.description,
266
+ invocation: { modelInvocable: true, userInvocable: true },
267
+ source: 'custom',
268
+ provider: 'skills-hub',
269
+ rank: 350,
270
+ locator: { path: s.path, rel: s.rel },
271
+ resourceBase: { kind: 'directory', path: config.folder },
272
+ path: s.path,
273
+ }
274
+ if (s.whenToUse !== undefined) candidate.whenToUse = s.whenToUse
275
+ out.push(candidate)
276
+ }
277
+ return out
278
+ },
279
+ async get(candidate, options) {
280
+ const signal = options === undefined ? undefined : options.signal
281
+ const loc = candidate === null || candidate === undefined ? null : candidate.locator
282
+ const path = loc === null || loc === undefined ? undefined : loc.path
283
+ if (typeof path !== 'string' || path === '') return undefined
284
+ if (signal !== undefined && signal.aborted) return undefined
285
+ let text
286
+ try {
287
+ text = await readFile(path, 'utf8')
288
+ } catch (_) {
289
+ return undefined
290
+ }
291
+ if (signal !== undefined && signal.aborted) return undefined
292
+ const rel = loc === null || loc === undefined ? '' : loc.rel
293
+ const parsed = parseSkill(text, typeof rel === 'string' ? rel : '')
294
+ const definition = {
295
+ name: candidate.name,
296
+ description: parsed.description,
297
+ invocation: { modelInvocable: true, userInvocable: true },
298
+ source: 'custom',
299
+ provider: 'skills-hub',
300
+ resourceBase: { kind: 'directory', path: config.folder },
301
+ path,
302
+ content: parsed.content,
303
+ }
304
+ if (parsed.whenToUse !== undefined) definition.whenToUse = parsed.whenToUse
305
+ if (parsed.metadata !== undefined) definition.metadata = parsed.metadata
306
+ return definition
307
+ },
308
+ }
309
+ }), `${NAME} provider`)
310
+
311
+ // ---------- Client JSON 视图 ----------
312
+ function buildView() {
313
+ const set = Object.create(null)
314
+ for (const name of config.selected) set[name] = true
315
+ const skillRows = []
316
+ for (const s of state.skills) {
317
+ skillRows.push({
318
+ name: s.name,
319
+ description: s.description,
320
+ whenToUse: s.whenToUse === undefined ? null : s.whenToUse,
321
+ rel: s.rel,
322
+ path: s.path,
323
+ selected: set[s.name] === true,
324
+ })
325
+ }
326
+ return {
327
+ folder: config.folder,
328
+ folderExists: state.folderExists,
329
+ scanned: state.scanned,
330
+ scanning: state.scanning,
331
+ scanTime: state.scanTime,
332
+ scanError: state.scanError,
333
+ skills: skillRows,
334
+ selected: config.selected.slice(),
335
+ otherSkills: state.otherSkills.slice(),
336
+ }
337
+ }
338
+
339
+ // ---------- Web 路由 ----------
340
+ if (webServer !== undefined) {
341
+ const sendJson = (res, code, value) => {
342
+ const body = JSON.stringify(value)
343
+ res.writeHead(code, {
344
+ 'Content-Type': 'application/json; charset=utf-8',
345
+ 'Content-Length': Buffer.byteLength(body),
346
+ 'Cache-Control': 'no-store',
347
+ })
348
+ res.end(body)
349
+ }
350
+ const readJsonBody = (req) => new Promise((resolveBody) => {
351
+ const chunks = []
352
+ let size = 0
353
+ req.on('data', (chunk) => {
354
+ size += chunk.length
355
+ if (size <= 1024 * 1024) chunks.push(chunk)
356
+ })
357
+ req.on('end', () => {
358
+ const raw = Buffer.concat(chunks).toString('utf8')
359
+ try {
360
+ resolveBody(raw === '' ? {} : JSON.parse(raw))
361
+ } catch (_) {
362
+ resolveBody({})
363
+ }
364
+ })
365
+ req.on('error', () => resolveBody({}))
366
+ })
367
+
368
+ ctx.effect(() => webServer.register({
369
+ kind: 'prefix',
370
+ path: '/skills-hub',
371
+ handler: async (req, res) => {
372
+ try {
373
+ const pathname = new URL(req.url, 'http://localhost').pathname.replace(/\/+$/, '')
374
+ if (req.method === 'GET' && pathname === '/skills-hub/state') {
375
+ return sendJson(res, 200, await buildView())
376
+ }
377
+ if (req.method === 'POST' && pathname === '/skills-hub/set-folder') {
378
+ const body = await readJsonBody(req)
379
+ const folder = typeof body.path === 'string' ? body.path.trim() : ''
380
+ if (folder !== '') {
381
+ config.folder = folder
382
+ await persistConfig()
383
+ }
384
+ await scheduleScan()
385
+ return sendJson(res, 200, await buildView())
386
+ }
387
+ if (req.method === 'POST' && pathname === '/skills-hub/set-selected') {
388
+ const body = await readJsonBody(req)
389
+ const names = Array.isArray(body.names) ? body.names.filter((name) => typeof name === 'string') : []
390
+ const known = Object.create(null)
391
+ for (const s of state.skills) known[s.name] = true
392
+ const next = []
393
+ for (const name of names) {
394
+ if (known[name] === true && !next.includes(name)) next.push(name)
395
+ }
396
+ config.selected = next
397
+ try { invalidate() } catch (_) {}
398
+ await persistConfig()
399
+ return sendJson(res, 200, await buildView())
400
+ }
401
+ if (req.method === 'POST' && pathname === '/skills-hub/reload') {
402
+ await scheduleScan()
403
+ return sendJson(res, 200, await buildView())
404
+ }
405
+ sendJson(res, 404, { error: 'not found' })
406
+ } catch (error) {
407
+ ctx.logger.warn(`[${NAME}] route error: ${String(error)}`)
408
+ try {
409
+ sendJson(res, 500, { error: String(error) })
410
+ } catch (_) {}
411
+ }
412
+ },
413
+ }), `${NAME} web route`)
414
+ }
415
+
416
+ // ---------- 启动 ----------
417
+ void (async () => {
418
+ await loadConfig()
419
+ await scheduleScan()
420
+ })()
421
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "dsh-skills-hub",
3
+ "version": "1.0.0",
4
+ "description": "DSH skills loader & manager bundle: no-naming-rules recursive folder scanning, composer skill picker (select-to-enable), and a skills admin page without enable/disable toggles.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "inject": [],
18
+ "platform": "web"
19
+ }
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "cordis.patch.yml"
24
+ ],
25
+ "license": "MIT"
26
+ }
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: cordis-skills-manager-plugin
3
+ description: 完整记录 DeepSeek Harness「技能加载与管理」插件(skills-hub)的需求、创建过程、动态版与永久版两套架构、实测验证结果、自查清单与两种安装方式(含 profile 依赖+用户补丁层的正确姿势与踩坑),用于复刻、维护或二次开发。
4
+ whenToUse: 需要复刻、维护该插件,重新安装(动态或永久),或排查"重启后插件消失"类问题时使用。
5
+ ---
6
+
7
+ # DeepSeek Harness 技能加载与管理插件(skills-hub)创建与安装指南
8
+
9
+ > 本插件有两个交付形态,代码逻辑相同:
10
+ > - **动态版**(Cordis 动态插件,进程内临时):`code.host` + `code.client` 函数体,经 `cordis_define`/`cordis_run` 安装。
11
+ > - **永久版**(profile 依赖 + 用户补丁层,重启不消失):npm 包 `dsh-skills-hub` 经 `pnpm add link:` 装入 `~/.dsh/profiles/web` 的 `dependencies`,插件行写在 `cordis.patch.yml` 用户补丁层;源码位于 `<workspace>/plugin/`。
12
+
13
+ ## 一、功能需求
14
+
15
+ 1. **加载 skills**:从用户配置的文件夹递归加载技能。
16
+ 2. **无命名规则**:任意子目录、任意文件名(含中文)、有无 YAML frontmatter 的 `.md` 文件都能被加载(修复官方 filesystem 提供者只认 `SKILL.md`、kebab-case 名称、强制 frontmatter 的限制)。
17
+ 3. **管理所有 skills**:设置页提供「技能管理」面板:配置文件夹、重新扫描、查看全部技能与描述,**不提供启用/停用开关**。
18
+ 4. **会话技能选择**:在输入框工具行的「模式」(访问模式/权限选择器)之后添加「技能」勾选器;勾选哪个技能就只启用哪个(精确启用,既不是默认全启用,也不是全部不启用)。未勾选的技能不出现在 `/技能` 菜单和模型可见的 `<available_skills>` 目录中。
19
+ 5. **安装自查面板(仅动态版)**:在 `cordis_run` 卡片中渲染状态面板(文件夹、扫描结果、已选技能、错误信息);永久版没有 cordis_run 卡片,状态经 `/skills-hub/state` 路由获取。
20
+
21
+ ## 二、运行机制调研(创建过程第 1 步)
22
+
23
+ 用 `cordis_inspect_list` / `cordis_inspect_query` 查询真实契约,并阅读 DSH 源码(`node_modules/@deepseek-ai/dsh-*`)确认:
24
+
25
+ - **Host `skills` 服务**:分层技能注册表。`registerProvider(create)` 注册提供者(返回取消注册函数);`list(options)` 返回摘要;`get(name, options)` 按名加载;提供者的 `control.invalidate()` 使缓存失效并通知消费者;`skills/change` 事件广播变更。
26
+ - **`dsh-tool-skill`**:模型侧 `skill` 工具 + 会话 `<available_skills>` 目录。每个 `agent/pre-step` 都会调用 `ctx.skills.snapshot({cwd, scope: agent})`,只保留 `invocation.modelInvocable === true` 的技能。因此**只要提供者只列出被勾选的技能,模型目录就会精确反映选择**。
27
+ - **`dsh-skill-filesystem`** 的命名规则限制(bug 根源):目录技能必须叫 `SKILL.md`;名称必须是 kebab-case(`isSkillName`);必须有 name+description frontmatter。本项目提供者绕过全部限制。
28
+ - **UI 插槽**(`Slots.listSubTree`):
29
+ - `conversation.input.left`:输入框工具行左端,位于访问模式(模式选择器)、计划、附件等常驻控件**之后** —— 技能选择按钮挂这里。
30
+ - `conversation.input.overlay`:输入条浮动浮层锚点(list,浮层卡片用 `position:absolute; bottom:calc(100%+4px)` 向上弹出)。
31
+ - `settings.section`:设置页分区(list,注册 `{id, order, label}`)。
32
+ - `tool.view.cordis`:`cordis_run` 卡片内面板,唯一允许 key 为 `'self'`。
33
+ - **动态插件运行环境**:Host 只有 `ctx/harness/console` 等内置符号,**没有** `import`/`require`/`process`/Node 全局 —— 文件读写必须走 `fs` 服务(`resolve`/`stat`/`listDir`/`readText`);Client 只有 `ctx/React/host/styles/console`。
34
+
35
+ ## 三、架构设计(创建过程第 2 步)
36
+
37
+ 两版共享同一套解析/扫描/选择语义(`parseSkill` 无命名规则解析、递归扫描、提供者只暴露已勾选技能、`setSelected` 校验裁剪),差异只在运行环境与通信方式:
38
+
39
+ **动态版 Host 半**(`code.host`,返回 `{ apply(ctx) {...} }`):
40
+
41
+ 1. `ctx.get('skills')`、`ctx.get('fs')`、`ctx.get('sandboxPolicy')`(取 `workspaceRoot` 作默认技能文件夹 `<workspace>/skills`)。
42
+ 2. 内存状态:`folder`、`skills`(扫描结果)、`selected`(勾选名称)、`scanError` 等。
43
+ 3. `scanNow()`:`fs.resolve(folder)` → `stat` 判断目录 → 递归 `listDir`(深度/数量上限),每个 `.md` 文件解析:
44
+ - frontmatter(手写极简解析器,只处理标量字段)有 `name` 用之,否则用文件名;
45
+ - `kebab()` 清洗(小写、非 `[a-z0-9]` 转 `-`),空结果回退为 `skill-` + 路径哈希 —— 中文名也能加载;
46
+ - 无 frontmatter 时描述取正文首行。
47
+ 4. `ctx.effect(() => skills.registerProvider(control => ({name:'skills-hub', list, get})), 'skills-hub provider')`:
48
+ - `list()` **只返回 `selected` 集合内的技能**,`invocation: {modelInvocable:true, userInvocable:true}`,`source:'custom'`,`rank:350`,`locator:{path,rel}`,**不要**在 `list()` 里等待扫描 Promise(会与扫描内部的 `skills.list()` 死锁,见自查清单);
49
+ - `get(candidate)` 用 `fs.resolve/readText` 重新读文件并解析出 `content`。
50
+ 5. RPC(`harness.handle` 注册进 `ctx.effect`):`state`、`setFolder({path})`、`setSelected({names})`、`reload`;每次变更后 `control.invalidate()`,`setSelected` 只保留扫描结果中存在的名称。
51
+ 6. `apply` 末尾 `scheduleScan()` 做首次扫描。
52
+
53
+ **动态版 Client 半**(`code.client`):
54
+
55
+ 1. 模块级小 store(view + open + listeners)连接多个插槽组件;`host.call('state')` 拉取状态。
56
+ 2. `slots.inject('conversation.input.left', () => slots.register({name, id:'skills-hub-trigger', order:0}, Trigger))`:显示「技能 N」小按钮(N=已勾选数)。
57
+ 3. `slots.inject('conversation.input.overlay', () => slots.register({name, id:'skills-hub-popup', order:2}, Popup))`:关闭时返回 `null`;打开时渲染透明全屏 backdrop(点击关闭)+ 向上弹出的勾选卡片(纯 React 实现,不用 document)。
58
+ 4. `slots.inject('settings.section', () => slots.register({name, id:'skills-hub', order:25, label:'技能管理'}, Admin))`:文件夹输入 + 保存并加载 + 重新扫描 + 技能列表(**只有查看徽标,没有启用/停用开关**)+ 其他提供者技能只读列表。
59
+ 5. `slots.inject('tool.view.cordis', () => slots.register({name, key:'self'}, Panel))`:安装后立刻可见的自查面板。
60
+ 6. `ctx.effect(() => styles.insert(css))` 管理自有样式(用 `--dsw-alias-*` 主题变量)。
61
+
62
+ **永久版差异**(源码在 `<workspace>/plugin/`,与动态版逐项对应):
63
+
64
+ | 方面 | 动态版 | 永久版 |
65
+ | --- | --- | --- |
66
+ | 代码形态 | Guard 沙箱内函数体(无 import/require) | 真实 ESM(`lib/index.js`,`export {apply, inject}`) |
67
+ | 文件读写 | `fs` 服务(受会话沙箱) | `node:fs/promises` 直接读写 |
68
+ | Client↔Host 通信 | Package 私有 `host.call` | `webServer.register({kind:'prefix', path:'/skills-hub', handler})` JSON 路由 + 浏览器 `fetch` |
69
+ | 状态持久化 | 无(进程内存) | `~/.dsh/skills-hub.json`(folder + selected) |
70
+ | 默认文件夹 | `<workspaceRoot>/skills` | `~/.dsh/skills`(已预配置为工作区 skills 文件夹) |
71
+ | 客户端样式注入 | `styles.insert(css)` | `document.createElement('style')`(`window.__ModuleLoader__.load` 格式,`require('react')`) |
72
+ | Run 卡片面板 | 有(`tool.view.cordis` key self) | 无 |
73
+ | 依赖 | 无 | **只用 node: 内置模块**(避免 profile 解析不到第三方包) |
74
+
75
+ ## 四、创建过程(实际时间线)
76
+
77
+ 1. **调研**:`cordis_inspect_list/query` 读 `skills`/`fs`/`webServer` 服务契约与插槽树,并阅读 shipped 包源码(`dsh-skill`、`dsh-skill-filesystem`、`dsh-tool-skill`、`dsh-client-ui-skill` 等)确认目录生成机制与命名规则限制。
78
+ 2. **动态版 v1**:`cordis_define`(new,idPrefix `skhub`)→ 自查(见下节)→ `cordis_run` → 用户在 Run 卡片批准 → `running`,四处 UI 实时挂载确认。
79
+ 3. **重启丢失事件**:DSH 进程重启后动态插件整体消失(定义/版本/运行态均在进程内存)。→ 结论:动态插件是进程内临时扩展;要持久必须走 profile 依赖 + 用户补丁层。
80
+ 4. **临时恢复**:同代码重新 `cordis_define`(新进程内同 idPrefix 得到同名 `skhub-1`)+ `cordis_run` + 批准。
81
+ 5. **永久版打包**:按「方式 B」把插件源码改写为真实 ESM 包(`<workspace>/plugin/`,只用 node: 内置依赖),`pnpm add link:` 装进 web profile 的 `dependencies`,插件行写进用户补丁层 `cordis.patch.yml`(**不放进 `dsh.profile.bundles`**——实测 bundles 路径行不通),预检 + 端到端验证通过,重启后接管。
82
+ 6. 每个 Package 不可变;改代码 = 追加新 Package(`kind:'existing'`),`mode:'update'` 切换,`mode:'run'` 回滚。
83
+
84
+ 动态版日常操作速查:`cordis_define`(new/existing)→ `cordis_run`(首次 run / 换版本 update)。返回 `awaiting-approval` 表示等待 UI 批准;返回 `starting` 表示进入异步流程,等系统通知最终结果。诊断用 `cordis_inspect_self(pluginId, packageId)`。
85
+
86
+ ## 五、安装前自查(实际做法与结果)
87
+
88
+ **动态版(18/18 通过)**——`<workspace>/_verify/host-test.js`:
89
+
90
+ 1. `cordis_define` 记录源码后,把 `code.host` / `code.client` 的**函数体原文**复制到本地 `_verify/host-body.js` 与 `_verify/client-body.js`。
91
+ 2. `new Function(body)` 做语法编译检查(Host/Client 都过)。
92
+ 3. 用 mock `fs`(映射到真实 `<workspace>/skills` 文件夹)、mock `skills`、mock `harness` 运行 Host 逻辑,断言:
93
+ - 中文文件名、无 frontmatter 的 `.md` 能加载(回退名 `skill-<hash>`,符合 kebab-case);
94
+ - 未勾选时提供者暴露 0 个技能;勾选 1 个只暴露 1 个;全选全暴露;清空全部停用(选择即启用的精确语义);
95
+ - `get()` 返回完整 `content` 与正确 `resourceBase`;
96
+ - 未知名称被裁剪;文件夹不存在只报错不崩溃;重新扫描生效;变更触发 `invalidate`。
97
+ 4. 全部通过后再 `cordis_run` 安装;安装后用 `cordis_inspect_self(pluginId, packageId)` 读运行时诊断复核。
98
+
99
+ **永久版(17/17 通过)**——在 profile 目录内临时脚本(验证后已清理):
100
+
101
+ 1. `node --check` 编译检查 `plugin/lib/index.js`、`plugin/lib/client.js`。
102
+ 2. 组合预检:`import { loadProfile, composeEntries } from '@deepseek-ai/dsh-app-boot'`,`loadProfile('dsh','web',<dsh 安装锚点 package.json>)` + `composeEntries` —— 确认 `skills-hub` 行进入组合条目(实测 142 条中命中)。
103
+ 3. 端到端:mock `ctx`(skills/webServer/effect/logger)直接 `import` 真实 `plugin/lib/index.js`,用 mock req/res 调用四条路由断言:boot 扫描、set-folder 扫描真实文件夹、中文文件哈希名、选择即启用语义、`get()` 完整内容、配置持久化到 `~/.dsh/skills-hub.json`、invalidate 触发。
104
+
105
+ ## 六、安装方式
106
+
107
+ ### 方式 A:动态安装(临时,进程内)
108
+
109
+ 1. 进入 DSH Web(本插件宿主进程),确认 `skills` 与 `fs` 服务存在(`cordis_inspect_list` → `Service.listService`)。
110
+ 2. `cordis_define`(new,idPrefix 如 `skhub`)→ 记录 pluginId/packageId。
111
+ 3. `cordis_run`(mode run)→ 批准(如需要)→ 等待 `starting` → 系统通知成功。
112
+ 4. 验证:`cordis_run` 卡片出现自查面板;设置 → 技能管理 可配置文件夹;输入框工具行「模式」选择器旁出现「技能」按钮。
113
+ 5. 更新版本:`cordis_define`(existing)→ `cordis_run`(mode update)。回滚:对 `currentPackageId` 用 `mode:'run'`。
114
+ 6. 临时停用 `cordis_stop`(保留版本与授权);永久删除 `cordis_undefine`。
115
+
116
+ **注意:动态插件只在当前 DSH 进程内存中,重启进程即消失(设计如此),需要持久请用方式 B。**
117
+
118
+ ### 方式 B:永久安装(profile 依赖 + 用户补丁层,重启不消失)——已验证实例
119
+
120
+ 原理(**经实践验证的正确姿势**):把插件包用 `pnpm add link:` 装进 `~/.dsh/profiles/<name>/` 的 **`dependencies`**(使包在 profile 的 `node_modules` 中可解析),再把**插件行写进 profile 的用户补丁层 `cordis.patch.yml`**(DSH 启动时该层在全部 bundle 层之后应用,行随组合永久挂载)。**不要**把包名追加进 `dsh.profile.bundles`——bundle 层是给自带组合补丁的官方 bundle 包用的,用户自装插件放进 bundles 实测行不通(依赖解析/挂载均不生效),`dependencies + 用户补丁行` 才是稳定路径。客户端半无需任何清单条目:`dsh-client-modules` 扫描 profile 依赖里的 `dsh.client` 声明自动加入 wire 图。
121
+
122
+ 实际完成的安装记录(可直接对照检查):
123
+
124
+ 1. 插件包目录:`C:\Users\ZC\Desktop\dsh-skills\plugin\`(包名 `dsh-skills-hub`):
125
+ - `package.json`:`"type":"module"`、`"main":"lib/index.js"`、`exports`(含 `./client`)、`"dsh":{"client":{"inject":[],"platform":"web"}}`(`"dsh":{"bundle":{...}}` 可选保留——只有被列为 bundle 时才会被读取,本方式用不到)。
126
+ - `lib/index.js`:Host 插件(真实 ESM,只用 `node:fs/promises`/`node:path`/`node:os`;**不要依赖第三方包**以免 profile 解析不到);**`export const inject = ['skills', 'webServer']` 硬依赖**(修复启动并发竞态);`ctx.skills.registerProvider` 注册提供者;`ctx.webServer.register({kind:'prefix', path:'/skills-hub', handler(req,res)})` 提供 JSON 路由(GET `/state`,POST `/set-folder` `/set-selected` `/reload`);配置持久化到 `~/.dsh/skills-hub.json`(JSON 结构:`{folder, selected}`)。
127
+ - `lib/client.js`:`window.__ModuleLoader__.load({ id, factory(require){...} })` 格式(`require('react')` 可用),`export { apply, inject }`;插槽注册与动态版相同(`ctx.slots.inject(...)`),**不注册 `tool.view.cordis`**(永久版没有 cordis_run 卡片)。
128
+ 2. 安装到 dependencies:`cd ~/.dsh/profiles/web && pnpm add "link:C:/Users/ZC/Desktop/dsh-skills/plugin"`(**必须用 `link:` 协议**——软链指向源目录,改源文件即时生效;`file:` 协议会把源码**复制**进 profile,之后改源文件不会同步,导致"改了代码却不生效/识别不了技能"的疑难杂症。若已用 file: 安装,先 `pnpm remove` 再 `link:` 重装,或每次改完重跑 `pnpm add`)。
129
+ 3. **插件行写进用户补丁层** `~/.dsh/profiles/web/cordis.patch.yml`(**不是 bundles**):
130
+ ```yaml
131
+ - insert:
132
+ - id: skills-hub
133
+ name: 'dsh-skills-hub'
134
+ inject:
135
+ - skills
136
+ - webServer
137
+ ```
138
+ `name` 指向依赖包名,Loader 从 profile 的 `node_modules` 解析并挂载其 `lib/index.js` 导出的 `apply/inject`;`id` 全局唯一;行级 `inject` 与模块内 `inject` 双保险。
139
+ 4. 预检(重启前):临时脚本 `import { loadProfile, composeEntries } from '@deepseek-ai/dsh-app-boot'` → `loadProfile('dsh','web','C:/Users/ZC/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/package.json')` → `composeEntries([...各 bundle 层 patches, ...用户补丁层])`,确认含 `{id:'skills-hub', name:'dsh-skills-hub'}` 条目。
140
+ 5. 重启 DSH 进程 → 插件自动挂载(设置/勾选状态从 `~/.dsh/skills-hub.json` 恢复)。重启后动态版消失属预期,永久版接替同一套 UI。验证:`curl http://127.0.0.1:<端口>/skills-hub/state` 返回 JSON。
141
+
142
+ ## 七、自查清单(bug 检查)
143
+
144
+ - [ ] 中文/任意文件名、任意子目录、无 frontmatter 的 `.md` 均能加载(`kebab()` 空回退哈希名)
145
+ - [ ] `list()` 中**不得** await 扫描 Promise(否则 `scanNow → skills.list → 本提供者 list → await 自身` 死锁)
146
+ - [ ] 提供者只在 `selected` 中列出技能;`setSelected` 校验并裁剪未知名称
147
+ - [ ] RPC/路由返回值只含标量/纯 JSON(不传 Service、FsTarget、Agent、req/res 等活对象)
148
+ - [ ] 所有副作用进 Fiber:`ctx.effect(() => registerProvider(...))`、`ctx.effect(() => harness.handle(...))`、`ctx.effect(() => webServer.register(...))`、`ctx.effect(() => styles.insert(css))`
149
+ - [ ] 扫描异常不抛到提供者外:错误写入 `scanError` 供 UI 展示
150
+ - [ ] 文件夹不存在/不是目录/fs 服务缺失 → 状态提示而非崩溃
151
+ - [ ] 重名技能按名去重;扫描后裁剪已不存在的勾选
152
+ - [ ] (永久版)插件包源码只用 node: 内置 import,`node --check` 通过
153
+ - [ ] (永久版)**Host 行必须 `inject: ['skills','webServer']` 硬依赖**——Loader 并发挂载各行,若用 `ctx.get()` 判空,行可能先于服务 apply 而静默空返回(fiber 显示已激活、无报错、贡献为零,boot 审计查不出),表现为"插件装了但识别不了任何技能、路由 404"
154
+ - [ ] (永久版)安装前跑 `loadProfile`+`composeEntries` 预检,确认插件行进入组合
155
+ - [ ] (永久版)依赖用 `link:` 软链安装;改源文件后无需重装
156
+ - [ ] (永久版)配置写入 `~/.dsh/skills-hub.json` 带异常兜底,损坏时回退默认值
157
+ - [ ] (永久版)客户端不注册 `tool.view.cordis`;CSS 用 `document.createElement('style')` 且带重复安装保护
158
+ - [ ] (永久版)用真实 Loader + 真实 SkillRegistry/WebServer 在本地**并发**挂载复现(`_repro-race` 模式):`root.baseUrl = 'file:///…/profiles/web/'`、`await root.plugin(Loader)`(无 config)、服务与行同时 `Promise.all` 挂载、随后请求 `/skills-hub/state` 断言 JSON
159
+
160
+ ## 八、常见问题
161
+
162
+ - **重启后插件消失**:动态插件只在当前 DSH 进程内存中,重启即清空(设计如此)。临时用:重新 `cordis_define`+`cordis_run`;持久用:按「方式 B」装 profile 依赖 + 用户补丁层行(本插件已装好 `dsh-skills-hub`)。
163
+ - **插件包加到 `dsh.profile.bundles` 行不通**(实践结论):用户自装插件应放进 `dependencies`(`pnpm add link:`)+ 插件行写入 `cordis.patch.yml` 用户补丁层;`bundles` 列表只装官方 bundle 包(自带 `dsh.bundle.patch` 组合补丁的那些),把本地插件包名加进 bundles 实测不生效。
164
+ - **勾选后模型目录没变**:`tool-skill` 在每个 `agent/pre-step` 用 `skills.snapshot()` 重建目录,下一次回合即生效(提供者 `invalidate()` 已清缓存)。
165
+ - **`/技能` 斜杠菜单仍旧**:shipped `dsh-client-ui-skill` 按会话缓存目录,仅在切换预设或重连时失效;勾选器与模型目录始终是权威的。
166
+ - **Host 报 `service "x" is not declared`**:未声明 `inject` 却用 `ctx.x`;改用 `ctx.get('x')` 并判空。
167
+ - **永久版重启后如何确认生效**:设置 → 技能管理 出现本插件页面;`~/.dsh/skills-hub.json` 存在且 `folder` 指向配置的技能文件夹;`curl http://127.0.0.1:<端口>/skills-hub/state` 返回 JSON。
168
+ - **"识别不了文件中的 skill"排查顺序**:① `curl /skills-hub/state` 是否返回 JSON(返回 HTML = Host 行未生效);② Host 行未生效查三件事——依赖是否用 `link:` 软链(`file:` 复制导致旧代码)、行是否写在用户补丁层 `cordis.patch.yml`(放进 `dsh.profile.bundles` 不生效)、行是否声明了 `inject` 硬依赖(并发挂载竞态导致空挂载);③ 路由正常但仍无技能 → 检查 `~/.dsh/skills-hub.json` 的 `folder` 与 `selected`(勾选即启用,未勾选不出现在模型目录)。
169
+ - **HMR 用户补丁层热重载**:`profiles/<name>/cordis.patch.yml` 由 Cordis HMR 监听、变更后整树重组;但实测在本部署下对已挂载行的重挂载不可靠,改行配置后仍以**重启进程**为准。
170
+ - **动态版与永久版并存**:进程内同时只有动态版在跑时,两版 UI 相同但数据互不相通(动态版内存态 vs 永久版 json 持久态);重启后仅永久版存活,建议重启前在永久版界面确认勾选,或直接复用同一配置文件路径。