@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 +21 -0
- package/README.md +82 -0
- package/{plugin/lib → lib}/client.js +34 -2
- package/{plugin/lib → lib}/index.js +101 -19
- package/package.json +48 -7
- package/index.js +0 -4
- package/plugin/README.md +0 -66
- package/plugin/package.json +0 -37
- /package/{plugin/cordis.patch.yml → cordis.patch.yml} +0 -0
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 = '
|
|
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' }, '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
finish(raw === '' ? {} : JSON.parse(raw))
|
|
375
442
|
} catch (_) {
|
|
376
|
-
|
|
443
|
+
finish({})
|
|
377
444
|
}
|
|
378
445
|
})
|
|
379
|
-
req.on('error', () =>
|
|
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
|
|
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
|
|
4
|
-
"description": "",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
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": "
|
|
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
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
|
package/plugin/package.json
DELETED
|
@@ -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
|