@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 CHANGED
@@ -5,16 +5,13 @@
5
5
  <h1 align="center">DSH Skills Manager</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>An npm-installable DeepSeek Harness Web plugin for managing local skills safely.</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
- </p>
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
- <p align="center">
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
- > The screenshot shows the Skills panel layout. In the current plugin, shared Agent skills are read-only and do not expose action buttons.
26
+ ## Features
32
27
 
33
- > DSH Skills Manager is a community-maintained plugin. It is not an official DeepSeek AI product.
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
- ## What it provides
34
+ ![Skills Manager settings page](assets/screenshots/skills-manager.png)
36
35
 
37
- - **Two skill groups** — view local `$DSH_HOME\skills` and shared `$DSH_AGENTS_HOME\skills` in one Settings panel.
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
- ## Directory permissions
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
- | Directory | View | Enable/Disable | Upload/Replace | Delete |
48
- | --- | --- | --- | --- | --- |
49
- | `$DSH_HOME\skills` | Yes | Yes | Yes | Yes |
50
- | `$DSH_AGENTS_HOME\skills` | Yes | No | No | No |
42
+ ## Installation
51
43
 
52
- The shared Agent directory is intentionally read-only in both the interface and the server API.
44
+ ### Install from npm
53
45
 
54
- ## Safety behavior
46
+ Run this from any PowerShell directory. Install into the DSH profile through `dsh plugin`:
55
47
 
56
- - Enable, disable, and delete only accept one ordinary skill-name path segment; directory traversal names are rejected.
57
- - Replacements are first copied to a temporary sibling path. Existing files are kept until that copy succeeds.
58
- - Mutation endpoints require JSON and the DSH client request marker, so a cross-site browser request cannot trigger a local file operation.
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
- ## Quick start
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
- You need a working DeepSeek Harness Web installation. Do not run `npm install` in an arbitrary directory: install the plugin into the DSH Web profile instead.
57
+ ### Install from source
63
58
 
64
- 1. Run:
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
- dsh plugin --profile web add @michengai/dsh-skills-manager
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
- 2. Restart DSH or reload the active Web profile.
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
- If a package mirror has not synchronized the latest version yet, append `--registry=https://registry.npmjs.org/` to the install command.
75
+ ## Usage
76
76
 
77
- ## Use the Skills panel
77
+ Open **Settings Skills**, then use the panel as follows:
78
78
 
79
- | Goal | What to do | Scope |
79
+ | Goal | Action | Scope |
80
80
  | --- | --- | --- |
81
- | Check a skill | Open **Settings → Skills** to see its name, form, description, and invocation status. | DSH and shared Agent skills |
82
- | Enable or disable | Choose **Enable** or **Disable**. This changes model invocation and the `/` manual command without rewriting other `SKILL.md` metadata. | DSH skills only |
83
- | Upload a plugin | Choose **Upload Plugin**, then select the plugin directory's `SKILL.md`. DSH copies the complete directory, including scripts and resources. | DSH skills only |
84
- | Select a directory instead | If the selected file has no usable path, choose **Choose Plugin Directory** and select the directory that contains `SKILL.md`. | DSH skills only |
85
- | Replace or delete | Confirm a same-name replacement; use **Delete** for a DSH-local skill you no longer need. | DSH skills only |
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 directorys `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
- Press **Escape** to close only the frontmost upload or confirmation dialog; the Settings page remains open.
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
- ## Development and verification
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
- node test\core-test.mjs
115
+ npm test
116
+ npm run pack:check
117
+ dsh plugin --profile web add .
96
118
  ```
97
119
 
98
- ## Project documentation
120
+ Preserve path validation, temporary-copy replacement, and shared-skill read-only behavior when changing file-mutation code.
99
121
 
100
- Project status, usage boundaries, architecture, and iteration records start at the [documentation entry point](https://github.com/MichengAI/dsh-skills-manager/blob/master/docs/00-%E4%BA%A4%E6%8E%A5%E5%85%A5%E5%8F%A3/00-%E9%98%85%E8%AF%BB%E5%AF%BC%E8%88%AA.md). The Chinese operational guide is available at `docs\02-产品与业务\01-使用说明.md` in the repository.
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 version patch
110
- npm publish
127
+ npm test
128
+ npm run pack:check
111
129
  ```
112
130
 
113
- `prepublishOnly` runs the core test suite before publication. The package always publishes to the official npm registry; use `npm run pack:check` to inspect the tarball first.
131
+ `prepublishOnly` runs the core tests before publishing.
132
+
133
+ ## Documentation and license
114
134
 
115
- ## License
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 the [Apache License 2.0](LICENSE).
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>通过 npm 安装的 DeepSeek Harness Web 插件,用于安全管理本地技能。</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
- </p>
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
- <p align="center">
28
- <img src="assets/screenshots/skills-manager.png" alt="从技能设置面板打开的系统原生文件选择器" width="960">
29
- </p>
24
+ > DSH Skills Manager 是社区维护的插件,并非 DeepSeek AI 官方产品。
30
25
 
31
- > 截图展示技能面板的整体布局。当前版本中的公共 Agent 技能为只读,不会显示操作按钮。
26
+ ## 功能概览
32
27
 
33
- > DSH Skills Manager 是社区维护的插件,并非 DeepSeek AI 官方产品。
28
+ - 在同一设置页查看本地 `$DSH_HOME\skills` 与公共 `$DSH_AGENTS_HOME\skills`。
29
+ - 对 DSH 本地技能执行启用、停用、上传、覆盖和删除。
30
+ - 公共 Agent 技能始终只读,不改写共享的全局元数据。
31
+ - 使用系统文件选择器导入包含 `SKILL.md` 的插件目录,并对同名覆盖强制确认。
32
+ - 拒绝目录穿越、从 DSH 技能目录导入自身及符号链接导入。
34
33
 
35
- ## 核心能力
34
+ ![技能管理设置页面](assets/screenshots/skills-manager.png)
36
35
 
37
- - **双目录分组**:在一个设置面板中查看本地 `$DSH_HOME\skills` 与公共 `$DSH_AGENTS_HOME\skills`。
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
- 公共 Agent 目录在界面和服务端均为只读,用于避免误改共享的全局技能元数据。
44
+ ### npm 安装
53
45
 
54
- ## 安全行为
46
+ 在任意 PowerShell 目录执行。请通过 `dsh plugin` 安装到 DSH profile:
55
47
 
56
- - 启用、停用和删除只接受单个普通技能名称,目录穿越名称会被拒绝。
57
- - 覆盖前会先复制到同目录临时路径;复制成功前不会触碰现有技能。
58
- - 写入接口要求 JSON 与 DSH 客户端请求标记,跨站浏览器请求无法触发本地文件操作。
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
- 环境要求:可正常运行的 DeepSeek Harness Web 环境。不要在任意目录执行 `npm install`;应将插件安装到 DSH Web profile。
57
+ ### 从源码安装
63
58
 
64
- 1. 执行:
59
+ 适用于调试或使用未发布改动。克隆后的目录会直接作为插件安装路径:
65
60
 
66
61
  ```powershell
67
62
  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
68
63
  $OutputEncoding = [System.Text.Encoding]::UTF8
69
- dsh plugin --profile web add @michengai/dsh-skills-manager
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
- 2. 重启 DSH 或重新加载当前 Web profile
73
- 3. 打开「设置 → 技能」。无需下载源码。
73
+ 完成后重启 DSH Web 或重新加载当前 Web profile。`dsh plugin ... add .` 会自动读取并应用 `cordis.patch.yml`;不要手工复制 `lib` 文件。
74
74
 
75
- 若软件源镜像尚未同步最新版本,可在安装命令末尾添加 `--registry=https://registry.npmjs.org/`。
75
+ ## 使用
76
76
 
77
- ## 使用技能面板
77
+ 打开「设置 → 技能」,再按下表操作:
78
78
 
79
79
  | 目标 | 操作 | 范围 |
80
80
  | --- | --- | --- |
81
- | 查看技能 | 打开「设置 → 技能」,查看名称、形态、说明和调用状态。 | DSH 与公共 Agent 技能 |
82
- | 启用或停用 | 点击「启用」或「停用」。它会同步控制模型调用和 `/` 手动命令,不会改写 `SKILL.md` 的其他元数据。 | 仅 DSH 技能 |
83
- | 上传插件 | 点击「上传插件」,选择插件目录内的 `SKILL.md`。DSH 会复制完整目录,包括脚本和资源。 | 仅 DSH 技能 |
84
- | 改选插件目录 | 若所选文件没有可用路径,点击「选择插件文件夹」,并选择包含 `SKILL.md` 的目录。 | 仅 DSH 技能 |
85
- | 覆盖或删除 | 同名时按提示确认覆盖;使用「删除」移除不再需要的 DSH 本地技能。 | 仅 DSH 技能 |
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
- node test\core-test.mjs
115
+ npm test
116
+ npm run pack:check
117
+ dsh plugin --profile web add .
96
118
  ```
97
119
 
98
- ## 项目文档
120
+ 修改文件写入逻辑时必须保留路径校验、临时目录复制和公共技能只读限制。
99
121
 
100
- 项目状态、使用边界、技术架构和迭代记录从[文档交接入口](https://github.com/MichengAI/dsh-skills-manager/blob/master/docs/00-%E4%BA%A4%E6%8E%A5%E5%85%A5%E5%8F%A3/00-%E9%98%85%E8%AF%BB%E5%AF%BC%E8%88%AA.md)开始;详细操作说明见仓库中的 `docs\02-产品与业务\01-使用说明.md`。
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 version patch
110
- npm publish
127
+ npm test
128
+ npm run pack:check
111
129
  ```
112
130
 
113
- 发布前会由 `prepublishOnly` 自动运行核心测试。包始终发布到官方 npm registry;可先执行 `npm run pack:check` 检查 tarball 内容。
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
- // 词典命名空间:注册进 DSH locale 服务,渲染机制按该命名空间把 t 注入组件 props
12
+ // 词典命名空间必须与 settings.section locale 一致,宿主据此注入 t。
13
13
  var NS = "skills-manager";
14
- // 双语词典:zh 与现有硬编码文案逐字一致,保证中文用户无感知;en 为通顺英文。
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(function (response) {
201
- return response.json().then(function (payload) {
202
- if (!payload.ok) {
203
- var error = new Error(payload.error || ("HTTP " + response.status));
204
- // 透传业务错误码与失败明细,供 translateError 按词典翻译。
205
- if (payload.code) error.code = payload.code;
206
- if (payload.params) error.params = payload.params;
207
- if (payload.failed) error.failed = payload.failed;
208
- throw error;
209
- }
210
- return payload.data;
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
- // {action} 占位符按当前语言映射(enable/disable/delete → 启用/停用/删除)。
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 enabled = skill.modelInvocable && skill.userInvocable;
379
- var policyValid = skill.invocationPolicyValid !== false;
380
- var actionEnabled = policyValid && enabled;
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(function (skill) { return skill.modelInvocable; }).length;
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 === ".." || /[\\/\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
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 !== dshRootPath()) return readonlyError("toggle");
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 !== dshRootPath()) return readonlyError("delete");
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
- await fs.rm(resolved.entryPath, { recursive: resolved.kind === "bundle", force: true });
301
- if (log) log("delete", `删除 ${resolved.entryPath}`);
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
- for (const item of backups.reverse()) await fs.rename(item.backup, item.path).catch(() => undefined);
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, p, afterSuccess) {
94
- p.then((r) => {
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, 500, { ok: false, error: String(e && e.message ? e.message : e) });
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
- switch (path) {
149
- case "/api/dsh-skills-manager/enable": {
150
- run(res, setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), true), invalidateSkills);
151
- return;
152
- }
153
- case "/api/dsh-skills-manager/disable": {
154
- run(res, setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), false), invalidateSkills);
155
- return;
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
- case "/api/dsh-skills-manager/delete": {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "NPM-installable DSH Web plugin for managing local skills and viewing shared Agent skills safely.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {