@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 +21 -0
- package/README.md +82 -0
- package/cordis.patch.yml +10 -0
- package/{plugin/lib → lib}/client.js +38 -6
- package/{plugin/lib → lib}/index.js +198 -103
- package/package.json +48 -7
- package/index.js +0 -4
- package/plugin/cordis.patch.yml +0 -4
- package/plugin/package.json +0 -26
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)。
|
package/cordis.patch.yml
ADDED
|
@@ -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: '
|
|
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 = '
|
|
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 = '
|
|
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('/
|
|
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 = '
|
|
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))),
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 `
|
|
7
|
-
* skills the user selected in the composer picker (select-to-enable).
|
|
8
|
-
* - Persists { folder, selected } in ~/.dsh/
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* POST /
|
|
13
|
-
* POST /
|
|
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 = '
|
|
20
|
+
const NAME = 'skill-manager'
|
|
20
21
|
const DEFAULT_FOLDER = join(homedir(), '.dsh', 'skills')
|
|
21
|
-
const CONFIG_PATH = join(homedir(), '.dsh', '
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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/cordis.patch.yml
DELETED
package/plugin/package.json
DELETED
|
@@ -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
|
-
}
|