@zhangchi835/skill-manager 1.0.2 → 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 ADDED
@@ -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)。
@@ -0,0 +1,10 @@
1
+ # @zhangchi835/skill-manager bundle layer
2
+ # `dsh plugin --profile web add @zhangchi835/skill-manager` 会自动 reconcile
3
+ # 本包进 dsh.profile.bundles 并应用此层。
4
+ # inject 行级硬依赖修复启动并发竞态(行先于服务 apply 导致静默空挂载)。
5
+ - insert:
6
+ - id: skill-manager
7
+ name: '@zhangchi835/skill-manager'
8
+ inject:
9
+ - skills
10
+ - webServer
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: 'dsh-skills-hub',
2
+ id: '@zhangchi835/skill-manager',
3
3
  factory: (require) => {
4
4
  var module = { exports: {} }
5
5
  var exports = module.exports
@@ -46,13 +46,17 @@ 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
52
- const tagId = 'dsh-skills-hub/SkillsHub.css'
56
+ const tagId = 'skill-manager/SkillsHub.css'
53
57
  if (document.querySelector('style[data-plugin-css="' + tagId + '"]') === null) {
54
58
  const tag = document.createElement('style')
55
- tag.dataset.plugin = 'dsh-skills-hub'
59
+ tag.dataset.plugin = '@zhangchi835/skill-manager'
56
60
  tag.dataset.pluginCss = tagId
57
61
  tag.textContent = CSS
58
62
  document.head.appendChild(tag)
@@ -61,7 +65,7 @@ window.__ModuleLoader__.load({
61
65
 
62
66
  // ---------- HTTP API(永久版经 webServer 路由,替代动态版 host.call) ----------
63
67
  function api(path, body) {
64
- return fetch('/skills-hub' + path, {
68
+ return fetch('/skill-manager' + path, {
65
69
  method: body === undefined ? 'GET' : 'POST',
66
70
  headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
67
71
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -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))),
@@ -1,24 +1,26 @@
1
1
  /**
2
- * dsh-skills-hub — permanent host plugin.
2
+ * @zhangchi835/skill-manager — permanent host plugin.
3
3
  *
4
4
  * - Scans a configured folder recursively; EVERY .md file is a skill
5
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
6
+ * - Registers a `skill-manager` provider on ctx.skills that exposes EXACTLY
7
+ * the skills the user selected in the composer picker (select-to-enable).
8
+ * - Persists { folder, selected } in ~/.dsh/skill-manager.json across
9
+ * restarts (migrates a legacy ~/.dsh/skills-hub.json on first run).
10
+ * - Serves JSON endpoints under /skill-manager for the web client:
11
+ * GET /skill-manager/state
12
+ * POST /skill-manager/set-folder { path }
13
+ * POST /skill-manager/set-selected { names: string[] }
14
+ * POST /skill-manager/reload
14
15
  */
15
16
  import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises'
16
- import { join, resolve, dirname } from 'node:path'
17
+ import { join, resolve, dirname, basename } from 'node:path'
17
18
  import { homedir } from 'node:os'
18
19
 
19
- const NAME = 'dsh-skills-hub'
20
+ const NAME = 'skill-manager'
20
21
  const DEFAULT_FOLDER = join(homedir(), '.dsh', 'skills')
21
- const CONFIG_PATH = join(homedir(), '.dsh', 'skills-hub.json')
22
+ const CONFIG_PATH = join(homedir(), '.dsh', 'skill-manager.json')
23
+ const LEGACY_CONFIG_PATH = join(homedir(), '.dsh', 'skills-hub.json')
22
24
 
23
25
  export const inject = ['skills', 'webServer']
24
26
 
@@ -28,7 +30,7 @@ export function apply(ctx) {
28
30
  const skills = ctx.skills
29
31
  const webServer = ctx.webServer
30
32
 
31
- const config = { folder: DEFAULT_FOLDER, selected: [] }
33
+ const config = { folder: DEFAULT_FOLDER, selected: [], files: [] }
32
34
  const state = {
33
35
  folderExists: false,
34
36
  scanned: false,
@@ -40,17 +42,39 @@ export function apply(ctx) {
40
42
  }
41
43
  let generation = 0
42
44
  let scanPromise = null
45
+ let scanDirty = false
43
46
  let invalidate = function () {}
44
47
 
45
48
  async function loadConfig() {
49
+ let raw = null
46
50
  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')
51
+ raw = await readFile(CONFIG_PATH, 'utf8')
50
52
  } catch (_) {
51
- // 首次运行或配置损坏:使用默认值
53
+ // 新配置不存在:尝试迁移旧版 skills-hub.json
54
+ try {
55
+ raw = await readFile(LEGACY_CONFIG_PATH, 'utf8')
56
+ } catch (_) {
57
+ raw = null
58
+ }
59
+ }
60
+ if (raw !== null) {
61
+ try {
62
+ const parsed = JSON.parse(raw)
63
+ if (typeof parsed.folder === 'string' && parsed.folder.trim() !== '') config.folder = parsed.folder.trim()
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())
66
+ } catch (_) {
67
+ // 配置损坏:使用默认值
68
+ }
52
69
  }
53
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
+ }
54
78
  async function persistConfig() {
55
79
  try {
56
80
  await mkdir(dirname(CONFIG_PATH), { recursive: true })
@@ -109,7 +133,10 @@ export function apply(ctx) {
109
133
  }
110
134
  return null
111
135
  }
112
- 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)
113
140
  const fm = parseFrontmatter(text)
114
141
  const fileName = rel.replace(/^.*[\\/]/, '')
115
142
  const stem = fileName.replace(/\.md$/i, '')
@@ -123,7 +150,7 @@ export function apply(ctx) {
123
150
  if (typeof fm.data['whenToUse'] === 'string' && fm.data['whenToUse'].trim() !== '') whenToUse = fm.data['whenToUse'].trim()
124
151
  if (fm.data.metadata !== null && typeof fm.data.metadata === 'object' && !Array.isArray(fm.data.metadata)) metadata = fm.data.metadata
125
152
  }
126
- if (name === null) name = stem
153
+ if (name === null) name = fallbackName !== undefined ? fallbackName : stem
127
154
  let finalName = kebab(name)
128
155
  if (finalName === '') finalName = 'skill-' + hashText(rel)
129
156
  if (finalName.length > 64) finalName = finalName.slice(0, 64).replace(/-+$/, '')
@@ -136,7 +163,10 @@ export function apply(ctx) {
136
163
  }
137
164
 
138
165
  // ---------- 递归扫描 ----------
139
- 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) {
140
170
  if (depth > 24 || budget.count >= 4000) return
141
171
  let entries
142
172
  try {
@@ -145,13 +175,30 @@ export function apply(ctx) {
145
175
  errors.push(`listDir failed ${rel}: ${String(error)}`)
146
176
  return
147
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
+ }
148
195
  for (const entry of entries) {
149
196
  if (budget.count >= 4000) break
150
197
  budget.count++
151
198
  const childRel = rel === '' ? entry.name : rel + '/' + entry.name
152
199
  const full = join(dir, entry.name)
153
200
  if (entry.isDirectory()) {
154
- await walk(full, childRel, found, errors, depth + 1, budget)
201
+ await walk(full, childRel, found, errors, depth + 1, budget, false)
155
202
  } else if (entry.isFile() && /\.md$/i.test(entry.name)) {
156
203
  let text
157
204
  try {
@@ -161,7 +208,7 @@ export function apply(ctx) {
161
208
  continue
162
209
  }
163
210
  const parsed = parseSkill(text, childRel)
164
- 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 })
165
212
  }
166
213
  }
167
214
  }
@@ -171,7 +218,7 @@ export function apply(ctx) {
171
218
  const list = await skills.list({})
172
219
  const out = []
173
220
  for (const s of list) {
174
- if (s === null || typeof s !== 'object' || s.provider === 'skills-hub') continue
221
+ if (s === null || typeof s !== 'object' || s.provider === 'skill-manager') continue
175
222
  out.push({
176
223
  name: typeof s.name === 'string' ? s.name : '',
177
224
  description: typeof s.description === 'string' ? s.description : '',
@@ -192,6 +239,20 @@ export function apply(ctx) {
192
239
  const errors = []
193
240
  const found = []
194
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
+ }
195
256
  const root = resolve(config.folder)
196
257
  let info
197
258
  try {
@@ -207,7 +268,7 @@ export function apply(ctx) {
207
268
  errors.push(`not a directory: ${config.folder}`)
208
269
  } else {
209
270
  state.folderExists = true
210
- await walk(root, '', found, errors, 0, { count: 0 })
271
+ await walk(root, '', found, errors, 0, { count: 0 }, true)
211
272
  }
212
273
  } catch (error) {
213
274
  errors.push(String(error))
@@ -221,7 +282,9 @@ export function apply(ctx) {
221
282
  out.push(s)
222
283
  }
223
284
  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)
285
+ // 只在扫描健康(文件夹存在且无读错误)时裁剪勾选:一次临时性失败(盘未挂载、权限抖动)
286
+ // 若也裁剪,用户的整份勾选会被静默清空,并在下一次 persist 时永久写盘丢掉
287
+ if (state.folderExists === true && errors.length === 0) config.selected = config.selected.filter((name) => seen[name] === true)
225
288
  state.skills = out
226
289
  state.scanError = errors.join('; ')
227
290
  state.scanned = true
@@ -233,8 +296,18 @@ export function apply(ctx) {
233
296
  }
234
297
 
235
298
  function scheduleScan() {
236
- if (scanPromise !== null) return scanPromise
237
- 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) => {
238
311
  state.scanError = String(error)
239
312
  state.scanning = false
240
313
  ctx.logger.error(`[${NAME}] scan failed: ${String(error)}`)
@@ -251,24 +324,25 @@ export function apply(ctx) {
251
324
  try { control.invalidate() } catch (_) {}
252
325
  }
253
326
  return {
254
- name: 'skills-hub',
327
+ name: 'skill-manager',
255
328
  async list(options) {
256
329
  const signal = options === undefined ? undefined : options.signal
257
330
  const selectedSet = Object.create(null)
258
331
  for (const name of config.selected) selectedSet[name] = true
259
332
  const out = []
260
333
  for (const s of state.skills) {
261
- if (signal !== undefined && signal.aborted) break
334
+ // 取消中的发现必须按契约标记为不完整:返回普通数组会被注册表当成完整目录缓存
335
+ if (signal !== undefined && signal.aborted) return { candidates: out, complete: false }
262
336
  if (selectedSet[s.name] !== true) continue
263
337
  const candidate = {
264
338
  name: s.name,
265
339
  description: s.description,
266
340
  invocation: { modelInvocable: true, userInvocable: true },
267
341
  source: 'custom',
268
- provider: 'skills-hub',
342
+ provider: 'skill-manager',
269
343
  rank: 350,
270
- locator: { path: s.path, rel: s.rel },
271
- 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) },
272
346
  path: s.path,
273
347
  }
274
348
  if (s.whenToUse !== undefined) candidate.whenToUse = s.whenToUse
@@ -296,8 +370,8 @@ export function apply(ctx) {
296
370
  description: parsed.description,
297
371
  invocation: { modelInvocable: true, userInvocable: true },
298
372
  source: 'custom',
299
- provider: 'skills-hub',
300
- resourceBase: { kind: 'directory', path: config.folder },
373
+ provider: 'skill-manager',
374
+ resourceBase: { kind: 'directory', path: loc !== null && loc !== undefined && typeof loc.base === 'string' ? loc.base : resolve(config.folder) },
301
375
  path,
302
376
  content: parsed.content,
303
377
  }
@@ -320,6 +394,7 @@ export function apply(ctx) {
320
394
  whenToUse: s.whenToUse === undefined ? null : s.whenToUse,
321
395
  rel: s.rel,
322
396
  path: s.path,
397
+ origin: s.origin === undefined ? null : s.origin,
323
398
  selected: set[s.name] === true,
324
399
  })
325
400
  }
@@ -332,90 +407,110 @@ export function apply(ctx) {
332
407
  scanError: state.scanError,
333
408
  skills: skillRows,
334
409
  selected: config.selected.slice(),
410
+ files: config.files.slice(),
335
411
  otherSkills: state.otherSkills.slice(),
336
412
  }
337
413
  }
338
414
 
339
415
  // ---------- 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)
416
+ const sendJson = (res, code, value) => {
417
+ const body = JSON.stringify(value)
418
+ res.writeHead(code, {
419
+ 'Content-Type': 'application/json; charset=utf-8',
420
+ 'Content-Length': Buffer.byteLength(body),
421
+ 'Cache-Control': 'no-store',
422
+ })
423
+ res.end(body)
424
+ }
425
+ const readJsonBody = (req) => new Promise((resolveBody) => {
426
+ const chunks = []
427
+ let size = 0
428
+ let settled = false
429
+ const finish = (value) => {
430
+ if (settled === true) return
431
+ settled = true
432
+ resolveBody(value)
349
433
  }
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({}))
434
+ req.on('data', (chunk) => {
435
+ size += chunk.length
436
+ if (size <= 1024 * 1024) chunks.push(chunk)
437
+ })
438
+ req.on('end', () => {
439
+ const raw = Buffer.concat(chunks).toString('utf8')
440
+ try {
441
+ finish(raw === '' ? {} : JSON.parse(raw))
442
+ } catch (_) {
443
+ finish({})
444
+ }
366
445
  })
446
+ req.on('error', () => finish({}))
447
+ // 客户端中途断开时不会触发 end:不兜底会让路由处理器永远挂起(闭包与连接一起泄漏)
448
+ req.on('close', () => finish({}))
449
+ })
367
450
 
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 (_) {}
451
+ ctx.effect(() => webServer.register({
452
+ kind: 'prefix',
453
+ path: '/skill-manager',
454
+ handler: async (req, res) => {
455
+ try {
456
+ await ensureConfig()
457
+ const pathname = new URL(req.url, 'http://localhost').pathname.replace(/\/+$/, '')
458
+ if (req.method === 'GET' && pathname === '/skill-manager/state') {
459
+ return sendJson(res, 200, await buildView())
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
+ }
471
+ if (req.method === 'POST' && pathname === '/skill-manager/set-folder') {
472
+ const body = await readJsonBody(req)
473
+ const folder = typeof body.path === 'string' ? body.path.trim() : ''
474
+ if (folder !== '') {
475
+ config.folder = folder
398
476
  await persistConfig()
399
- return sendJson(res, 200, await buildView())
400
477
  }
401
- if (req.method === 'POST' && pathname === '/skills-hub/reload') {
402
- await scheduleScan()
403
- return sendJson(res, 200, await buildView())
478
+ await scheduleScan()
479
+ return sendJson(res, 200, await buildView())
480
+ }
481
+ if (req.method === 'POST' && pathname === '/skill-manager/set-selected') {
482
+ const body = await readJsonBody(req)
483
+ // 首轮扫描未落地时 state.skills 为空,直接按已知名称裁剪会把整份勾选清空并写盘
484
+ if (state.scanned !== true) await scheduleScan()
485
+ const names = Array.isArray(body.names) ? body.names.filter((name) => typeof name === 'string') : []
486
+ const known = Object.create(null)
487
+ for (const s of state.skills) known[s.name] = true
488
+ const next = []
489
+ for (const name of names) {
490
+ if (known[name] === true && !next.includes(name)) next.push(name)
404
491
  }
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 (_) {}
492
+ config.selected = next
493
+ try { invalidate() } catch (_) {}
494
+ await persistConfig()
495
+ return sendJson(res, 200, await buildView())
411
496
  }
412
- },
413
- }), `${NAME} web route`)
414
- }
497
+ if (req.method === 'POST' && pathname === '/skill-manager/reload') {
498
+ await scheduleScan()
499
+ return sendJson(res, 200, await buildView())
500
+ }
501
+ sendJson(res, 404, { error: 'not found' })
502
+ } catch (error) {
503
+ ctx.logger.warn(`[${NAME}] route error: ${String(error)}`)
504
+ try {
505
+ sendJson(res, 500, { error: String(error) })
506
+ } catch (_) {}
507
+ }
508
+ },
509
+ }), `${NAME} web route`)
415
510
 
416
511
  // ---------- 启动 ----------
417
512
  void (async () => {
418
- await loadConfig()
513
+ await ensureConfig()
419
514
  await scheduleScan()
420
515
  })()
421
516
  }
package/package.json CHANGED
@@ -1,12 +1,53 @@
1
1
  {
2
2
  "name": "@zhangchi835/skill-manager",
3
- "version": "1.0.2",
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
- }
@@ -1,4 +0,0 @@
1
- # dsh-skills-hub bundle layer
2
- - insert:
3
- - id: skills-hub
4
- name: 'dsh-skills-hub'
@@ -1,26 +0,0 @@
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
- }