@michengai/dsh-skills-manager 0.1.0 → 0.1.2

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,11 +5,12 @@
5
5
  <h1 align="center">DSH Skills Manager</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Manage local DeepSeek Harness skills with a clear boundary for shared Agent skills.</strong>
8
+ <strong>An npm-installable DeepSeek Harness Web plugin for managing local skills safely.</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
+ · <a href="https://www.npmjs.com/package/@michengai/dsh-skills-manager">View on npm</a>
13
14
  </p>
14
15
 
15
16
  <p align="center">
@@ -18,6 +19,7 @@
18
19
 
19
20
  <p align="center">
20
21
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="Apache License 2.0"></a>
22
+ <a href="https://www.npmjs.com/package/@michengai/dsh-skills-manager"><img src="https://img.shields.io/npm/v/%40michengai/dsh-skills-manager?label=npm" alt="npm package"></a>
21
23
  <img src="https://img.shields.io/badge/DSH-Web%20Plugin-10b981" alt="DSH Web Plugin">
22
24
  <img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933?logo=nodedotjs&logoColor=white" alt="Node.js 20 or later">
23
25
  </p>
@@ -37,6 +39,7 @@
37
39
  - **Safe shared-skill view** — public Agent skills are visible but strictly read-only; their global metadata is never changed.
38
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.
39
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.
40
43
  - **Predictable dialogs** — Escape closes only the active upload or confirmation dialog, leaving the Settings page open.
41
44
 
42
45
  ## Directory permissions
@@ -48,11 +51,17 @@
48
51
 
49
52
  The shared Agent directory is intentionally read-only in both the interface and the server API.
50
53
 
51
- ## Install
54
+ ## Safety behavior
52
55
 
53
- Requirements: a working DeepSeek Harness Web installation. The published package declares Node.js 20 or later for the DSH runtime.
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.
54
59
 
55
- Install the published package into the Web profile:
60
+ ## Quick start
61
+
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.
63
+
64
+ 1. Run:
56
65
 
57
66
  ```powershell
58
67
  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
@@ -60,16 +69,23 @@ $OutputEncoding = [System.Text.Encoding]::UTF8
60
69
  dsh plugin --profile web add @michengai/dsh-skills-manager
61
70
  ```
62
71
 
63
- No source checkout is required. Restart DSH, then open **Settings → Skills**. If a package mirror has not synchronized the latest version yet, append `--registry=https://registry.npmjs.org/` to the install command.
72
+ 2. Restart DSH or reload the active Web profile.
73
+ 3. Open **Settings → Skills**. No source checkout is required.
74
+
75
+ If a package mirror has not synchronized the latest version yet, append `--registry=https://registry.npmjs.org/` to the install command.
76
+
77
+ ## Use the Skills panel
64
78
 
65
- ## How to use it
79
+ | Goal | What to do | Scope |
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 |
86
+ | Inspect shared skills | Review shared Agent skills without changing their metadata. | Read-only |
66
87
 
67
- 1. Open **Settings → Skills**. The panel separates **DSH Skills** from **Shared Agent Skills** and shows each skill's name, form, description, and current invocation status.
68
- 2. In **DSH Skills**, choose **Enable** or **Disable** to control both model invocation and the `/` manual command. Other `SKILL.md` metadata remains untouched.
69
- 3. Choose **Upload Plugin** in the upper-right corner. Select the plugin directory's `SKILL.md`; the complete plugin directory is copied to `$DSH_HOME\skills`.
70
- 4. When the browser does not provide the selected file path, use **Choose Plugin Directory** in the dialog and select the directory that contains `SKILL.md`.
71
- 5. Confirm any replacement of a same-name skill. Use **Delete** only for DSH-local skills you no longer need.
72
- 6. Treat **Shared Agent Skills** as an inspection list: their controls are deliberately unavailable so the shared global skill metadata stays unchanged.
88
+ Press **Escape** to close only the frontmost upload or confirmation dialog; the Settings page remains open.
73
89
 
74
90
  ## Development and verification
75
91
 
@@ -83,18 +99,18 @@ node test\core-test.mjs
83
99
 
84
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.
85
101
 
86
- ## Publishing
102
+ ## Maintainer release
87
103
 
88
- The user-level npm registry may point to a download mirror such as `registry.npmmirror.com`; mirrors do not accept publication. This package therefore pins publication to the official npm registry. A maintainer with npm publishing access can release the current version with:
104
+ The package is published as [`@michengai/dsh-skills-manager`](https://www.npmjs.com/package/@michengai/dsh-skills-manager). To release a new version:
89
105
 
90
106
  ```powershell
91
107
  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
92
108
  $OutputEncoding = [System.Text.Encoding]::UTF8
93
- npm login --registry=https://registry.npmjs.org/
109
+ npm version patch
94
110
  npm publish
95
111
  ```
96
112
 
97
- `prepublishOnly` runs the core test suite before publication, and `publishConfig.registry` prevents `npm publish` from going to a configured download mirror. Use `npm run pack:check` to inspect the exact tarball contents without publishing.
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.
98
114
 
99
115
  ## License
100
116
 
package/README.zh-CN.md CHANGED
@@ -5,11 +5,12 @@
5
5
  <h1 align="center">DSH Skills Manager</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>管理本地 DeepSeek Harness 技能,并为公共 Agent 技能提供清晰、安全的查看边界。</strong>
8
+ <strong>通过 npm 安装的 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
+ · <a href="https://www.npmjs.com/package/@michengai/dsh-skills-manager">在 npm 查看</a>
13
14
  </p>
14
15
 
15
16
  <p align="center">
@@ -18,6 +19,7 @@
18
19
 
19
20
  <p align="center">
20
21
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="Apache License 2.0"></a>
22
+ <a href="https://www.npmjs.com/package/@michengai/dsh-skills-manager"><img src="https://img.shields.io/npm/v/%40michengai/dsh-skills-manager?label=npm" alt="npm 包"></a>
21
23
  <img src="https://img.shields.io/badge/DSH-Web%20Plugin-10b981" alt="DSH Web Plugin">
22
24
  <img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933?logo=nodedotjs&logoColor=white" alt="Node.js 20 或更高版本">
23
25
  </p>
@@ -37,6 +39,7 @@
37
39
  - **公共技能安全查看**:公共 Agent 技能可见但严格只读,绝不会改写其全局元数据。
38
40
  - **系统原生选择**:通过操作系统文件选择器选择插件的 `SKILL.md`;客户端无法提供所选文件路径时,可显式选择插件目录。
39
41
  - **覆盖保护**:导入同名技能一定要求确认;flat 与 bundle 两种形态同名时同样如此。
42
+ - **更安全的导入**:拒绝从 DSH 技能目录自身、其父目录或子目录导入,也拒绝符号链接。
40
43
  - **弹窗行为可预期**:按 ESC 只关闭当前上传框或确认框,不会关闭设置页面。
41
44
 
42
45
  ## 目录权限
@@ -48,11 +51,17 @@
48
51
 
49
52
  公共 Agent 目录在界面和服务端均为只读,用于避免误改共享的全局技能元数据。
50
53
 
51
- ## 安装
54
+ ## 安全行为
52
55
 
53
- 环境要求:可正常运行的 DeepSeek Harness Web 环境。已发布的包会声明 DSH 运行时需要 Node.js 20 或更高版本。
56
+ - 启用、停用和删除只接受单个普通技能名称,目录穿越名称会被拒绝。
57
+ - 覆盖前会先复制到同目录临时路径;复制成功前不会触碰现有技能。
58
+ - 写入接口要求 JSON 与 DSH 客户端请求标记,跨站浏览器请求无法触发本地文件操作。
54
59
 
55
- 将已发布 npm 包安装到 Web profile:
60
+ ## 快速开始
61
+
62
+ 环境要求:可正常运行的 DeepSeek Harness Web 环境。不要在任意目录执行 `npm install`;应将插件安装到 DSH Web profile。
63
+
64
+ 1. 执行:
56
65
 
57
66
  ```powershell
58
67
  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
@@ -60,16 +69,23 @@ $OutputEncoding = [System.Text.Encoding]::UTF8
60
69
  dsh plugin --profile web add @michengai/dsh-skills-manager
61
70
  ```
62
71
 
63
- 无需下载源码。重启 DSH 后,打开「设置 技能」。若软件源镜像尚未同步最新版本,可在安装命令末尾添加 `--registry=https://registry.npmjs.org/`。
72
+ 2. 重启 DSH 或重新加载当前 Web profile。
73
+ 3. 打开「设置 → 技能」。无需下载源码。
74
+
75
+ 若软件源镜像尚未同步最新版本,可在安装命令末尾添加 `--registry=https://registry.npmjs.org/`。
76
+
77
+ ## 使用技能面板
64
78
 
65
- ## 使用说明
79
+ | 目标 | 操作 | 范围 |
80
+ | --- | --- | --- |
81
+ | 查看技能 | 打开「设置 → 技能」,查看名称、形态、说明和调用状态。 | DSH 与公共 Agent 技能 |
82
+ | 启用或停用 | 点击「启用」或「停用」。它会同步控制模型调用和 `/` 手动命令,不会改写 `SKILL.md` 的其他元数据。 | 仅 DSH 技能 |
83
+ | 上传插件 | 点击「上传插件」,选择插件目录内的 `SKILL.md`。DSH 会复制完整目录,包括脚本和资源。 | 仅 DSH 技能 |
84
+ | 改选插件目录 | 若所选文件没有可用路径,点击「选择插件文件夹」,并选择包含 `SKILL.md` 的目录。 | 仅 DSH 技能 |
85
+ | 覆盖或删除 | 同名时按提示确认覆盖;使用「删除」移除不再需要的 DSH 本地技能。 | 仅 DSH 技能 |
86
+ | 查看公共技能 | 查看公共 Agent 技能,不修改其元数据。 | 只读 |
66
87
 
67
- 1. 打开「设置 → 技能」。面板会区分「DSH 技能」和「公共 Agent 技能」,并展示每个技能的名称、形态、说明和调用状态。
68
- 2. 仅在「DSH 技能」分组中使用「启用」或「停用」。它会同步控制模型调用和 `/` 手动命令,且不会改写 `SKILL.md` 的其他元数据。
69
- 3. 点击右上角「上传插件」,选择插件目录中的 `SKILL.md`;整个插件目录会复制到 `$DSH_HOME\skills`。
70
- 4. 若客户端无法提供所选文件路径,在弹窗中点击「选择插件文件夹」,并选择包含 `SKILL.md` 的目录。
71
- 5. 遇到同名技能时,根据提示确认是否覆盖;仅在「DSH 技能」分组中使用「删除」移除不再需要的插件。
72
- 6. 「公共 Agent 技能」仅供智能查看,不提供启停、上传、覆盖或删除,以保护共享的全局技能元数据。
88
+ ESC 只关闭最上层上传框或确认框,设置页保持打开。
73
89
 
74
90
  ## 开发与验证
75
91
 
@@ -83,18 +99,18 @@ node test\core-test.mjs
83
99
 
84
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`。
85
101
 
86
- ## 发布 npm 包
102
+ ## 维护者发布
87
103
 
88
- 用户级 npm registry 可能指向 `registry.npmmirror.com` 等下载镜像;镜像不接受发布。本包已固定发布目标为官方 npm registry,拥有 npm 发布权限的维护者可执行:
104
+ 本包已发布为 [`@michengai/dsh-skills-manager`](https://www.npmjs.com/package/@michengai/dsh-skills-manager)。发布新版本时执行:
89
105
 
90
106
  ```powershell
91
107
  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
92
108
  $OutputEncoding = [System.Text.Encoding]::UTF8
93
- npm login --registry=https://registry.npmjs.org/
109
+ npm version patch
94
110
  npm publish
95
111
  ```
96
112
 
97
- 发布前会由 `prepublishOnly` 自动运行核心测试,`publishConfig.registry` 会避免 `npm publish` 写入下载镜像。执行 `npm run pack:check` 可在不发布的情况下检查最终 tarball 内容。
113
+ 发布前会由 `prepublishOnly` 自动运行核心测试。包始终发布到官方 npm registry;可先执行 `npm run pack:check` 检查 tarball 内容。
98
114
 
99
115
  ## 许可证
100
116
 
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-skills-manager client half:DSH 设置中的单目录技能管理面板。
1
+ // dsh-skills-manager client half:DSH 设置中的本地管理与公共 Agent 只读技能面板。
2
2
  window.__ModuleLoader__.load({
3
3
  id: "dsh-skills-manager",
4
4
  factory: (require) => {
@@ -7,6 +7,7 @@ window.__ModuleLoader__.load({
7
7
  var react = require("react");
8
8
  var h = react.createElement;
9
9
  var pickDirectory = null;
10
+ var MUTATION_HEADERS = { "content-type": "application/json", "x-dsh-skills-manager": "1" };
10
11
 
11
12
  var CSS = `
12
13
  .dssm-section{display:flex;min-width:0;flex-direction:column;gap:20px;color:var(--dsw-alias-label-primary)}
@@ -85,7 +86,7 @@ window.__ModuleLoader__.load({
85
86
  function action(path, body) {
86
87
  if (busy) return;
87
88
  setBusy(path);
88
- callApi(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }).then(function () {
89
+ callApi(path, { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify(body || {}) }).then(function () {
89
90
  setBusy(null);
90
91
  refresh();
91
92
  }).catch(function (error) {
@@ -135,7 +136,7 @@ window.__ModuleLoader__.load({
135
136
  function executeImport(source, conflict) {
136
137
  setBusy("import");
137
138
  setConfirmImport(null);
138
- callApi("/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source: source, conflict: conflict }) }).then(function (data) {
139
+ callApi("/import", { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify({ source: source, conflict: conflict }) }).then(function (data) {
139
140
  setBusy(null);
140
141
  setImportResult(data);
141
142
  setSelected(null);
@@ -150,7 +151,7 @@ window.__ModuleLoader__.load({
150
151
  function installSelected() {
151
152
  if (!selected || busy) return;
152
153
  setBusy("import-check");
153
- callApi("/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source: selected.source, dryRun: true }) }).then(function (data) {
154
+ callApi("/import", { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify({ source: selected.source, dryRun: true }) }).then(function (data) {
154
155
  if (data.conflicts && data.conflicts.length) {
155
156
  setBusy(null);
156
157
  setConfirmImport({ source: selected.source, conflicts: data.conflicts });
@@ -165,14 +166,16 @@ window.__ModuleLoader__.load({
165
166
 
166
167
  function skillRow(skill, root) {
167
168
  var enabled = skill.modelInvocable && skill.userInvocable;
169
+ var policyValid = skill.invocationPolicyValid !== false;
170
+ var actionEnabled = policyValid && enabled;
168
171
  return h("div", { key: skill.name, className: "dssm-row" },
169
172
  h("div", { className: "dssm-row-main" },
170
173
  h("div", { className: "dssm-row-id" },
171
174
  h("span", { className: "dssm-row-name" }, skill.name),
172
175
  h("span", { className: "dssm-tag" }, skill.kind === "bundle" ? "目录插件" : "单文件"),
173
- h("span", { className: "dssm-tag " + (enabled ? "dssm-tag-on" : "dssm-tag-off") }, enabled ? "已启用" : "已停用")),
176
+ h("span", { className: "dssm-tag " + (policyValid && enabled ? "dssm-tag-on" : "dssm-tag-off") }, policyValid ? (enabled ? "已启用" : "已停用") : "配置异常")),
174
177
  h("div", { className: "dssm-note", title: skill.description || "" }, skill.description || "未提供简介")),
175
- root.mutable ? h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { action(enabled ? "/disable" : "/enable", { name: skill.name, root: root.key }); } }, enabled ? "停用" : "启用") : null,
178
+ root.mutable ? h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { action(actionEnabled ? "/disable" : "/enable", { name: skill.name, root: root.key }); } }, actionEnabled ? "停用" : policyValid ? "启用" : "修复并启用") : null,
176
179
  root.mutable ? h("button", { className: "dssm-btn dssm-btn-danger", disabled: !!busy, onClick: function () { setConfirmDelete(skill); } }, "删除") : null);
177
180
  }
178
181
 
@@ -201,8 +204,9 @@ window.__ModuleLoader__.load({
201
204
  if (importResult) {
202
205
  var importedNames = (importResult.imported || []).map(function (item) { return item.name; });
203
206
  var failedItems = importResult.failed || [];
207
+ var skippedNames = (importResult.skipped || []).map(function (item) { return item.name; });
204
208
  var failedText = failedItems.map(function (item) { return item.error; }).join(";");
205
- var resultText = importResult.error ? "上传失败:" + importResult.error : failedItems.length ? (importedNames.length ? "部分上传成功:" + importedNames.join("、") + ";失败:" + failedText : "上传失败:" + failedText) : "上传完成:" + importedNames.join("、");
209
+ var resultText = importResult.error ? "上传失败:" + importResult.error : failedItems.length ? (importedNames.length ? "部分上传成功:" + importedNames.join("、") + ";失败:" + failedText : "上传失败:" + failedText) : importedNames.length ? "上传完成:" + importedNames.join("、") : skippedNames.length ? "未导入:同名插件已跳过:" + skippedNames.join("、") : "未导入任何插件。";
206
210
  nodes.push(h("div", { key: "import-result", className: importResult.error || failedItems.length ? "dssm-error" : "dssm-note", role: "status" }, resultText));
207
211
  }
208
212
  if (uploadOpen) {
package/lib/core.js CHANGED
@@ -5,13 +5,16 @@
5
5
  // - 条目形态:<root>/<name>/SKILL.md(bundle)或 <root>/<name>.md(flat),只扫一层
6
6
  // - 前端展示 name、description 与启停状态,不做格式检查或自动修复
7
7
  //
8
- // 所有函数返回普通结果对象,失败时返回 { ok: false, error };写文件/日志失败静默降级。
8
+ // 所有函数返回普通结果对象,业务校验失败返回 { ok: false, error };文件写入错误由路由返回给调用方。
9
9
 
10
10
  import { homedir } from "node:os";
11
- import { join, basename, dirname } from "node:path";
11
+ import { join, basename, dirname, resolve, relative, isAbsolute, sep } from "node:path";
12
12
  import { promises as fs } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
13
14
 
14
15
  const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const WINDOWS_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
17
+ const MAX_SOURCE_DEPTH = 64;
15
18
 
16
19
  // ── 路径解析 ────────────────────────────────────────────────────────────────
17
20
 
@@ -35,6 +38,29 @@ export function logPath() {
35
38
  return join(resolveDshHome(), "dsh-skills-manager.log");
36
39
  }
37
40
 
41
+ function dshRootPath() {
42
+ return userRoots().find((root) => root.key === "dsh").path;
43
+ }
44
+
45
+ /** 判断 child 是否与 parent 相同或位于其内部。跨盘符时 relative 会返回绝对路径。 */
46
+ function isSameOrDescendant(parent, child) {
47
+ const rel = relative(resolve(parent), resolve(child));
48
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
49
+ }
50
+
51
+ /** 两个路径重叠时,覆盖导入可能删除自身来源,必须拒绝。 */
52
+ function pathsOverlap(a, b) {
53
+ return isSameOrDescendant(a, b) || isSameOrDescendant(b, a);
54
+ }
55
+
56
+ /** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
57
+ function entryPath(root, name) {
58
+ if (typeof name !== "string" || name === "" || name === "." || name === ".." || /[\\/\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
59
+ const rootPath = resolve(root);
60
+ const path = resolve(rootPath, name);
61
+ return isSameOrDescendant(rootPath, path) && rootPath !== path ? path : null;
62
+ }
63
+
38
64
  // ── 命名规整 ────────────────────────────────────────────────────────────────
39
65
 
40
66
  /** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
@@ -81,9 +107,11 @@ export function parseSkillDoc(text) {
81
107
  const content = [];
82
108
  while (i + 1 < end && (/^\s/.test(lines[i + 1]) || lines[i + 1] === "")) {
83
109
  i++;
84
- content.push(lines[i].replace(/^\s{1,2}/, ""));
110
+ content.push(lines[i]);
85
111
  }
86
- map[m[1]] = block[1] === ">" ? content.join(" ").replace(/\s+/g, " ").trim() : content.join("\n").trim();
112
+ const indentation = content.filter((line) => line.trim() !== "").reduce((min, line) => Math.min(min, (/^\s*/.exec(line) || [""])[0].length), Infinity);
113
+ const normalized = content.map((line) => Number.isFinite(indentation) ? line.slice(Math.min(indentation, line.length)) : line);
114
+ map[m[1]] = block[1] === ">" ? normalized.join(" ").replace(/\s+/g, " ").trim() : normalized.join("\n").trim();
87
115
  } else {
88
116
  map[m[1]] = decodeYamlScalar(m[2]);
89
117
  }
@@ -118,42 +146,31 @@ export function unquote(v) {
118
146
  return s;
119
147
  }
120
148
 
121
- /** 把 JS 值序列化为 YAML 标量;需要转义的字符串用 JSON 引号形式。 */
122
- export function yamlScalar(v) {
123
- if (v === true) return "true";
124
- if (v === false) return "false";
125
- if (v === null || v === undefined) return "null";
126
- if (typeof v === "number") return String(v);
127
- const s = String(v);
128
- if (s === "") return '""';
129
- if (/^(true|false|null|yes|no|on|off|~)$/i.test(s) || /^[-+]?\d+(\.\d+)?$/.test(s)) return JSON.stringify(s);
130
- if (/[:#{}[\]&*,?|>%@`"'\s]/.test(s) || s !== s.trim()) return JSON.stringify(s);
131
- return s;
132
- }
133
-
134
- export function serializeSkillDoc(map, body) {
135
- let out = "---\n";
136
- for (const key of Object.keys(map)) {
137
- out += `${key}: ${yamlScalar(map[key])}\n`;
138
- }
139
- out += "---\n";
140
- out += body;
141
- return out;
142
- }
143
-
144
149
  /** 仅更新调用策略行,避免重写并破坏其他 YAML frontmatter。 */
145
150
  function updateInvocationPolicy(text, enabled) {
146
151
  const source = String(text);
147
152
  const newline = source.includes("\r\n") ? "\r\n" : "\n";
148
153
  const lines = source.split(/\r?\n/);
149
- if (lines[0]?.trim() !== "---") return source;
154
+ if (lines[0]?.trim() !== "---") return null;
150
155
  const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
151
- if (end < 0) return source;
156
+ if (end < 0) return null;
152
157
  const fields = lines.slice(1, end).filter((line) => !/^(disable-model-invocation|user-invocable)\s*:/.test(line));
153
158
  if (!enabled) fields.push("disable-model-invocation: true", "user-invocable: false");
154
159
  return ["---", ...fields, "---", ...lines.slice(end + 1)].join(newline);
155
160
  }
156
161
 
162
+ /** 同目录临时文件加 rename,避免写入中断时截断原 SKILL.md。 */
163
+ async function writeFileAtomically(path, content) {
164
+ const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
165
+ try {
166
+ await fs.writeFile(temp, content, "utf8");
167
+ await fs.rename(temp, path);
168
+ } catch (error) {
169
+ await fs.rm(temp, { force: true }).catch(() => undefined);
170
+ throw error;
171
+ }
172
+ }
173
+
157
174
  /** 解析布尔字段值;合法布尔返回 true/false,非法返回 undefined。 */
158
175
  export function parseBoolValue(raw) {
159
176
  const v = unquote(raw).trim().toLowerCase();
@@ -166,24 +183,30 @@ export function parseBoolValue(raw) {
166
183
 
167
184
  /** 按名称解析条目(bundle 优先,其次 flat)。找不到返回 null。 */
168
185
  export async function resolveEntry(root, name) {
169
- if (typeof name !== "string" || name === "" || basename(name) !== name) return null;
170
- const bundleDoc = join(root, name, "SKILL.md");
186
+ const bundlePath = entryPath(root, name);
187
+ if (bundlePath === null) return null;
188
+ const rootPath = resolve(root);
189
+ const bundleDoc = join(bundlePath, "SKILL.md");
171
190
  try {
172
191
  const st = await fs.stat(bundleDoc);
173
- if (st.isFile()) return { kind: "bundle", docPath: bundleDoc };
192
+ if (st.isFile()) return { kind: "bundle", docPath: bundleDoc, entryPath: bundlePath };
174
193
  } catch {}
175
- const flatDoc = join(root, `${name}.md`);
194
+ const flatDoc = resolve(rootPath, `${name}.md`);
195
+ if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc) return null;
176
196
  try {
177
197
  const st = await fs.stat(flatDoc);
178
- if (st.isFile()) return { kind: "flat", docPath: flatDoc };
198
+ if (st.isFile()) return { kind: "flat", docPath: flatDoc, entryPath: flatDoc };
179
199
  } catch {}
180
200
  return null;
181
201
  }
182
202
 
183
203
  function entryOf(name, kind, docPath, doc) {
184
204
  const description = doc.map.description !== undefined ? unquote(doc.map.description) : "";
185
- const modelDisabled = parseBoolValue(doc.map["disable-model-invocation"]) === true;
186
- const userDisabled = parseBoolValue(doc.map["user-invocable"]) === false;
205
+ const modelValue = parseBoolValue(doc.map["disable-model-invocation"]);
206
+ const userValue = parseBoolValue(doc.map["user-invocable"]);
207
+ const modelDisabled = modelValue === true;
208
+ const userDisabled = userValue === false;
209
+ const invocationPolicyValid = (doc.map["disable-model-invocation"] === undefined || modelValue !== undefined) && (doc.map["user-invocable"] === undefined || userValue !== undefined);
187
210
  return {
188
211
  name,
189
212
  kind,
@@ -191,6 +214,7 @@ function entryOf(name, kind, docPath, doc) {
191
214
  description,
192
215
  modelInvocable: !modelDisabled,
193
216
  userInvocable: !userDisabled,
217
+ invocationPolicyValid,
194
218
  };
195
219
  }
196
220
 
@@ -211,7 +235,7 @@ export async function scanEntries(root) {
211
235
  if (!st.isFile()) continue;
212
236
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
213
237
  entries.push(entryOf(it.name, "bundle", docPath, doc));
214
- } else if (it.isFile() && it.name.endsWith(".md") && it.name !== "SKILL.md") {
238
+ } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
215
239
  const docPath = join(root, it.name);
216
240
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
217
241
  entries.push(entryOf(it.name.slice(0, -3), "flat", docPath, doc));
@@ -228,22 +252,23 @@ export async function scanEntries(root) {
228
252
 
229
253
  /** enabled=true 恢复模型与 / 手动调用;false 同时停用两种调用入口。 */
230
254
  export async function setSkillEnabled(root, name, enabled) {
231
- if (root !== userRoots()[0].path) return { ok: false, error: "公共 Agent 技能目录不允许启用或停用" };
255
+ if (root !== dshRootPath()) return { ok: false, error: "公共 Agent 技能目录不允许启用或停用" };
232
256
  const resolved = await resolveEntry(root, name);
233
257
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}` };
234
258
  const source = await fs.readFile(resolved.docPath, "utf8");
235
- await fs.writeFile(resolved.docPath, updateInvocationPolicy(source, enabled), "utf8");
259
+ const updated = updateInvocationPolicy(source, enabled);
260
+ if (updated === null) return { ok: false, error: `技能缺少完整 frontmatter,无法${enabled ? "启用" : "停用"}: ${name}` };
261
+ await writeFileAtomically(resolved.docPath, updated);
236
262
  return { name, enabled };
237
263
  }
238
264
 
239
265
  /** 删除 DSH 根目录中的单个技能。调用方必须先向用户确认。 */
240
266
  export async function deleteSkill(root, name, log) {
241
- if (root !== userRoots()[0].path) return { ok: false, error: "公共 Agent 技能目录不允许删除" };
267
+ if (root !== dshRootPath()) return { ok: false, error: "公共 Agent 技能目录不允许删除" };
242
268
  const resolved = await resolveEntry(root, name);
243
269
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}` };
244
- const entryPath = resolved.kind === "bundle" ? join(root, name) : resolved.docPath;
245
- await fs.rm(entryPath, { recursive: resolved.kind === "bundle", force: true });
246
- if (log) log("delete", `删除 ${entryPath}`);
270
+ await fs.rm(resolved.entryPath, { recursive: resolved.kind === "bundle", force: true });
271
+ if (log) log("delete", `删除 ${resolved.entryPath}`);
247
272
  return { name };
248
273
  }
249
274
 
@@ -253,10 +278,11 @@ export async function deleteSkill(root, name, log) {
253
278
  async function analyzeSource(source) {
254
279
  let st;
255
280
  try {
256
- st = await fs.stat(source);
281
+ st = await fs.lstat(source);
257
282
  } catch {
258
283
  return { kind: "none", error: `路径不存在: ${source}` };
259
284
  }
285
+ if (st.isSymbolicLink()) return { kind: "none", error: `不支持包含符号链接的 skill 来源: ${source}` };
260
286
  if (st.isDirectory()) {
261
287
  try {
262
288
  const sk = join(source, "SKILL.md");
@@ -282,6 +308,7 @@ async function collectCandidates(dir) {
282
308
  const items = await fs.readdir(dir, { withFileTypes: true });
283
309
  const out = [];
284
310
  for (const it of items) {
311
+ if (it.isSymbolicLink()) throw new Error(`不支持包含符号链接的 skill 来源: ${join(dir, it.name)}`);
285
312
  try {
286
313
  if (it.isDirectory()) {
287
314
  const sk = join(dir, it.name, "SKILL.md");
@@ -289,7 +316,7 @@ async function collectCandidates(dir) {
289
316
  if (st.isFile()) {
290
317
  out.push({ source: join(dir, it.name), kebab: toKebab(it.name), rawName: it.name, isDir: true });
291
318
  }
292
- } else if (it.isFile() && it.name.endsWith(".md") && it.name !== "SKILL.md") {
319
+ } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
293
320
  out.push({ source: join(dir, it.name), kebab: toKebab(it.name.slice(0, -3)), rawName: it.name.slice(0, -3), isDir: false });
294
321
  }
295
322
  } catch {
@@ -299,24 +326,92 @@ async function collectCandidates(dir) {
299
326
  return out;
300
327
  }
301
328
 
329
+ /** 导入内容不接受符号链接,避免把目标目录外的内容带入技能目录。 */
330
+ async function assertNoSymbolicLinks(source) {
331
+ const pending = [{ path: source, depth: 0 }];
332
+ while (pending.length) {
333
+ const current = pending.pop();
334
+ if (current.depth > MAX_SOURCE_DEPTH) throw new Error(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`);
335
+ const st = await fs.lstat(current.path);
336
+ if (st.isSymbolicLink()) throw new Error(`不支持包含符号链接的 skill 来源: ${current.path}`);
337
+ if (!st.isDirectory()) continue;
338
+ const items = await fs.readdir(current.path, { withFileTypes: true });
339
+ for (const item of items) {
340
+ const path = join(current.path, item.name);
341
+ if (item.isSymbolicLink()) throw new Error(`不支持包含符号链接的 skill 来源: ${path}`);
342
+ if (item.isDirectory()) pending.push({ path, depth: current.depth + 1 });
343
+ }
344
+ }
345
+ }
346
+
347
+ function temporaryPath(target, kind) {
348
+ return join(dirname(target), `.${basename(target)}.dssm-${kind}-${randomUUID()}`);
349
+ }
350
+
351
+ /** 先复制到同目录临时路径,复制失败时不触碰现有技能。 */
352
+ async function copyToTemporary(source, target, isDir) {
353
+ const temp = temporaryPath(target, "stage");
354
+ try {
355
+ await assertNoSymbolicLinks(source);
356
+ if (isDir) await fs.cp(source, temp, { recursive: true, dereference: false });
357
+ else await fs.copyFile(source, temp);
358
+ return temp;
359
+ } catch (error) {
360
+ await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ /** 临时副本就绪后再替换;替换失败时尽力恢复旧条目。 */
366
+ async function replaceWithCopy(source, dest, isDir, existing = []) {
367
+ const stage = await copyToTemporary(source, dest, isDir);
368
+ const backups = [];
369
+ try {
370
+ for (const path of existing) {
371
+ const backup = temporaryPath(path, "backup");
372
+ await fs.rename(path, backup);
373
+ backups.push({ path, backup });
374
+ }
375
+ await fs.rename(stage, dest);
376
+ } catch (error) {
377
+ await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
378
+ for (const item of backups.reverse()) await fs.rename(item.backup, item.path).catch(() => undefined);
379
+ throw error;
380
+ }
381
+ const warnings = [];
382
+ for (const item of backups) {
383
+ try {
384
+ await fs.rm(item.backup, { recursive: true, force: true });
385
+ } catch (error) {
386
+ warnings.push(`旧版本备份未清理: ${item.backup}(${String(error && error.message ? error.message : error)})`);
387
+ }
388
+ }
389
+ return warnings;
390
+ }
391
+
302
392
  /**
303
393
  * 导入技能到目标根。
304
394
  * options: { conflict: 'skip'|'overwrite', dryRun: boolean }
305
395
  * 成功返回 { kind, imported, skipped, failed };失败返回 { ok:false, error }。
306
396
  */
307
397
  export async function importSkill(source, log, options = {}) {
308
- const targetRoot = userRoots()[0].path;
398
+ const targetRoot = dshRootPath();
309
399
  const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
310
400
  const dryRun = options.dryRun === true;
311
401
 
312
402
  const analysis = await analyzeSource(source);
313
403
  if (analysis.kind === "none") return { ok: false, error: analysis.error || "无法识别的 skill 来源" };
404
+ if (pathsOverlap(analysis.source, targetRoot)) return { ok: false, error: "导入来源不能与 DSH 技能目录相同、包含或位于其中" };
314
405
 
315
406
  let candidates = [];
316
407
  if (analysis.kind === "single") {
317
408
  candidates = [{ source: analysis.source, kebab: analysis.kebab, rawName: analysis.rawName, isDir: analysis.isDir }];
318
409
  } else {
319
- candidates = await collectCandidates(source);
410
+ try {
411
+ candidates = await collectCandidates(source);
412
+ } catch (error) {
413
+ return { ok: false, error: String(error && error.message ? error.message : error) };
414
+ }
320
415
  if (candidates.length === 0) return { ok: false, error: `目录下未找到任何 skill 条目(需含 SKILL.md 的子目录或 .md 文件): ${source}` };
321
416
  }
322
417
 
@@ -326,6 +421,11 @@ export async function importSkill(source, log, options = {}) {
326
421
  const imported = [];
327
422
  const skipped = [];
328
423
 
424
+ const nameCount = new Map();
425
+ for (const candidate of candidates) {
426
+ if (candidate.kebab && KEBAB_RE.test(candidate.kebab)) nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
427
+ }
428
+
329
429
  function failureResult() {
330
430
  return {
331
431
  ok: false,
@@ -342,6 +442,10 @@ export async function importSkill(source, log, options = {}) {
342
442
  failed.push({ source: c.source, error: `无法生成合法 kebab-case 名称(原始名: ${c.rawName || basename(c.source)})` });
343
443
  continue;
344
444
  }
445
+ if (nameCount.get(c.kebab) > 1) {
446
+ failed.push({ source: c.source, error: `批量来源中存在多个同名插件: ${c.kebab}` });
447
+ continue;
448
+ }
345
449
  const dest = c.isDir ? join(targetRoot, c.kebab) : join(targetRoot, `${c.kebab}.md`);
346
450
  const paths = [join(targetRoot, c.kebab), join(targetRoot, `${c.kebab}.md`)];
347
451
  const existing = [];
@@ -367,9 +471,8 @@ export async function importSkill(source, log, options = {}) {
367
471
 
368
472
  for (const p of pending) {
369
473
  try {
370
- if (p.isDir) await fs.cp(p.source, p.dest, { recursive: true });
371
- else await fs.copyFile(p.source, p.dest);
372
- imported.push({ name: p.name, overwritten: false, warnings: [] });
474
+ const warnings = await replaceWithCopy(p.source, p.dest, p.isDir);
475
+ imported.push({ name: p.name, overwritten: false, warnings });
373
476
  if (log) log("import", `导入 ${p.source} -> ${p.dest}`);
374
477
  } catch (e) {
375
478
  failed.push({ source: p.source, error: String(e && e.message ? e.message : e) });
@@ -380,10 +483,8 @@ export async function importSkill(source, log, options = {}) {
380
483
  for (const c of conflicts) {
381
484
  try {
382
485
  const dest = c.isDir ? join(targetRoot, c.name) : join(targetRoot, `${c.name}.md`);
383
- for (const path of c.paths) await fs.rm(path, { recursive: true, force: true });
384
- if (c.isDir) await fs.cp(c.source, dest, { recursive: true });
385
- else await fs.copyFile(c.source, dest);
386
- imported.push({ name: c.name, overwritten: true, warnings: [] });
486
+ const warnings = await replaceWithCopy(c.source, dest, c.isDir, c.paths);
487
+ imported.push({ name: c.name, overwritten: true, warnings });
387
488
  if (log) log("import-overwrite", `覆盖导入 ${c.source} -> ${dest}`);
388
489
  } catch (e) {
389
490
  failed.push({ source: c.source, error: String(e && e.message ? e.message : e) });
@@ -413,6 +514,7 @@ export async function state() {
413
514
  description: e.description,
414
515
  modelInvocable: e.modelInvocable,
415
516
  userInvocable: e.userInvocable,
517
+ invocationPolicyValid: e.invocationPolicyValid,
416
518
  });
417
519
  }
418
520
  result.roots.push({ key: root.key, path: root.path, label: root.label, mutable: root.mutable, exists, skills });
package/lib/index.js CHANGED
@@ -3,48 +3,81 @@
3
3
  // - 路由:/api/dsh-skills-manager/state | enable | disable | delete | import
4
4
  // - $DSH_HOME\skills 可上传、删除、启停;$DSH_AGENTS_HOME\skills 仅允许查看
5
5
 
6
- import { appendFile } from "node:fs/promises";
6
+ import { appendFile, rename, rm, stat } from "node:fs/promises";
7
7
  import { state, setSkillEnabled, deleteSkill, importSkill, userRoots, logPath } from "./core.js";
8
8
 
9
9
  const name = "skills-manager";
10
10
  const inject = ["webServer", "skills"];
11
+ const CLIENT_MARKER_HEADER = "x-dsh-skills-manager";
12
+ const MAX_LOG_BYTES = 1 << 20;
11
13
 
12
14
  function makeLog() {
13
15
  const file = logPath();
16
+ let queue = Promise.resolve();
14
17
  return async (event, detail) => {
15
- try {
16
- await appendFile(file, `${JSON.stringify({ ts: new Date().toISOString(), event, detail })}\n`, "utf8");
17
- } catch {
18
- /* 日志失败不阻塞主流程 */
19
- }
18
+ queue = queue.then(async () => {
19
+ try {
20
+ const current = await stat(file).catch(() => null);
21
+ if (current && current.size >= MAX_LOG_BYTES) {
22
+ await rm(`${file}.1`, { force: true });
23
+ await rename(file, `${file}.1`);
24
+ }
25
+ await appendFile(file, `${JSON.stringify({ ts: new Date().toISOString(), event, detail })}\n`, "utf8");
26
+ } catch {
27
+ /* 日志失败不阻塞主流程 */
28
+ }
29
+ });
30
+ await queue;
20
31
  };
21
32
  }
22
33
 
23
34
  function readBody(req, limit = 1 << 20) {
24
35
  return new Promise((resolve, reject) => {
25
36
  let size = 0;
37
+ let settled = false;
26
38
  const chunks = [];
39
+ const fail = (error) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ reject(error);
43
+ };
27
44
  req.on("data", (c) => {
45
+ if (settled) return;
28
46
  size += c.length;
29
47
  if (size > limit) {
30
- reject(new Error("body too large"));
31
- req.destroy();
48
+ const error = new Error("body too large");
49
+ error.statusCode = 413;
50
+ fail(error);
51
+ req.resume();
32
52
  return;
33
53
  }
34
54
  chunks.push(c);
35
55
  });
36
56
  req.on("end", () => {
57
+ if (settled) return;
37
58
  try {
38
59
  const raw = Buffer.concat(chunks).toString("utf8");
39
- resolve(raw ? JSON.parse(raw) : {});
60
+ const body = raw ? JSON.parse(raw) : {};
61
+ settled = true;
62
+ resolve(body);
40
63
  } catch (e) {
41
- reject(new Error(`invalid JSON body: ${e.message}`));
64
+ const error = new Error(`invalid JSON body: ${e.message}`);
65
+ error.statusCode = 400;
66
+ fail(error);
42
67
  }
43
68
  });
44
- req.on("error", reject);
69
+ req.on("error", fail);
45
70
  });
46
71
  }
47
72
 
73
+ /** 自定义请求头使跨站 fetch 必须预检;本地接口不提供 CORS 响应。 */
74
+ function validateMutationRequest(req) {
75
+ if (req.headers[CLIENT_MARKER_HEADER] !== "1") return { statusCode: 403, error: "forbidden mutation request" };
76
+ const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
77
+ if (contentType !== "application/json") return { statusCode: 415, error: "content-type must be application/json" };
78
+ return null;
79
+ }
80
+
48
81
  function json(res, code, payload) {
49
82
  const body = JSON.stringify(payload);
50
83
  res.writeHead(code, {
@@ -104,6 +137,11 @@ function apply(ctx) {
104
137
  json(res, 405, { ok: false, error: `method not allowed: ${req.method}` });
105
138
  return;
106
139
  }
140
+ const requestError = validateMutationRequest(req);
141
+ if (requestError) {
142
+ json(res, requestError.statusCode, { ok: false, error: requestError.error });
143
+ return;
144
+ }
107
145
  const body = await readBody(req);
108
146
  switch (path) {
109
147
  case "/api/dsh-skills-manager/enable": {
@@ -128,7 +166,7 @@ function apply(ctx) {
128
166
  json(res, 404, { ok: false, error: `unknown action: ${path}` });
129
167
  }
130
168
  } catch (e) {
131
- json(res, 500, { ok: false, error: String(e && e.message ? e.message : e) });
169
+ json(res, Number.isInteger(e && e.statusCode) ? e.statusCode : 500, { ok: false, error: String(e && e.message ? e.message : e) });
132
170
  }
133
171
  },
134
172
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.0",
4
- "description": "DSH Web plugin: manage local DSH skills and safely view shared Agent skills.",
3
+ "version": "0.1.2",
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": {
7
7
  "type": "git",
@@ -47,9 +47,12 @@
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@deepseek-ai/cordis": ">=4.0.1 <5.0.0",
50
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.0 <0.2.0",
51
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.0 <0.2.0",
50
52
  "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.5",
51
53
  "@deepseek-ai/dsh-skill": ">=0.1.0-rc.5",
52
- "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.0-rc.5"
54
+ "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.0-rc.5",
55
+ "react": "^18.2.0"
53
56
  },
54
57
  "engines": {
55
58
  "node": ">=20"