@michengai/dsh-skills-manager 0.1.7 → 0.1.9
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/README.md +76 -56
- package/README.zh-CN.md +74 -54
- package/lib/client.js +65 -27
- package/lib/core.js +39 -11
- package/lib/index.js +33 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,16 +5,13 @@
|
|
|
5
5
|
<h1 align="center">DSH Skills Manager</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
8
|
+
<strong>A DeepSeek Harness Web plugin for safely managing local skills and viewing shared Agent skills.</strong>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://github.com/MichengAI/dsh-skills-manager/issues">Report an issue</a>
|
|
13
13
|
· <a href="https://www.npmjs.com/package/@michengai/dsh-skills-manager">View on npm</a>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<p align="center">
|
|
17
|
-
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
|
14
|
+
· <a href="README.zh-CN.md">简体中文</a>
|
|
18
15
|
</p>
|
|
19
16
|
|
|
20
17
|
<p align="center">
|
|
@@ -24,94 +21,117 @@
|
|
|
24
21
|
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933?logo=nodedotjs&logoColor=white" alt="Node.js 20 or later">
|
|
25
22
|
</p>
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
<img src="assets/screenshots/skills-manager.png" alt="Native file picker opened from the Skills settings panel" width="960">
|
|
29
|
-
</p>
|
|
24
|
+
> DSH Skills Manager is a community-maintained plugin, not an official DeepSeek AI product.
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
## Features
|
|
32
27
|
|
|
33
|
-
|
|
28
|
+
- Shows local `$DSH_HOME\skills` and shared `$DSH_AGENTS_HOME\skills` in one Settings page.
|
|
29
|
+
- Enables, disables, uploads, replaces, and deletes DSH-local skills.
|
|
30
|
+
- Keeps shared Agent skills strictly read-only and never changes their global metadata.
|
|
31
|
+
- Imports a plugin directory containing `SKILL.md` through the native file picker and confirms every name collision.
|
|
32
|
+
- Rejects traversal names, imports from the DSH skills directory itself, and symbolic links.
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+

|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
- **Local lifecycle controls** — enable, disable, upload, replace, and delete only DSH-local skills.
|
|
39
|
-
- **Safe shared-skill view** — public Agent skills are visible but strictly read-only; their global metadata is never changed.
|
|
40
|
-
- **Native file selection** — select a plugin `SKILL.md` from the operating system file picker, or explicitly choose its directory when the client cannot expose the selected file path.
|
|
41
|
-
- **Protected replacement** — a same-name import always requires confirmation, including a flat skill and a bundled skill with the same normalized name.
|
|
42
|
-
- **Safer imports** — importing from the DSH skills directory itself, its parent, or a child path is rejected; symbolic links are also rejected.
|
|
43
|
-
- **Predictable dialogs** — Escape closes only the active upload or confirmation dialog, leaving the Settings page open.
|
|
36
|
+
## Prerequisites
|
|
44
37
|
|
|
45
|
-
|
|
38
|
+
- A working DeepSeek Harness Web installation with `dsh` available in PowerShell.
|
|
39
|
+
- Examples use the `web` profile; replace it with the target profile.
|
|
40
|
+
- Source installation and development require Node.js 20+. npm installation does not require running `npm install` in an arbitrary directory.
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
| --- | --- | --- | --- | --- |
|
|
49
|
-
| `$DSH_HOME\skills` | Yes | Yes | Yes | Yes |
|
|
50
|
-
| `$DSH_AGENTS_HOME\skills` | Yes | No | No | No |
|
|
42
|
+
## Installation
|
|
51
43
|
|
|
52
|
-
|
|
44
|
+
### Install from npm
|
|
53
45
|
|
|
54
|
-
|
|
46
|
+
Run this from any PowerShell directory. Install into the DSH profile through `dsh plugin`:
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
```powershell
|
|
49
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
50
|
+
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
51
|
+
dsh plugin --profile web add @michengai/dsh-skills-manager
|
|
52
|
+
dsh --profile web --dump-config
|
|
53
|
+
```
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
Restart DSH Web or reload the active Web profile. If a package mirror is behind, append `--registry=https://registry.npmjs.org/`.
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
### Install from source
|
|
63
58
|
|
|
64
|
-
|
|
59
|
+
Use this for debugging or unpublished changes. The cloned directory becomes the plugin source path:
|
|
65
60
|
|
|
66
61
|
```powershell
|
|
67
62
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
68
63
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
69
|
-
|
|
64
|
+
Set-Location D:\Repository\deepseek-harness-plugin
|
|
65
|
+
git clone https://github.com/MichengAI/dsh-skills-manager.git
|
|
66
|
+
Set-Location .\dsh-skills-manager
|
|
67
|
+
npm install
|
|
68
|
+
npm test
|
|
69
|
+
dsh plugin --profile web add .
|
|
70
|
+
dsh --profile web --dump-config
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
3. Open **Settings → Skills**. No source checkout is required.
|
|
73
|
+
Restart DSH Web or reload the active Web profile. Local installation reads and applies `cordis.patch.yml`; do not copy `lib` files manually.
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
## Usage
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
Open **Settings → Skills**, then use the panel as follows:
|
|
78
78
|
|
|
79
|
-
| Goal |
|
|
79
|
+
| Goal | Action | Scope |
|
|
80
80
|
| --- | --- | --- |
|
|
81
|
-
|
|
|
82
|
-
| Enable or disable |
|
|
83
|
-
| Upload a plugin |
|
|
84
|
-
|
|
|
85
|
-
| Replace or delete | Confirm a
|
|
81
|
+
| Inspect a skill | Review its name, form, description, and invocation state. | DSH and shared Agent skills |
|
|
82
|
+
| Enable or disable | Select **Enable** or **Disable**; this controls model invocation and the `/` command. | DSH-local skills only |
|
|
83
|
+
| Upload a plugin | Select **Upload Plugin**, then choose the plugin directory’s `SKILL.md`. Its scripts and resources are copied too. | DSH-local skills only |
|
|
84
|
+
| Choose the directory | If the selected file has no usable path, choose the directory containing `SKILL.md`. | DSH-local skills only |
|
|
85
|
+
| Replace or delete | Confirm a name collision, or select **Delete** for an unneeded local skill. | DSH-local skills only |
|
|
86
86
|
| Inspect shared skills | Review shared Agent skills without changing their metadata. | Read-only |
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
Escape closes only the frontmost upload or confirmation dialog and leaves Settings open.
|
|
89
|
+
|
|
90
|
+
## Permissions and safety limits
|
|
91
|
+
|
|
92
|
+
| Directory | View | Enable or disable | Upload or replace | Delete |
|
|
93
|
+
| --- | --- | --- | --- | --- |
|
|
94
|
+
| `$DSH_HOME\skills` | Yes | Yes | Yes | Yes |
|
|
95
|
+
| `$DSH_AGENTS_HOME\skills` | Yes | No | No | No |
|
|
96
|
+
|
|
97
|
+
- Enable, disable, and delete accept only one ordinary skill-name path segment.
|
|
98
|
+
- Replacements copy to a temporary sibling path first and keep the original until that succeeds.
|
|
99
|
+
- Write endpoints require JSON and the DSH client request marker, so cross-site browser requests cannot trigger local file operations.
|
|
89
100
|
|
|
90
|
-
##
|
|
101
|
+
## Secondary development
|
|
102
|
+
|
|
103
|
+
This repository has no `src` directory. `lib` is directly maintained runtime source, which is its current layout rather than the recommended layout for new plugins. New plugins should prefer `src` built to `lib`.
|
|
104
|
+
|
|
105
|
+
- [lib\index.js](lib/index.js): host service and local skill file operations.
|
|
106
|
+
- [lib\client.js](lib/client.js): Settings page, upload, and confirmation interactions.
|
|
107
|
+
- `test\core-test.mjs`: file-operation, permission, and import boundary tests.
|
|
108
|
+
- `test\locale-test.mjs`: UI locale tests.
|
|
109
|
+
|
|
110
|
+
After changing the runtime source, test, inspect package contents, and install from the local directory:
|
|
91
111
|
|
|
92
112
|
```powershell
|
|
93
113
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
94
114
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
95
|
-
|
|
115
|
+
npm test
|
|
116
|
+
npm run pack:check
|
|
117
|
+
dsh plugin --profile web add .
|
|
96
118
|
```
|
|
97
119
|
|
|
98
|
-
|
|
120
|
+
Preserve path validation, temporary-copy replacement, and shared-skill read-only behavior when changing file-mutation code.
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
## Maintainer release
|
|
103
|
-
|
|
104
|
-
The package is published as [`@michengai/dsh-skills-manager`](https://www.npmjs.com/package/@michengai/dsh-skills-manager). To release a new version:
|
|
122
|
+
## Validation
|
|
105
123
|
|
|
106
124
|
```powershell
|
|
107
125
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
108
126
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
109
|
-
npm
|
|
110
|
-
npm
|
|
127
|
+
npm test
|
|
128
|
+
npm run pack:check
|
|
111
129
|
```
|
|
112
130
|
|
|
113
|
-
`prepublishOnly` runs the core
|
|
131
|
+
`prepublishOnly` runs the core tests before publishing.
|
|
132
|
+
|
|
133
|
+
## Documentation and license
|
|
114
134
|
|
|
115
|
-
|
|
135
|
+
Project status, usage boundaries, architecture, and iteration records begin at the [documentation entry point](docs/00-交接入口/00-阅读导航.md). The detailed operational guide is `docs\02-产品与业务\01-使用说明.md`.
|
|
116
136
|
|
|
117
|
-
Licensed under
|
|
137
|
+
Licensed under [Apache License 2.0](LICENSE).
|
package/README.zh-CN.md
CHANGED
|
@@ -5,16 +5,13 @@
|
|
|
5
5
|
<h1 align="center">DSH Skills Manager</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong
|
|
8
|
+
<strong>安全管理本地技能并查看公共 Agent 技能的 DeepSeek Harness Web 插件。</strong>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://github.com/MichengAI/dsh-skills-manager/issues">反馈问题</a>
|
|
13
13
|
· <a href="https://www.npmjs.com/package/@michengai/dsh-skills-manager">在 npm 查看</a>
|
|
14
|
-
</
|
|
15
|
-
|
|
16
|
-
<p align="center">
|
|
17
|
-
<a href="README.md">English</a> · <strong>简体中文</strong>
|
|
14
|
+
· <a href="README.md">English</a>
|
|
18
15
|
</p>
|
|
19
16
|
|
|
20
17
|
<p align="center">
|
|
@@ -24,94 +21,117 @@
|
|
|
24
21
|
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933?logo=nodedotjs&logoColor=white" alt="Node.js 20 或更高版本">
|
|
25
22
|
</p>
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
<img src="assets/screenshots/skills-manager.png" alt="从技能设置面板打开的系统原生文件选择器" width="960">
|
|
29
|
-
</p>
|
|
24
|
+
> DSH Skills Manager 是社区维护的插件,并非 DeepSeek AI 官方产品。
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
## 功能概览
|
|
32
27
|
|
|
33
|
-
|
|
28
|
+
- 在同一设置页查看本地 `$DSH_HOME\skills` 与公共 `$DSH_AGENTS_HOME\skills`。
|
|
29
|
+
- 对 DSH 本地技能执行启用、停用、上传、覆盖和删除。
|
|
30
|
+
- 公共 Agent 技能始终只读,不改写共享的全局元数据。
|
|
31
|
+
- 使用系统文件选择器导入包含 `SKILL.md` 的插件目录,并对同名覆盖强制确认。
|
|
32
|
+
- 拒绝目录穿越、从 DSH 技能目录导入自身及符号链接导入。
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+

|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
- **本地技能管理**:仅对 DSH 本地技能提供启用、停用、上传、覆盖和删除。
|
|
39
|
-
- **公共技能安全查看**:公共 Agent 技能可见但严格只读,绝不会改写其全局元数据。
|
|
40
|
-
- **系统原生选择**:通过操作系统文件选择器选择插件的 `SKILL.md`;客户端无法提供所选文件路径时,可显式选择插件目录。
|
|
41
|
-
- **覆盖保护**:导入同名技能一定要求确认;flat 与 bundle 两种形态同名时同样如此。
|
|
42
|
-
- **更安全的导入**:拒绝从 DSH 技能目录自身、其父目录或子目录导入,也拒绝符号链接。
|
|
43
|
-
- **弹窗行为可预期**:按 ESC 只关闭当前上传框或确认框,不会关闭设置页面。
|
|
36
|
+
## 前置条件
|
|
44
37
|
|
|
45
|
-
|
|
38
|
+
- 已可正常运行 DeepSeek Harness Web,且可在 PowerShell 中使用 `dsh`。
|
|
39
|
+
- 以下示例使用 `web` profile;请替换为实际目标 profile。
|
|
40
|
+
- 从源码安装或二次开发需要 Node.js 20+;仅从 npm 安装无需在任意目录执行 `npm install`。
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
| --- | --- | --- | --- | --- |
|
|
49
|
-
| `$DSH_HOME\skills` | 支持 | 支持 | 支持 | 支持 |
|
|
50
|
-
| `$DSH_AGENTS_HOME\skills` | 支持 | 不支持 | 不支持 | 不支持 |
|
|
42
|
+
## 安装
|
|
51
43
|
|
|
52
|
-
|
|
44
|
+
### 从 npm 安装
|
|
53
45
|
|
|
54
|
-
|
|
46
|
+
在任意 PowerShell 目录执行。请通过 `dsh plugin` 安装到 DSH profile:
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
```powershell
|
|
49
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
50
|
+
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
51
|
+
dsh plugin --profile web add @michengai/dsh-skills-manager
|
|
52
|
+
dsh --profile web --dump-config
|
|
53
|
+
```
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
安装或升级后重启 DSH Web,或重新加载当前 Web profile。若镜像未同步最新版本,可在安装命令末尾追加 `--registry=https://registry.npmjs.org/`。
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
### 从源码安装
|
|
63
58
|
|
|
64
|
-
|
|
59
|
+
适用于调试或使用未发布改动。克隆后的目录会直接作为插件安装路径:
|
|
65
60
|
|
|
66
61
|
```powershell
|
|
67
62
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
68
63
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
69
|
-
|
|
64
|
+
Set-Location D:\Repository\deepseek-harness-plugin
|
|
65
|
+
git clone https://github.com/MichengAI/dsh-skills-manager.git
|
|
66
|
+
Set-Location .\dsh-skills-manager
|
|
67
|
+
npm install
|
|
68
|
+
npm test
|
|
69
|
+
dsh plugin --profile web add .
|
|
70
|
+
dsh --profile web --dump-config
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
3. 打开「设置 → 技能」。无需下载源码。
|
|
73
|
+
完成后重启 DSH Web 或重新加载当前 Web profile。`dsh plugin ... add .` 会自动读取并应用 `cordis.patch.yml`;不要手工复制 `lib` 文件。
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
## 使用
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
打开「设置 → 技能」,再按下表操作:
|
|
78
78
|
|
|
79
79
|
| 目标 | 操作 | 范围 |
|
|
80
80
|
| --- | --- | --- |
|
|
81
|
-
| 查看技能 |
|
|
82
|
-
| 启用或停用 | 点击「启用」或「停用」。它会同步控制模型调用和 `/`
|
|
83
|
-
| 上传插件 | 点击「上传插件」,选择插件目录内的 `SKILL.md
|
|
84
|
-
| 改选插件目录 |
|
|
85
|
-
| 覆盖或删除 |
|
|
81
|
+
| 查看技能 | 查看名称、形态、说明和调用状态。 | DSH 与公共 Agent 技能 |
|
|
82
|
+
| 启用或停用 | 点击「启用」或「停用」。它会同步控制模型调用和 `/` 手动命令。 | 仅 DSH 本地技能 |
|
|
83
|
+
| 上传插件 | 点击「上传插件」,选择插件目录内的 `SKILL.md`。完整目录、脚本和资源都会被复制。 | 仅 DSH 本地技能 |
|
|
84
|
+
| 改选插件目录 | 所选文件无可用路径时,选择包含 `SKILL.md` 的目录。 | 仅 DSH 本地技能 |
|
|
85
|
+
| 覆盖或删除 | 同名时确认覆盖;使用「删除」移除不再需要的本地技能。 | 仅 DSH 本地技能 |
|
|
86
86
|
| 查看公共技能 | 查看公共 Agent 技能,不修改其元数据。 | 只读 |
|
|
87
87
|
|
|
88
|
-
按 ESC
|
|
88
|
+
按 ESC 只关闭最上层上传框或确认框,设置页会保持打开。
|
|
89
|
+
|
|
90
|
+
## 权限与安全边界
|
|
91
|
+
|
|
92
|
+
| 目录 | 查看 | 启用或停用 | 上传或覆盖 | 删除 |
|
|
93
|
+
| --- | --- | --- | --- | --- |
|
|
94
|
+
| `$DSH_HOME\skills` | 支持 | 支持 | 支持 | 支持 |
|
|
95
|
+
| `$DSH_AGENTS_HOME\skills` | 支持 | 不支持 | 不支持 | 不支持 |
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
- 启用、停用和删除只接受单个普通技能名称,目录穿越名称会被拒绝。
|
|
98
|
+
- 覆盖前先复制到同目录临时路径;复制成功前不会改动现有技能。
|
|
99
|
+
- 写入接口要求 JSON 与 DSH 客户端请求标记,跨站浏览器请求不能触发本地文件操作。
|
|
100
|
+
|
|
101
|
+
## 二次开发
|
|
102
|
+
|
|
103
|
+
当前仓库未提供 `src` 源目录,`lib` 是直接维护的运行源码;这是当前仓库的实现方式,不是新插件的推荐布局。新插件建议使用 `src` 开发并构建到 `lib`:
|
|
104
|
+
|
|
105
|
+
- [lib\index.js](lib/index.js):Host 服务与本地技能文件操作入口。
|
|
106
|
+
- [lib\client.js](lib/client.js):设置页、上传和确认交互。
|
|
107
|
+
- `test\core-test.mjs`:文件操作、权限和导入边界测试。
|
|
108
|
+
- `test\locale-test.mjs`:界面词条测试。
|
|
109
|
+
|
|
110
|
+
修改后运行测试、检查发布内容并以本地目录安装验证:
|
|
91
111
|
|
|
92
112
|
```powershell
|
|
93
113
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
94
114
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
95
|
-
|
|
115
|
+
npm test
|
|
116
|
+
npm run pack:check
|
|
117
|
+
dsh plugin --profile web add .
|
|
96
118
|
```
|
|
97
119
|
|
|
98
|
-
|
|
120
|
+
修改文件写入逻辑时必须保留路径校验、临时目录复制和公共技能只读限制。
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
## 维护者发布
|
|
103
|
-
|
|
104
|
-
本包已发布为 [`@michengai/dsh-skills-manager`](https://www.npmjs.com/package/@michengai/dsh-skills-manager)。发布新版本时执行:
|
|
122
|
+
## 验证
|
|
105
123
|
|
|
106
124
|
```powershell
|
|
107
125
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
108
126
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
109
|
-
npm
|
|
110
|
-
npm
|
|
127
|
+
npm test
|
|
128
|
+
npm run pack:check
|
|
111
129
|
```
|
|
112
130
|
|
|
113
|
-
|
|
131
|
+
`prepublishOnly` 会在发布前自动执行核心测试。
|
|
132
|
+
|
|
133
|
+
## 项目文档与许可证
|
|
114
134
|
|
|
115
|
-
|
|
135
|
+
项目状态、使用边界、技术架构和迭代记录从[文档交接入口](docs/00-交接入口/00-阅读导航.md)开始。详细操作说明见 `docs\02-产品与业务\01-使用说明.md`。
|
|
116
136
|
|
|
117
137
|
本项目采用 [Apache License 2.0](LICENSE)。
|
package/lib/client.js
CHANGED
|
@@ -9,9 +9,9 @@ window.__ModuleLoader__.load({
|
|
|
9
9
|
var pickDirectory = null;
|
|
10
10
|
var MUTATION_HEADERS = { "content-type": "application/json", "x-dsh-skills-manager": "1" };
|
|
11
11
|
|
|
12
|
-
//
|
|
12
|
+
// 词典命名空间必须与 settings.section 的 locale 一致,宿主据此注入 t。
|
|
13
13
|
var NS = "skills-manager";
|
|
14
|
-
//
|
|
14
|
+
// 宿主支持运行时切换语言,所有用户可见文案必须同时维护 zh/en。
|
|
15
15
|
var DICT = {
|
|
16
16
|
zh: {
|
|
17
17
|
"title": "技能",
|
|
@@ -71,7 +71,6 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
"confirm.overwrite.desc": "将覆盖现有插件:{names}",
|
|
72
72
|
"confirm.delete.title": "删除插件?",
|
|
73
73
|
"confirm.delete.desc": "“{name}”将从 DSH 技能目录中永久删除,无法恢复。",
|
|
74
|
-
// Step 2 业务错误码:zh 值保持与 core 原有中文文案逐字一致({action} 由 action.* 映射后替换)。
|
|
75
74
|
"error.root.readonly": "公共 Agent 技能目录不允许{action}",
|
|
76
75
|
"error.skill.notFound": "技能不存在: {name}",
|
|
77
76
|
"error.skill.noFrontmatter": "技能缺少完整 frontmatter,无法{action}: {name}",
|
|
@@ -90,6 +89,7 @@ window.__ModuleLoader__.load({
|
|
|
90
89
|
"error.proto.unknownAction": "未知操作",
|
|
91
90
|
"error.proto.bodyTooLarge": "请求体过大",
|
|
92
91
|
"error.proto.invalidJson": "请求体不是合法 JSON",
|
|
92
|
+
"error.proto.nonJson": "服务端返回非 JSON 响应(HTTP {status})",
|
|
93
93
|
"action.enable": "启用",
|
|
94
94
|
"action.disable": "停用",
|
|
95
95
|
"action.delete": "删除",
|
|
@@ -172,6 +172,7 @@ window.__ModuleLoader__.load({
|
|
|
172
172
|
"error.proto.unknownAction": "Unknown action",
|
|
173
173
|
"error.proto.bodyTooLarge": "Request body too large",
|
|
174
174
|
"error.proto.invalidJson": "Invalid JSON request body",
|
|
175
|
+
"error.proto.nonJson": "Server returned a non-JSON response (HTTP {status})",
|
|
175
176
|
"action.enable": "enable",
|
|
176
177
|
"action.disable": "disable",
|
|
177
178
|
"action.delete": "deleting",
|
|
@@ -197,36 +198,40 @@ window.__ModuleLoader__.load({
|
|
|
197
198
|
`;
|
|
198
199
|
|
|
199
200
|
function callApi(path, options) {
|
|
200
|
-
return fetch("/api/dsh-skills-manager" + path, options).then(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
201
|
+
return fetch("/api/dsh-skills-manager" + path, options).then(parseApiResponse);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseApiResponse(response) {
|
|
205
|
+
return response.json().catch(function () {
|
|
206
|
+
var error = new Error("HTTP " + response.status + " returned a non-JSON response");
|
|
207
|
+
error.code = "error.proto.nonJson";
|
|
208
|
+
error.params = { status: response.status };
|
|
209
|
+
throw error;
|
|
210
|
+
}).then(function (payload) {
|
|
211
|
+
if (!payload.ok) {
|
|
212
|
+
var error = new Error(payload.error || ("HTTP " + response.status));
|
|
213
|
+
if (payload.code) error.code = payload.code;
|
|
214
|
+
if (payload.params) error.params = payload.params;
|
|
215
|
+
if (payload.failed) error.failed = payload.failed;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
return payload.data;
|
|
212
219
|
});
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
// 翻译业务错误:payload.code 存在则查词典(params 替换占位符),词典 miss 回退原文。
|
|
216
|
-
// 兼容 { code, params, error }、Error 对象、纯字符串三种形态。
|
|
217
222
|
function translateError(t, payload) {
|
|
223
|
+
// 调用方会传入 API payload、Error 和纯字符串,统一在此处兼容可避免漏翻译。
|
|
218
224
|
if (payload && typeof payload === "object") {
|
|
219
225
|
var code = payload.code;
|
|
220
226
|
if (typeof code === "string" && code !== "") {
|
|
221
227
|
var params = payload.params || {};
|
|
222
|
-
//
|
|
228
|
+
// error.* 使用动作键,而动作名称必须随当前语言切换。
|
|
223
229
|
if (params.action !== undefined) {
|
|
224
230
|
var actionKey = "action." + params.action;
|
|
225
231
|
var actionText = t(actionKey);
|
|
226
232
|
if (actionText !== undefined && actionText !== actionKey) params = Object.assign({}, params, { action: actionText });
|
|
227
233
|
}
|
|
228
234
|
var translated = t(code, params);
|
|
229
|
-
// locale miss 时 t 返回 key 本身(或 undefined/null),此时回退原文。
|
|
230
235
|
if (typeof translated === "string" && translated !== code) return translated;
|
|
231
236
|
}
|
|
232
237
|
if (payload.error !== undefined) return translateError(t, payload.error);
|
|
@@ -241,6 +246,25 @@ window.__ModuleLoader__.load({
|
|
|
241
246
|
return typeof value === "string" && value !== key ? value : fallback;
|
|
242
247
|
}
|
|
243
248
|
|
|
249
|
+
function isSkillEnabled(skill) {
|
|
250
|
+
return skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function modalFocusable(modal) {
|
|
254
|
+
return Array.prototype.slice.call(modal.querySelectorAll("button:not(:disabled), [href], input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex=\"-1\"])"));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function trapModalFocus(modal, event) {
|
|
258
|
+
if (event.key !== "Tab") return;
|
|
259
|
+
var focusable = modalFocusable(modal);
|
|
260
|
+
if (focusable.length === 0) return;
|
|
261
|
+
var active = document.activeElement;
|
|
262
|
+
if (!modal.contains(active) || (event.shiftKey ? active === focusable[0] : active === focusable[focusable.length - 1])) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
(event.shiftKey ? focusable[focusable.length - 1] : focusable[0]).focus();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
244
268
|
// 渲染机制按 register 的 locale 字段把命名空间绑定的 t 注入组件 props。
|
|
245
269
|
function SkillManagerSection(props) {
|
|
246
270
|
var t = props.t;
|
|
@@ -266,6 +290,7 @@ window.__ModuleLoader__.load({
|
|
|
266
290
|
var confirmDelete = deleteState[0];
|
|
267
291
|
var setConfirmDelete = deleteState[1];
|
|
268
292
|
var inputRef = react.useRef(null);
|
|
293
|
+
var modalRef = react.useRef(null);
|
|
269
294
|
|
|
270
295
|
function refresh() {
|
|
271
296
|
setSnapshot({ loading: true, error: null, data: snapshot.data });
|
|
@@ -293,6 +318,16 @@ window.__ModuleLoader__.load({
|
|
|
293
318
|
return function () { window.removeEventListener("keydown", closeTopModal, true); };
|
|
294
319
|
}, [uploadOpen, confirmImport, confirmDelete]);
|
|
295
320
|
|
|
321
|
+
react.useEffect(function () {
|
|
322
|
+
var modal = modalRef.current;
|
|
323
|
+
if (!modal) return undefined;
|
|
324
|
+
var focusable = modalFocusable(modal);
|
|
325
|
+
(focusable[0] || modal).focus();
|
|
326
|
+
function trapFocus(event) { trapModalFocus(modal, event); }
|
|
327
|
+
modal.addEventListener("keydown", trapFocus);
|
|
328
|
+
return function () { modal.removeEventListener("keydown", trapFocus); };
|
|
329
|
+
}, [uploadOpen, confirmImport, confirmDelete]);
|
|
330
|
+
|
|
296
331
|
function action(path, body) {
|
|
297
332
|
if (busy) return;
|
|
298
333
|
setBusy(path);
|
|
@@ -375,9 +410,9 @@ window.__ModuleLoader__.load({
|
|
|
375
410
|
}
|
|
376
411
|
|
|
377
412
|
function skillRow(skill, root) {
|
|
378
|
-
var
|
|
379
|
-
var
|
|
380
|
-
var actionEnabled =
|
|
413
|
+
var policyValid = skill.invocationPolicyValid;
|
|
414
|
+
var enabled = isSkillEnabled(skill);
|
|
415
|
+
var actionEnabled = enabled;
|
|
381
416
|
return h("div", { key: skill.name, className: "dssm-row" },
|
|
382
417
|
h("div", { className: "dssm-row-main" },
|
|
383
418
|
h("div", { className: "dssm-row-id" },
|
|
@@ -394,7 +429,7 @@ window.__ModuleLoader__.load({
|
|
|
394
429
|
if (snapshot.data) {
|
|
395
430
|
var roots = snapshot.data.roots || [];
|
|
396
431
|
var skills = roots.reduce(function (all, root) { return all.concat(root.skills || []); }, []);
|
|
397
|
-
var enabled = skills.filter(
|
|
432
|
+
var enabled = skills.filter(isSkillEnabled).length;
|
|
398
433
|
nodes.push(h("div", { key: "toolbar", className: "dssm-toolbar" },
|
|
399
434
|
h("div", null, h("h2", { className: "dssm-title" }, t("title")), h("p", { className: "dssm-desc" }, t("desc"))),
|
|
400
435
|
h("div", { className: "dssm-actions" },
|
|
@@ -438,7 +473,7 @@ window.__ModuleLoader__.load({
|
|
|
438
473
|
nodes.push(h("div", { key: "import-result", className: isImportError || failedItems.length ? "dssm-error" : "dssm-note", role: "status" }, resultText));
|
|
439
474
|
}
|
|
440
475
|
if (uploadOpen) {
|
|
441
|
-
nodes.push(h("div", { key: "upload", className: "dssm-mask" }, h("div", { className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-upload-title" },
|
|
476
|
+
nodes.push(h("div", { key: "upload", className: "dssm-mask" }, h("div", { ref: modalRef, tabIndex: -1, className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-upload-title" },
|
|
442
477
|
h("div", { className: "dssm-modal-head" }, h("h3", { id: "dssm-upload-title", className: "dssm-modal-title" }, t("upload.title")), h("button", { className: "dssm-icon-btn", "aria-label": t("upload.close"), onClick: function () { setUploadOpen(false); } }, "×")),
|
|
443
478
|
h("input", { ref: inputRef, className: "dssm-hidden-input", type: "file", accept: ".md", onChange: function (event) { selectFile(event.target.files && event.target.files[0]); event.target.value = ""; } }),
|
|
444
479
|
h("div", { className: "dssm-dropzone", tabIndex: 0, onClick: openNativePicker, onKeyDown: function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openNativePicker(); } }, onDragOver: function (event) { event.preventDefault(); }, onDrop: function (event) { event.preventDefault(); selectFile(event.dataTransfer.files && event.dataTransfer.files[0]); } }, h("span", { className: "dssm-dropzone-title" }, t("upload.drop.title")), h("span", { className: "dssm-dropzone-copy" }, t("upload.drop.copy"))),
|
|
@@ -448,10 +483,10 @@ window.__ModuleLoader__.load({
|
|
|
448
483
|
h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: openNativePicker }, t("btn.file.pick")), pickDirectory ? h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: selectDirectory }, t("btn.dir.pick")) : null, h("button", { className: "dssm-btn", disabled: !selected || !!busy, onClick: installSelected }, busy === "import" || busy === "import-check" ? t("btn.uploading") : t("btn.upload.dsh"))))));
|
|
449
484
|
}
|
|
450
485
|
if (confirmImport) {
|
|
451
|
-
nodes.push(h("div", { key: "import-confirm", className: "dssm-mask" }, h("div", { className: "dssm-modal", role: "dialog", "aria-modal": "true" }, h("div", { className: "dssm-modal-head" }, h("h3", { className: "dssm-modal-title" }, t("confirm.overwrite.title"))), h("p", { className: "dssm-desc" }, t("confirm.overwrite.desc", { names: confirmImport.conflicts.map(function (item) { return item.name; }).join(t("sep.names")) })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setConfirmImport(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: !!busy, onClick: function () { executeImport(confirmImport.source, "overwrite"); } }, t("btn.overwrite.upload"))))));
|
|
486
|
+
nodes.push(h("div", { key: "import-confirm", className: "dssm-mask" }, h("div", { ref: modalRef, tabIndex: -1, className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-import-title" }, h("div", { className: "dssm-modal-head" }, h("h3", { id: "dssm-import-title", className: "dssm-modal-title" }, t("confirm.overwrite.title"))), h("p", { className: "dssm-desc" }, t("confirm.overwrite.desc", { names: confirmImport.conflicts.map(function (item) { return item.name; }).join(t("sep.names")) })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setConfirmImport(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: !!busy, onClick: function () { executeImport(confirmImport.source, "overwrite"); } }, t("btn.overwrite.upload"))))));
|
|
452
487
|
}
|
|
453
488
|
if (confirmDelete) {
|
|
454
|
-
nodes.push(h("div", { key: "delete-confirm", className: "dssm-mask" }, h("div", { className: "dssm-modal", role: "dialog", "aria-modal": "true" }, h("div", { className: "dssm-modal-head" }, h("h3", { className: "dssm-modal-title" }, t("confirm.delete.title"))), h("p", { className: "dssm-desc" }, t("confirm.delete.desc", { name: confirmDelete.name })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { setConfirmDelete(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn dssm-btn-danger", disabled: !!busy, onClick: function () { setConfirmDelete(null); action("/delete", { name: confirmDelete.name }); } }, t("btn.delete.confirm"))))));
|
|
489
|
+
nodes.push(h("div", { key: "delete-confirm", className: "dssm-mask" }, h("div", { ref: modalRef, tabIndex: -1, className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-delete-title" }, h("div", { className: "dssm-modal-head" }, h("h3", { id: "dssm-delete-title", className: "dssm-modal-title" }, t("confirm.delete.title"))), h("p", { className: "dssm-desc" }, t("confirm.delete.desc", { name: confirmDelete.name })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { setConfirmDelete(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn dssm-btn-danger", disabled: !!busy, onClick: function () { setConfirmDelete(null); action("/delete", { name: confirmDelete.name }); } }, t("btn.delete.confirm"))))));
|
|
455
490
|
}
|
|
456
491
|
return h("section", { className: "dssm-section" }, nodes);
|
|
457
492
|
}
|
|
@@ -462,7 +497,7 @@ window.__ModuleLoader__.load({
|
|
|
462
497
|
return ctx.locale.register(NS, DICT);
|
|
463
498
|
}, "skills-manager: dictionaries");
|
|
464
499
|
ctx.slots.inject("settings.section", function () {
|
|
465
|
-
return ctx.slots.register({ name: "settings.section", id: "skills-manager", order: 17, label: function () { return ctx.locale.bind(NS)("title"); }, locale: NS }, SkillManagerSection);
|
|
500
|
+
return ctx.slots.register({ name: "settings.section", id: "skills-manager", order: 17, label: function () { return ctx.locale.bind(NS)("title"); }, icon: "skill", locale: NS }, SkillManagerSection);
|
|
466
501
|
});
|
|
467
502
|
}
|
|
468
503
|
module.exports.apply = apply;
|
|
@@ -470,6 +505,9 @@ window.__ModuleLoader__.load({
|
|
|
470
505
|
// 导出词典与翻译函数供零依赖对齐测试读取(宿主只消费 apply/inject,不影响运行时)。
|
|
471
506
|
module.exports.DICT = DICT;
|
|
472
507
|
module.exports.translateError = translateError;
|
|
508
|
+
module.exports.isSkillEnabled = isSkillEnabled;
|
|
509
|
+
module.exports.parseApiResponse = parseApiResponse;
|
|
510
|
+
module.exports.trapModalFocus = trapModalFocus;
|
|
473
511
|
return module.exports;
|
|
474
512
|
}
|
|
475
513
|
});
|
package/lib/core.js
CHANGED
|
@@ -84,13 +84,17 @@ function pathsOverlap(a, b) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
|
|
87
|
-
function entryPath(root, name) {
|
|
88
|
-
if (typeof name !== "string" || name === "" || name === "." || name === ".." || /[
|
|
87
|
+
export function entryPath(root, name) {
|
|
88
|
+
if (typeof name !== "string" || name === "" || name === "." || name === ".." || /[\\/:*?"<>|\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
|
|
89
89
|
const rootPath = resolve(root);
|
|
90
90
|
const path = resolve(rootPath, name);
|
|
91
91
|
return isSameOrDescendant(rootPath, path) && rootPath !== path ? path : null;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function isDshRoot(root) {
|
|
95
|
+
return typeof root === "string" && resolve(root) === resolve(dshRootPath());
|
|
96
|
+
}
|
|
97
|
+
|
|
94
98
|
// ── 命名规整 ────────────────────────────────────────────────────────────────
|
|
95
99
|
|
|
96
100
|
/** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
|
|
@@ -259,13 +263,13 @@ export async function scanEntries(root) {
|
|
|
259
263
|
const entries = [];
|
|
260
264
|
for (const it of items) {
|
|
261
265
|
try {
|
|
262
|
-
if (it.isDirectory()) {
|
|
266
|
+
if (it.isDirectory() && entryPath(root, it.name) !== null) {
|
|
263
267
|
const docPath = join(root, it.name, "SKILL.md");
|
|
264
268
|
const st = await fs.stat(docPath);
|
|
265
269
|
if (!st.isFile()) continue;
|
|
266
270
|
const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
267
271
|
entries.push(entryOf(it.name, "bundle", docPath, doc));
|
|
268
|
-
} else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
|
|
272
|
+
} else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md" && entryPath(root, it.name.slice(0, -3)) !== null) {
|
|
269
273
|
const docPath = join(root, it.name);
|
|
270
274
|
const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
271
275
|
entries.push(entryOf(it.name.slice(0, -3), "flat", docPath, doc));
|
|
@@ -282,7 +286,7 @@ export async function scanEntries(root) {
|
|
|
282
286
|
|
|
283
287
|
/** enabled=true 恢复模型与 / 手动调用;false 同时停用两种调用入口。 */
|
|
284
288
|
export async function setSkillEnabled(root, name, enabled) {
|
|
285
|
-
if (root
|
|
289
|
+
if (!isDshRoot(root)) return readonlyError("toggle");
|
|
286
290
|
const resolved = await resolveEntry(root, name);
|
|
287
291
|
if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
|
|
288
292
|
const source = await fs.readFile(resolved.docPath, "utf8");
|
|
@@ -294,11 +298,19 @@ export async function setSkillEnabled(root, name, enabled) {
|
|
|
294
298
|
|
|
295
299
|
/** 删除 DSH 根目录中的单个技能。调用方必须先向用户确认。 */
|
|
296
300
|
export async function deleteSkill(root, name, log) {
|
|
297
|
-
if (root
|
|
301
|
+
if (!isDshRoot(root)) return readonlyError("delete");
|
|
298
302
|
const resolved = await resolveEntry(root, name);
|
|
299
303
|
if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
|
|
300
|
-
|
|
301
|
-
|
|
304
|
+
const bundlePath = entryPath(root, name);
|
|
305
|
+
const alternatePath = resolved.kind === "bundle" ? resolve(root, `${name}.md`) : bundlePath;
|
|
306
|
+
const alternateDocPath = resolved.kind === "bundle" ? alternatePath : join(alternatePath, "SKILL.md");
|
|
307
|
+
const targets = [{ path: resolved.entryPath, recursive: resolved.kind === "bundle" }];
|
|
308
|
+
try {
|
|
309
|
+
const st = await fs.stat(alternateDocPath);
|
|
310
|
+
if (st.isFile()) targets.push({ path: alternatePath, recursive: resolved.kind !== "bundle" });
|
|
311
|
+
} catch {}
|
|
312
|
+
for (const target of targets) await fs.rm(target.path, { recursive: target.recursive, force: true });
|
|
313
|
+
if (log) log("delete", `删除 ${targets.map((target) => target.path).join("、")}`);
|
|
302
314
|
return { name };
|
|
303
315
|
}
|
|
304
316
|
|
|
@@ -385,6 +397,7 @@ async function copyToTemporary(source, target, isDir) {
|
|
|
385
397
|
await assertNoSymbolicLinks(source);
|
|
386
398
|
if (isDir) await fs.cp(source, temp, { recursive: true, dereference: false });
|
|
387
399
|
else await fs.copyFile(source, temp);
|
|
400
|
+
await assertNoSymbolicLinks(temp);
|
|
388
401
|
return temp;
|
|
389
402
|
} catch (error) {
|
|
390
403
|
await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
|
|
@@ -405,7 +418,20 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
|
|
|
405
418
|
await fs.rename(stage, dest);
|
|
406
419
|
} catch (error) {
|
|
407
420
|
await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
408
|
-
|
|
421
|
+
const rollbackFailures = [];
|
|
422
|
+
for (const item of backups.reverse()) {
|
|
423
|
+
try {
|
|
424
|
+
await fs.rename(item.backup, item.path);
|
|
425
|
+
} catch (rollbackError) {
|
|
426
|
+
rollbackFailures.push(`${item.backup}(${String(rollbackError && rollbackError.message ? rollbackError.message : rollbackError)})`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (rollbackFailures.length) {
|
|
430
|
+
const restoreError = new Error(`${String(error && error.message ? error.message : error)};覆盖导入回滚失败,备份保留在: ${rollbackFailures.join("、")}`);
|
|
431
|
+
if (error && error.code) restoreError.code = error.code;
|
|
432
|
+
if (error && error.params) restoreError.params = error.params;
|
|
433
|
+
throw restoreError;
|
|
434
|
+
}
|
|
409
435
|
throw error;
|
|
410
436
|
}
|
|
411
437
|
const warnings = [];
|
|
@@ -453,7 +479,7 @@ export async function importSkill(source, log, options = {}) {
|
|
|
453
479
|
|
|
454
480
|
const nameCount = new Map();
|
|
455
481
|
for (const candidate of candidates) {
|
|
456
|
-
if (candidate.kebab && KEBAB_RE.test(candidate.kebab)) nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
|
|
482
|
+
if (candidate.kebab && KEBAB_RE.test(candidate.kebab) && entryPath(targetRoot, candidate.kebab) !== null) nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
|
|
457
483
|
}
|
|
458
484
|
|
|
459
485
|
function failureResult() {
|
|
@@ -470,7 +496,7 @@ export async function importSkill(source, log, options = {}) {
|
|
|
470
496
|
}
|
|
471
497
|
|
|
472
498
|
for (const c of candidates) {
|
|
473
|
-
if (!c.kebab || !KEBAB_RE.test(c.kebab)) {
|
|
499
|
+
if (!c.kebab || !KEBAB_RE.test(c.kebab) || entryPath(targetRoot, c.kebab) === null) {
|
|
474
500
|
failed.push({ source: c.source, error: `无法生成合法 kebab-case 名称(原始名: ${c.rawName || basename(c.source)})`, code: "error.import.invalidName", params: { name: c.rawName || basename(c.source) } });
|
|
475
501
|
continue;
|
|
476
502
|
}
|
|
@@ -494,6 +520,8 @@ export async function importSkill(source, log, options = {}) {
|
|
|
494
520
|
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest });
|
|
495
521
|
}
|
|
496
522
|
|
|
523
|
+
if (pending.length === 0 && conflicts.length === 0) return failureResult();
|
|
524
|
+
|
|
497
525
|
if (dryRun) {
|
|
498
526
|
if (failed.length && pending.length === 0 && conflicts.length === 0) return failureResult();
|
|
499
527
|
return { kind: analysis.kind, pending, conflicts, failed };
|
package/lib/index.js
CHANGED
|
@@ -74,6 +74,8 @@ function readBody(req, limit = 1 << 20) {
|
|
|
74
74
|
|
|
75
75
|
/** 自定义请求头使跨站 fetch 必须预检;本地接口不提供 CORS 响应。 */
|
|
76
76
|
function validateMutationRequest(req) {
|
|
77
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
78
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return { statusCode: 403, code: "error.proto.forbidden", error: "forbidden host" };
|
|
77
79
|
if (req.headers[CLIENT_MARKER_HEADER] !== "1") return { statusCode: 403, code: "error.proto.forbidden", error: "forbidden mutation request" };
|
|
78
80
|
const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
|
|
79
81
|
if (contentType !== "application/json") return { statusCode: 415, code: "error.proto.contentType", error: "content-type must be application/json" };
|
|
@@ -90,8 +92,8 @@ function json(res, code, payload) {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/** 成功统一包成 { ok: true, data };核心返回 { ok:false, error } 时透传为 400。 */
|
|
93
|
-
function run(res,
|
|
94
|
-
|
|
95
|
+
function run(res, task, afterSuccess) {
|
|
96
|
+
return Promise.resolve().then(task).then((r) => {
|
|
95
97
|
if (r && r.ok === false) json(res, 400, r);
|
|
96
98
|
else {
|
|
97
99
|
try {
|
|
@@ -103,7 +105,11 @@ function run(res, p, afterSuccess) {
|
|
|
103
105
|
}
|
|
104
106
|
}).catch((e) => {
|
|
105
107
|
try {
|
|
106
|
-
json(res,
|
|
108
|
+
json(res, Number.isInteger(e && e.statusCode) ? e.statusCode : 500, {
|
|
109
|
+
ok: false,
|
|
110
|
+
...(e && e.code ? { code: e.code } : {}),
|
|
111
|
+
error: String(e && e.message ? e.message : e),
|
|
112
|
+
});
|
|
107
113
|
} catch {
|
|
108
114
|
/* response already closed */
|
|
109
115
|
}
|
|
@@ -115,6 +121,12 @@ function apply(ctx) {
|
|
|
115
121
|
const roots = userRoots();
|
|
116
122
|
const rootByKey = Object.fromEntries(roots.map((r) => [r.key, r.path]));
|
|
117
123
|
let invalidateSkills = () => {};
|
|
124
|
+
let mutationQueue = Promise.resolve();
|
|
125
|
+
const enqueueMutation = (task) => {
|
|
126
|
+
const queued = mutationQueue.then(task, task);
|
|
127
|
+
mutationQueue = queued.catch(() => undefined);
|
|
128
|
+
return queued;
|
|
129
|
+
};
|
|
118
130
|
ctx.effect(() => ctx.skills.registerProvider((control) => {
|
|
119
131
|
invalidateSkills = control.invalidate;
|
|
120
132
|
return {
|
|
@@ -132,8 +144,7 @@ function apply(ctx) {
|
|
|
132
144
|
const path = u.pathname.replace(/\/+$/, "");
|
|
133
145
|
try {
|
|
134
146
|
if (req.method === "GET" && path === "/api/dsh-skills-manager/state") {
|
|
135
|
-
run(res, state
|
|
136
|
-
return;
|
|
147
|
+
return run(res, state);
|
|
137
148
|
}
|
|
138
149
|
if (req.method !== "POST") {
|
|
139
150
|
json(res, 405, { ok: false, code: "error.proto.method", error: `method not allowed: ${req.method}` });
|
|
@@ -141,32 +152,27 @@ function apply(ctx) {
|
|
|
141
152
|
}
|
|
142
153
|
const requestError = validateMutationRequest(req);
|
|
143
154
|
if (requestError) {
|
|
144
|
-
json(res, requestError.statusCode, { ok: false, error: requestError.error });
|
|
155
|
+
json(res, requestError.statusCode, { ok: false, code: requestError.code, error: requestError.error });
|
|
145
156
|
return;
|
|
146
157
|
}
|
|
147
158
|
const body = await readBody(req);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
return enqueueMutation(() => {
|
|
160
|
+
switch (path) {
|
|
161
|
+
case "/api/dsh-skills-manager/enable":
|
|
162
|
+
return run(res, () => setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), true), invalidateSkills);
|
|
163
|
+
case "/api/dsh-skills-manager/disable":
|
|
164
|
+
return run(res, () => setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), false), invalidateSkills);
|
|
165
|
+
case "/api/dsh-skills-manager/delete":
|
|
166
|
+
return run(res, () => deleteSkill(rootByKey.dsh, String(body.name || ""), log), invalidateSkills);
|
|
167
|
+
case "/api/dsh-skills-manager/import":
|
|
168
|
+
return run(res, () => importSkill(String(body.source || ""), log, {
|
|
169
|
+
conflict: body.conflict === "overwrite" ? "overwrite" : "skip",
|
|
170
|
+
dryRun: body.dryRun === true,
|
|
171
|
+
}), body.dryRun === true ? undefined : invalidateSkills);
|
|
172
|
+
default:
|
|
173
|
+
json(res, 404, { ok: false, code: "error.proto.unknownAction", error: `unknown action: ${path}` });
|
|
156
174
|
}
|
|
157
|
-
|
|
158
|
-
run(res, deleteSkill(rootByKey.dsh, String(body.name || ""), log), invalidateSkills);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
case "/api/dsh-skills-manager/import":
|
|
162
|
-
run(res, importSkill(String(body.source || ""), log, {
|
|
163
|
-
conflict: body.conflict === "overwrite" ? "overwrite" : "skip",
|
|
164
|
-
dryRun: body.dryRun === true,
|
|
165
|
-
}), body.dryRun === true ? undefined : invalidateSkills);
|
|
166
|
-
return;
|
|
167
|
-
default:
|
|
168
|
-
json(res, 404, { ok: false, code: "error.proto.unknownAction", error: `unknown action: ${path}` });
|
|
169
|
-
}
|
|
175
|
+
});
|
|
170
176
|
} catch (e) {
|
|
171
177
|
json(res, Number.isInteger(e && e.statusCode) ? e.statusCode : 500, {
|
|
172
178
|
ok: false,
|
package/package.json
CHANGED