@zhangchi835/skill-manager 1.0.3 → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhangchi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -0,0 +1,82 @@
1
+ # @zhangchi835/skill-manager
2
+
3
+ DeepSeek Harness(DSH)技能加载与管理插件:从任意文件夹**无命名规则**地递归加载技能(中文文件名、无 frontmatter、任意子目录均可),支持**目录型技能**与**任意位置的单个技能文件**,在输入框「模式」选择器旁提供**勾选即启用**的会话技能选择器,并附带只做配置与展示的「技能管理」设置页(无启用/停用开关)。
4
+
5
+ 要求 Node.js >= 18;零运行时依赖,只用 `node:` 内置模块。
6
+
7
+ ## 功能
8
+
9
+ - **递归加载**:配置一个文件夹(默认 `~/.dsh/skills`),其中所有 `.md` 都被识别为技能;不要求 `SKILL.md`、kebab-case 或 YAML frontmatter(名称自动清洗为 kebab-case,上限 64 字符;中文名等无 ASCII 字符的文件名回退为哈希名)。扫描上限:递归深度 24 层、最多 4000 个目录项。
10
+ - **目录型技能**:任意子目录内出现 `SKILL.md`(大小写不敏感)时,整个目录算一个技能,目录内其他 `.md`/资源不再单独注册;根文件夹本身不受此规则影响。
11
+ - **单个技能文件**:在管理页按绝对路径逐个添加 `.md`(可位于任意位置);与文件夹技能重名时以文件为准。
12
+ - **选择即启用**:勾选哪个技能就只启用哪个——未勾选的技能不出现在模型 `<available_skills>` 目录与 `/技能` 菜单中。
13
+ - **技能管理页**:设置 → 技能管理,可配置文件夹、增删单个技能文件、重新扫描、查看全部技能与选中状态(**无启用/停用开关**,启用只通过输入框旁的勾选器),并列出会话中其他提供者的技能。
14
+ - **持久化**:文件夹、文件清单与勾选状态保存在 `~/.dsh/skill-manager.json`(`{ folder, selected, files }`;首次运行自动迁移旧版 `~/.dsh/skills-hub.json`),重启自动恢复。
15
+
16
+ ## 安装(一条命令)
17
+
18
+ ```bash
19
+ dsh plugin --profile web add @zhangchi835/skill-manager
20
+ ```
21
+
22
+ 该命令会:① 把包安装进 `~/.dsh/profiles/web` 的 dependencies;② 因本包声明了 `dsh.bundle.patch`,自动把包名 reconcile 进 `dsh.profile.bundles`,插件行随之进入组合。重启 DSH 进程后生效。
23
+
24
+ > 如果你之前装过 npm 上的 1.0.0 / 1.0.2 / 1.0.3,那三个版本是仓库根目录误发的空壳(缺 `dsh` 元数据,装上也加载不了)。升级到 **1.1.0** 即可。
25
+ >
26
+ > 手动安装(不使用 `dsh plugin` CLI 时):`cd ~/.dsh/profiles/web && pnpm add @zhangchi835/skill-manager`,然后把下行写进 `~/.dsh/profiles/web/cordis.patch.yml`(用户补丁层):
27
+ >
28
+ > ```yaml
29
+ > - insert:
30
+ > - id: skill-manager
31
+ > name: '@zhangchi835/skill-manager'
32
+ > inject: [skills, webServer]
33
+ > ```
34
+
35
+ ## 验证
36
+
37
+ ```bash
38
+ curl http://127.0.0.1:<DSH Web 端口>/skill-manager/state # 返回 JSON 即成功
39
+ ```
40
+
41
+ 其余路由:`POST /skill-manager/set-folder`、`POST /skill-manager/set-selected`、`POST /skill-manager/set-files`、`POST /skill-manager/reload`(均为 JSON)。
42
+
43
+ ## 使用
44
+
45
+ 1. 设置 → 技能管理:填写技能文件夹路径(默认 `~/.dsh/skills`),保存并加载。
46
+ 2. 同一页可按绝对路径逐个添加单个 `.md` 技能文件(可选)。
47
+ 3. 任一会话输入框工具行、「模式」选择器旁,点「🧩 技能」勾选要启用的技能。
48
+ 4. 勾选后下一回合模型即可通过 `skill` 工具加载对应技能。
49
+
50
+ ## 发布(维护者)
51
+
52
+ **在本包目录(`plugin/`)内发布**——仓库根目录是 `private` 工程壳,不是发布单元:
53
+
54
+ ```bash
55
+ cd plugin
56
+ npm login # 首次
57
+ npm publish # publishConfig.access = public
58
+ npm pack --dry-run --json # 发布前核对文件清单
59
+ ```
60
+
61
+ - 清单应只有 `package.json`、`README.md`、`LICENSE`、`cordis.patch.yml`、`lib/index.js`、`lib/client.js`。
62
+ - `lib/index.js` 与 `cordis.patch.yml` 中的 `inject: [skills, webServer]` 是**必须**的(修复启动并发竞态导致的行空挂载),发布前请勿移除。
63
+ - `package.json` 的 `dsh`、`exports`、`files`、`publishConfig`、`type`、`main` 都是承重字段。
64
+
65
+ ## 本地开发
66
+
67
+ ```bash
68
+ cd ~/.dsh/profiles/web
69
+ pnpm add "link:<本包目录绝对路径>" # link: 软链,改源码即时生效(file: 会复制、不同步)
70
+ ```
71
+
72
+ 包内自测:`npm test`(`node test/smoke.mjs`)。
73
+
74
+ ## 结构
75
+
76
+ - `lib/index.js`:Host 插件(skills 提供者 + `/skill-manager/*` JSON 路由)
77
+ - `lib/client.js`:Web 客户端插件(模式选择器旁勾选器 + 技能管理页)
78
+ - `cordis.patch.yml`:bundle 组合补丁(`dsh plugin add` 依赖它自动归入 bundles)
79
+
80
+ ## License
81
+
82
+ MIT,版权人 zhangchi(2026)。见随包发布的 [LICENSE](./LICENSE)。
@@ -46,6 +46,10 @@ window.__ModuleLoader__.load({
46
46
  '.skh-row-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}',
47
47
  '.skh-row-rel{font-size:11px;color:var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary));overflow-wrap:anywhere}',
48
48
  '.skh-note{font-size:12px;color:var(--dsw-alias-label-tertiary);border:1px dashed var(--dsw-alias-border-l1);border-radius:8px;padding:8px}',
49
+ '.skh-file-row{display:flex;align-items:center;gap:8px}',
50
+ '.skh-file-path{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
51
+ '.skh-file-x{flex:none;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-state-error-primary);border-radius:6px;padding:1px 8px;font-size:11px;cursor:pointer;white-space:nowrap}',
52
+ '.skh-file-x:hover{background:var(--dsw-alias-interactive-bg-hover)}',
49
53
  ].join('\n')
50
54
  ;(function installCss() {
51
55
  if (typeof document === 'undefined') return
@@ -151,8 +155,10 @@ window.__ModuleLoader__.load({
151
155
  const [folderDraft, setFolderDraft] = react.useState(null)
152
156
  const [draftTouched, setDraftTouched] = react.useState(false)
153
157
  if (folderDraft === null && view !== null && !draftTouched) setFolderDraft(view.folder)
158
+ const [fileDraft, setFileDraft] = react.useState('')
154
159
  const skills = view === null ? [] : view.skills
155
160
  const others = view === null ? [] : view.otherSkills
161
+ const files = view === null ? [] : view.files
156
162
  const selectedSet = {}
157
163
  if (view !== null) for (let i = 0; i < view.selected.length; i++) selectedSet[view.selected[i]] = true
158
164
  let statusText = ''
@@ -162,11 +168,24 @@ window.__ModuleLoader__.load({
162
168
  else if (view.scanning) statusText = '正在扫描…'
163
169
  else if (!view.scanned) statusText = '尚未扫描'
164
170
  else if (view.scanError !== '') { statusText = view.scanError; statusErr = true }
165
- else statusText = '已扫描 ' + skills.length + ' 个技能' + (view.scanTime === null || view.scanTime === '' ? '' : ' · ' + view.scanTime)
171
+ else statusText = '已加载 ' + skills.length + ' 个技能' + (view.scanTime === null || view.scanTime === '' ? '' : ' · ' + view.scanTime)
166
172
  const applyFolder = () => {
167
173
  api('/set-folder', { path: folderDraft === null ? '' : folderDraft }).then(refresh, refresh)
168
174
  }
169
175
  const reload = () => { api('/reload').then(refresh, refresh) }
176
+ const setFiles = (next) => { api('/set-files', { files: next }).then(refresh, refresh) }
177
+ const addFile = () => {
178
+ const path = fileDraft.trim()
179
+ if (path === '') return
180
+ if (files.indexOf(path) >= 0) { setFileDraft(''); return }
181
+ setFiles(files.concat([path]))
182
+ setFileDraft('')
183
+ }
184
+ const removeFile = (path) => {
185
+ const next = []
186
+ for (let i = 0; i < files.length; i++) if (files[i] !== path) next.push(files[i])
187
+ setFiles(next)
188
+ }
170
189
  return react.createElement('div', { className: 'skh-admin' },
171
190
  react.createElement('div', { className: 'skh-title' }, '技能管理'),
172
191
  react.createElement('div', { className: 'skh-sub' }, '技能文件夹'),
@@ -179,11 +198,24 @@ window.__ModuleLoader__.load({
179
198
  }),
180
199
  react.createElement('button', { type: 'button', className: 'skh-btn', onClick: applyFolder }, '保存并加载'),
181
200
  react.createElement('button', { type: 'button', className: 'skh-btn', onClick: reload }, '重新扫描')),
201
+ react.createElement('div', { className: 'skh-sub' }, '单个技能文件(可选,逐个指定加载)'),
202
+ files.map((f) => react.createElement('div', { key: f, className: 'skh-file-row' },
203
+ react.createElement('span', { className: 'skh-file-path', title: f }, f),
204
+ react.createElement('button', { type: 'button', className: 'skh-file-x', onClick: () => removeFile(f) }, '移除'))),
205
+ react.createElement('div', { className: 'skh-field' },
206
+ react.createElement('input', {
207
+ className: 'skh-input',
208
+ value: fileDraft,
209
+ placeholder: '单个 .md 技能文件的绝对路径(可放在任意位置)',
210
+ onChange: (event) => setFileDraft(event.target.value),
211
+ }),
212
+ react.createElement('button', { type: 'button', className: 'skh-btn', onClick: addFile }, '添加并加载')),
182
213
  react.createElement('div', { className: statusErr ? 'skh-status skh-status-err' : 'skh-status' }, statusText),
183
- react.createElement('div', { className: 'skh-sub' }, '本文件夹技能(' + skills.length + ')'),
214
+ react.createElement('div', { className: 'skh-sub' }, '已加载技能(' + skills.length + ')'),
184
215
  skills.map((s) => react.createElement('div', { key: s.name, className: 'skh-row' },
185
216
  react.createElement('div', { className: 'skh-row-top' },
186
217
  react.createElement('span', { className: 'skh-row-name' }, s.name),
218
+ s.origin === 'file' ? react.createElement('span', { className: 'skh-badge' }, '文件') : null,
187
219
  react.createElement('span', { className: selectedSet[s.name] === true ? 'skh-badge skh-badge-on' : 'skh-badge' }, selectedSet[s.name] === true ? '已选择' : '未选择')),
188
220
  react.createElement('div', { className: 'skh-row-desc' }, s.description === null || s.description === '' ? '(无描述)' : s.description),
189
221
  react.createElement('div', { className: 'skh-row-rel' }, s.rel))),
@@ -14,7 +14,7 @@
14
14
  * POST /skill-manager/reload
15
15
  */
16
16
  import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises'
17
- import { join, resolve, dirname } from 'node:path'
17
+ import { join, resolve, dirname, basename } from 'node:path'
18
18
  import { homedir } from 'node:os'
19
19
 
20
20
  const NAME = 'skill-manager'
@@ -30,7 +30,7 @@ export function apply(ctx) {
30
30
  const skills = ctx.skills
31
31
  const webServer = ctx.webServer
32
32
 
33
- const config = { folder: DEFAULT_FOLDER, selected: [] }
33
+ const config = { folder: DEFAULT_FOLDER, selected: [], files: [] }
34
34
  const state = {
35
35
  folderExists: false,
36
36
  scanned: false,
@@ -42,6 +42,7 @@ export function apply(ctx) {
42
42
  }
43
43
  let generation = 0
44
44
  let scanPromise = null
45
+ let scanDirty = false
45
46
  let invalidate = function () {}
46
47
 
47
48
  async function loadConfig() {
@@ -61,11 +62,19 @@ export function apply(ctx) {
61
62
  const parsed = JSON.parse(raw)
62
63
  if (typeof parsed.folder === 'string' && parsed.folder.trim() !== '') config.folder = parsed.folder.trim()
63
64
  if (Array.isArray(parsed.selected)) config.selected = parsed.selected.filter((name) => typeof name === 'string')
65
+ if (Array.isArray(parsed.files)) config.files = parsed.files.filter((f) => typeof f === 'string' && f.trim() !== '').map((f) => f.trim())
64
66
  } catch (_) {
65
67
  // 配置损坏:使用默认值
66
68
  }
67
69
  }
68
70
  }
71
+ // 配置必须在任何请求与首轮扫描之前读完:否则会拿默认 folder 与空选择去扫描并写盘,
72
+ // 覆盖用户真实的 ~/.dsh/skill-manager.json(启动瞬间到达的请求就是这条路径)
73
+ let configReady = null
74
+ function ensureConfig() {
75
+ if (configReady === null) configReady = loadConfig()
76
+ return configReady
77
+ }
69
78
  async function persistConfig() {
70
79
  try {
71
80
  await mkdir(dirname(CONFIG_PATH), { recursive: true })
@@ -124,7 +133,10 @@ export function apply(ctx) {
124
133
  }
125
134
  return null
126
135
  }
127
- function parseSkill(text, rel) {
136
+ function parseSkill(text, rel, fallbackName) {
137
+ // 带 BOM 的 UTF-8 文件(记事本"UTF-8 带 BOM"另存)首行不等于 '---',
138
+ // 整段 frontmatter 会被忽略:名称退化成文件名、描述变成 '---'
139
+ if (typeof text === 'string' && text.charCodeAt(0) === 0xfeff) text = text.slice(1)
128
140
  const fm = parseFrontmatter(text)
129
141
  const fileName = rel.replace(/^.*[\\/]/, '')
130
142
  const stem = fileName.replace(/\.md$/i, '')
@@ -138,7 +150,7 @@ export function apply(ctx) {
138
150
  if (typeof fm.data['whenToUse'] === 'string' && fm.data['whenToUse'].trim() !== '') whenToUse = fm.data['whenToUse'].trim()
139
151
  if (fm.data.metadata !== null && typeof fm.data.metadata === 'object' && !Array.isArray(fm.data.metadata)) metadata = fm.data.metadata
140
152
  }
141
- if (name === null) name = stem
153
+ if (name === null) name = fallbackName !== undefined ? fallbackName : stem
142
154
  let finalName = kebab(name)
143
155
  if (finalName === '') finalName = 'skill-' + hashText(rel)
144
156
  if (finalName.length > 64) finalName = finalName.slice(0, 64).replace(/-+$/, '')
@@ -151,7 +163,10 @@ export function apply(ctx) {
151
163
  }
152
164
 
153
165
  // ---------- 递归扫描 ----------
154
- async function walk(dir, rel, found, errors, depth, budget) {
166
+ // 目录型(复合)技能:非根目录内存在 SKILL.md(不区分大小写)时,
167
+ // 整个目录视为一个技能(frontmatter 名称优先,否则用目录名),
168
+ // 目录内其余 .md/子目录都是附属资源,不再注册为独立技能。
169
+ async function walk(dir, rel, found, errors, depth, budget, isRoot) {
155
170
  if (depth > 24 || budget.count >= 4000) return
156
171
  let entries
157
172
  try {
@@ -160,13 +175,30 @@ export function apply(ctx) {
160
175
  errors.push(`listDir failed ${rel}: ${String(error)}`)
161
176
  return
162
177
  }
178
+ if (isRoot !== true) {
179
+ const skillMd = entries.find((e) => e.isFile() && /^skill\.md$/i.test(e.name))
180
+ if (skillMd !== undefined) {
181
+ const full = join(dir, skillMd.name)
182
+ let text
183
+ try {
184
+ text = await readFile(full, 'utf8')
185
+ } catch (error) {
186
+ errors.push(`read failed ${rel}/${skillMd.name}: ${String(error)}`)
187
+ return
188
+ }
189
+ const dirName = rel.split('/').pop()
190
+ const parsed = parseSkill(text, rel + '/' + skillMd.name, dirName)
191
+ found.push({ name: parsed.name, description: parsed.description, whenToUse: parsed.whenToUse, rel: rel + '/' + skillMd.name, path: full, metadata: parsed.metadata, base: dir })
192
+ return
193
+ }
194
+ }
163
195
  for (const entry of entries) {
164
196
  if (budget.count >= 4000) break
165
197
  budget.count++
166
198
  const childRel = rel === '' ? entry.name : rel + '/' + entry.name
167
199
  const full = join(dir, entry.name)
168
200
  if (entry.isDirectory()) {
169
- await walk(full, childRel, found, errors, depth + 1, budget)
201
+ await walk(full, childRel, found, errors, depth + 1, budget, false)
170
202
  } else if (entry.isFile() && /\.md$/i.test(entry.name)) {
171
203
  let text
172
204
  try {
@@ -176,7 +208,7 @@ export function apply(ctx) {
176
208
  continue
177
209
  }
178
210
  const parsed = parseSkill(text, childRel)
179
- found.push({ name: parsed.name, description: parsed.description, whenToUse: parsed.whenToUse, rel: childRel, path: full, metadata: parsed.metadata })
211
+ found.push({ name: parsed.name, description: parsed.description, whenToUse: parsed.whenToUse, rel: childRel, path: full, metadata: parsed.metadata, base: dir })
180
212
  }
181
213
  }
182
214
  }
@@ -207,6 +239,20 @@ export function apply(ctx) {
207
239
  const errors = []
208
240
  const found = []
209
241
  try {
242
+ // 显式指定的单个技能文件优先加载(与文件夹技能重名时以文件为准)
243
+ for (const file of config.files) {
244
+ if (typeof file !== 'string' || file.trim() === '') continue
245
+ const full = resolve(file.trim())
246
+ let text
247
+ try {
248
+ text = await readFile(full, 'utf8')
249
+ } catch (error) {
250
+ errors.push(`file load failed ${file}: ${String(error)}`)
251
+ continue
252
+ }
253
+ const parsed = parseSkill(text, basename(full))
254
+ found.push({ name: parsed.name, description: parsed.description, whenToUse: parsed.whenToUse, rel: basename(full), path: full, metadata: parsed.metadata, base: dirname(full), origin: 'file' })
255
+ }
210
256
  const root = resolve(config.folder)
211
257
  let info
212
258
  try {
@@ -222,7 +268,7 @@ export function apply(ctx) {
222
268
  errors.push(`not a directory: ${config.folder}`)
223
269
  } else {
224
270
  state.folderExists = true
225
- await walk(root, '', found, errors, 0, { count: 0 })
271
+ await walk(root, '', found, errors, 0, { count: 0 }, true)
226
272
  }
227
273
  } catch (error) {
228
274
  errors.push(String(error))
@@ -236,7 +282,9 @@ export function apply(ctx) {
236
282
  out.push(s)
237
283
  }
238
284
  out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
239
- config.selected = config.selected.filter((name) => seen[name] === true)
285
+ // 只在扫描健康(文件夹存在且无读错误)时裁剪勾选:一次临时性失败(盘未挂载、权限抖动)
286
+ // 若也裁剪,用户的整份勾选会被静默清空,并在下一次 persist 时永久写盘丢掉
287
+ if (state.folderExists === true && errors.length === 0) config.selected = config.selected.filter((name) => seen[name] === true)
240
288
  state.skills = out
241
289
  state.scanError = errors.join('; ')
242
290
  state.scanned = true
@@ -248,8 +296,18 @@ export function apply(ctx) {
248
296
  }
249
297
 
250
298
  function scheduleScan() {
251
- if (scanPromise !== null) return scanPromise
252
- const promise = scanNow().catch((error) => {
299
+ // 扫描进行中又收到请求(set-folder/set-files/reload)时不能直接复用旧扫描,
300
+ // 否则新配置要等用户手动「重新扫描」才生效:置脏标记,本轮结束后按最新配置补扫一次
301
+ if (scanPromise !== null) {
302
+ scanDirty = true
303
+ return scanPromise
304
+ }
305
+ const promise = (async () => {
306
+ do {
307
+ scanDirty = false
308
+ await scanNow()
309
+ } while (scanDirty === true)
310
+ })().catch((error) => {
253
311
  state.scanError = String(error)
254
312
  state.scanning = false
255
313
  ctx.logger.error(`[${NAME}] scan failed: ${String(error)}`)
@@ -273,7 +331,8 @@ export function apply(ctx) {
273
331
  for (const name of config.selected) selectedSet[name] = true
274
332
  const out = []
275
333
  for (const s of state.skills) {
276
- if (signal !== undefined && signal.aborted) break
334
+ // 取消中的发现必须按契约标记为不完整:返回普通数组会被注册表当成完整目录缓存
335
+ if (signal !== undefined && signal.aborted) return { candidates: out, complete: false }
277
336
  if (selectedSet[s.name] !== true) continue
278
337
  const candidate = {
279
338
  name: s.name,
@@ -282,8 +341,8 @@ export function apply(ctx) {
282
341
  source: 'custom',
283
342
  provider: 'skill-manager',
284
343
  rank: 350,
285
- locator: { path: s.path, rel: s.rel },
286
- resourceBase: { kind: 'directory', path: config.folder },
344
+ locator: { path: s.path, rel: s.rel, base: s.base },
345
+ resourceBase: { kind: 'directory', path: s.base !== undefined ? s.base : resolve(config.folder) },
287
346
  path: s.path,
288
347
  }
289
348
  if (s.whenToUse !== undefined) candidate.whenToUse = s.whenToUse
@@ -312,7 +371,7 @@ export function apply(ctx) {
312
371
  invocation: { modelInvocable: true, userInvocable: true },
313
372
  source: 'custom',
314
373
  provider: 'skill-manager',
315
- resourceBase: { kind: 'directory', path: config.folder },
374
+ resourceBase: { kind: 'directory', path: loc !== null && loc !== undefined && typeof loc.base === 'string' ? loc.base : resolve(config.folder) },
316
375
  path,
317
376
  content: parsed.content,
318
377
  }
@@ -335,6 +394,7 @@ export function apply(ctx) {
335
394
  whenToUse: s.whenToUse === undefined ? null : s.whenToUse,
336
395
  rel: s.rel,
337
396
  path: s.path,
397
+ origin: s.origin === undefined ? null : s.origin,
338
398
  selected: set[s.name] === true,
339
399
  })
340
400
  }
@@ -347,6 +407,7 @@ export function apply(ctx) {
347
407
  scanError: state.scanError,
348
408
  skills: skillRows,
349
409
  selected: config.selected.slice(),
410
+ files: config.files.slice(),
350
411
  otherSkills: state.otherSkills.slice(),
351
412
  }
352
413
  }
@@ -364,6 +425,12 @@ export function apply(ctx) {
364
425
  const readJsonBody = (req) => new Promise((resolveBody) => {
365
426
  const chunks = []
366
427
  let size = 0
428
+ let settled = false
429
+ const finish = (value) => {
430
+ if (settled === true) return
431
+ settled = true
432
+ resolveBody(value)
433
+ }
367
434
  req.on('data', (chunk) => {
368
435
  size += chunk.length
369
436
  if (size <= 1024 * 1024) chunks.push(chunk)
@@ -371,12 +438,14 @@ export function apply(ctx) {
371
438
  req.on('end', () => {
372
439
  const raw = Buffer.concat(chunks).toString('utf8')
373
440
  try {
374
- resolveBody(raw === '' ? {} : JSON.parse(raw))
441
+ finish(raw === '' ? {} : JSON.parse(raw))
375
442
  } catch (_) {
376
- resolveBody({})
443
+ finish({})
377
444
  }
378
445
  })
379
- req.on('error', () => resolveBody({}))
446
+ req.on('error', () => finish({}))
447
+ // 客户端中途断开时不会触发 end:不兜底会让路由处理器永远挂起(闭包与连接一起泄漏)
448
+ req.on('close', () => finish({}))
380
449
  })
381
450
 
382
451
  ctx.effect(() => webServer.register({
@@ -384,10 +453,21 @@ export function apply(ctx) {
384
453
  path: '/skill-manager',
385
454
  handler: async (req, res) => {
386
455
  try {
456
+ await ensureConfig()
387
457
  const pathname = new URL(req.url, 'http://localhost').pathname.replace(/\/+$/, '')
388
458
  if (req.method === 'GET' && pathname === '/skill-manager/state') {
389
459
  return sendJson(res, 200, await buildView())
390
460
  }
461
+ if (req.method === 'POST' && pathname === '/skill-manager/set-files') {
462
+ const body = await readJsonBody(req)
463
+ const files = Array.isArray(body.files) ? body.files.filter((f) => typeof f === 'string' && f.trim() !== '').map((f) => f.trim()) : []
464
+ const unique = []
465
+ for (const f of files) if (!unique.includes(f)) unique.push(f)
466
+ config.files = unique
467
+ await persistConfig()
468
+ await scheduleScan()
469
+ return sendJson(res, 200, await buildView())
470
+ }
391
471
  if (req.method === 'POST' && pathname === '/skill-manager/set-folder') {
392
472
  const body = await readJsonBody(req)
393
473
  const folder = typeof body.path === 'string' ? body.path.trim() : ''
@@ -400,6 +480,8 @@ export function apply(ctx) {
400
480
  }
401
481
  if (req.method === 'POST' && pathname === '/skill-manager/set-selected') {
402
482
  const body = await readJsonBody(req)
483
+ // 首轮扫描未落地时 state.skills 为空,直接按已知名称裁剪会把整份勾选清空并写盘
484
+ if (state.scanned !== true) await scheduleScan()
403
485
  const names = Array.isArray(body.names) ? body.names.filter((name) => typeof name === 'string') : []
404
486
  const known = Object.create(null)
405
487
  for (const s of state.skills) known[s.name] = true
@@ -428,7 +510,7 @@ export function apply(ctx) {
428
510
 
429
511
  // ---------- 启动 ----------
430
512
  void (async () => {
431
- await loadConfig()
513
+ await ensureConfig()
432
514
  await scheduleScan()
433
515
  })()
434
516
  }
package/package.json CHANGED
@@ -1,12 +1,53 @@
1
1
  {
2
2
  "name": "@zhangchi835/skill-manager",
3
- "version": "1.0.3",
4
- "description": "",
5
- "license": "MIT",
6
- "author": "zhangchi",
7
- "type": "commonjs",
8
- "main": "index.js",
3
+ "version": "1.1.0",
4
+ "description": "DeepSeek Harness (DSH) 技能管理插件:从任意文件夹无命名规则递归加载技能(含 SKILL.md 目录型技能、任意位置的单个技能文件),在输入框「模式」选择器旁提供勾选即启用的会话技能选择器,并附带只做配置与展示的「技能管理」设置页。",
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
+ ],
9
25
  "scripts": {
10
- "test": "echo \"Error: no test specified\" && exit 1"
26
+ "test": "node test/smoke.mjs"
27
+ },
28
+ "keywords": [
29
+ "dsh",
30
+ "dsh-plugin",
31
+ "deepseek-harness",
32
+ "skill",
33
+ "skills",
34
+ "skill-manager"
35
+ ],
36
+ "author": "zhangchi <3090416692@qq.com>",
37
+ "license": "MIT",
38
+ "homepage": "https://github.com/zhangchi825/dsh-skills-project/tree/main/plugin#readme",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/zhangchi825/dsh-skills-project.git",
42
+ "directory": "plugin"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/zhangchi825/dsh-skills-project/issues"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
11
52
  }
12
53
  }
package/index.js DELETED
@@ -1,4 +0,0 @@
1
- module.exports = {
2
- name: "@zhangchi835/skill-manager",
3
- version: "1.0.0"
4
- }
package/plugin/README.md DELETED
@@ -1,66 +0,0 @@
1
- # @zhangchi835/skill-manager
2
-
3
- DeepSeek Harness(DSH)技能加载与管理插件:从任意文件夹**无命名规则**地递归加载技能(中文文件名、无 frontmatter、任意子目录均可),在输入框「模式」选择器旁提供**勾选即启用**的会话技能选择器,并附带「技能管理」设置页(无启用/停用开关)。
4
-
5
- ## 功能
6
-
7
- - **递归加载**:配置一个文件夹,所有 `.md` 文件都被识别为技能;不要求 `SKILL.md`、kebab-case 或 YAML frontmatter(名称自动清洗,中文名回退为哈希名)。
8
- - **选择即启用**:勾选哪个技能就只启用哪个——未勾选的技能不出现在模型 `<available_skills>` 目录与 `/技能` 菜单中。
9
- - **技能管理页**:设置 → 技能管理,可配置文件夹、重新扫描、查看全部技能(**无启用/停用开关**,启用只通过输入框旁的勾选器)。
10
- - **持久化**:文件夹与勾选状态保存在 `~/.dsh/skill-manager.json`(首次运行自动迁移旧版 `~/.dsh/skills-hub.json`),重启自动恢复。
11
-
12
- ## 安装(一条命令)
13
-
14
- ```bash
15
- dsh plugin --profile web add @zhangchi835/skill-manager
16
- ```
17
-
18
- 该命令会:① 把包安装进 `~/.dsh/profiles/web` 的 dependencies;② 因本包声明了 `dsh.bundle.patch`,自动把包名 reconcile 进 `dsh.profile.bundles`,插件行随之进入组合。重启 DSH 进程后生效。
19
-
20
- > 手动安装(不使用 dsh plugin CLI 时):`cd ~/.dsh/profiles/web && pnpm add @zhangchi835/skill-manager`,然后把下行写进 `~/.dsh/profiles/web/cordis.patch.yml`(用户补丁层):
21
- >
22
- > ```yaml
23
- > - insert:
24
- > - id: skill-manager
25
- > name: '@zhangchi835/skill-manager'
26
- > inject: [skills, webServer]
27
- > ```
28
-
29
- ## 验证
30
-
31
- ```bash
32
- curl http://127.0.0.1:<端口>/skill-manager/state # 返回 JSON 即成功
33
- ```
34
-
35
- ## 使用
36
-
37
- 1. 设置 → 技能管理:填写技能文件夹路径(默认 `~/.dsh/skills`),保存并加载。
38
- 2. 任一会话输入框工具行、「模式」选择器旁,点「🧩 技能」勾选要启用的技能。
39
- 3. 勾选后下一回合模型即可通过 `skill` 工具加载对应技能。
40
-
41
- ## 发布(维护者)
42
-
43
- ```bash
44
- npm login # 首次
45
- npm publish --access public # 在包目录执行
46
- ```
47
-
48
- - 依赖只使用 Node 内置模块,profile 解析零负担;
49
- - `lib/index.js` 与 `cordis.patch.yml` 中的 `inject: [skills, webServer]` 是**必须**的(修复启动并发竞态导致的行空挂载),发布前请勿移除。
50
-
51
- ## 本地开发
52
-
53
- ```bash
54
- cd ~/.dsh/profiles/web
55
- pnpm add "link:<本包目录绝对路径>" # link: 软链,改源码即时生效(file: 会复制、不同步)
56
- ```
57
-
58
- ## 结构
59
-
60
- - `lib/index.js`:Host 插件(skills 提供者 + `/skill-manager/*` JSON 路由)
61
- - `lib/client.js`:Web 客户端插件(模式选择器旁勾选器 + 技能管理页)
62
- - `cordis.patch.yml`:bundle 组合补丁(`dsh plugin add` 依赖它自动归入 bundles)
63
-
64
- ## License
65
-
66
- MIT
@@ -1,37 +0,0 @@
1
- {
2
- "name": "@zhangchi835/skill-manager",
3
- "version": "1.0.0",
4
- "description": "DeepSeek Harness skill manager plugin: no-naming-rules recursive folder scanning, select-to-enable composer skill picker, 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
- "publishConfig": {
22
- "access": "public"
23
- },
24
- "files": [
25
- "lib",
26
- "cordis.patch.yml"
27
- ],
28
- "keywords": [
29
- "dsh",
30
- "dsh-plugin",
31
- "deepseek-harness",
32
- "skill",
33
- "skills",
34
- "skill-manager"
35
- ],
36
- "license": "MIT"
37
- }
File without changes