dsh-toolbox-ui 1.6.1 → 1.6.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.en.md +4 -3
- package/README.md +4 -3
- package/docs/installation.md +7 -7
- package/docs/skills.md +23 -2
- package/lib/client.js +88 -46
- package/lib/index.js +68 -17
- package/lib/skills-file-ops.js +48 -7
- package/lib/skills-frontmatter.js +149 -9
- package/lib/skills-registry.js +49 -10
- package/lib/typert.js +1 -0
- package/lib/whats-new.js +17 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -43,6 +43,7 @@ An **MCP management panel and skills manager** for DeepSeek Harness Web: click t
|
|
|
43
43
|
- **Two entry points, one panel**: the draggable floating button (position remembered) and the official sidebar entry share the same state; the "Entry" control in the panel header can keep just one of them (never neither)
|
|
44
44
|
- **MCP market**: 47 services across 10 categories (search, dev, security & reverse engineering, data, AI & knowledge, browser automation, cloud & infra, productivity, files, comms), each verified against first-party sources. Search-first, with category and "keyless only" filters, sorting, and one-click install from the card; every entry states its free tier honestly and what you must supply (key / connection details / arguments to edit after install). Already-configured entries are detected and skipped, never overwritten
|
|
45
45
|
- Import MCP configuration from other clients installed on this machine: Claude Code, Codex (`config.toml`), OpenCode (including `.jsonc`), pi, Claude Desktop, Cursor, Windsurf, VS Code, Gemini CLI, Roo Code, and the cross-tool shared config are detected and listed, and one click imports from any of them; you can also paste `mcpServers` JSON from Claude, Cursor, Cline, or Roo, or `servers` JSON from VS Code. Everything is previewed before writing, with "merge" and "replace" modes
|
|
46
|
+
- **Skills pane** (the toolbox's second ability): see every installed skill's **real scope** and cross-scope shadowing, enable/disable (`.disabled`), remove, read the body and diagnostics, and **flip "model auto-invocation" with one switch** — it edits `disable-model-invocation` in `SKILL.md` in place, touching only that one line, and the state always comes from the file itself (unreadable frontmatter reads as "unknown", never a guess); the market is backed by skillhub.cn with an honest disclosure and an explicit acknowledgement before install. See [Skills](docs/skills.md)
|
|
46
47
|
- **Moving servers between workspaces**: copy this project's MCP servers into another registered workspace in one go (done inside the Host, so credentials never pass through the browser), or export the whole project configuration as JSON to download / copy (same shape as `.dsh/mcp.json`, ready to drop into another workspace). See [Moving servers between workspaces](#moving-servers-between-workspaces)
|
|
47
48
|
- Follows the DSH dark/light theme and adapts to narrow/mobile widths
|
|
48
49
|
- Update notice and **one-click upgrade**: a dismissible banner when a newer release exists (at most one check per day; disable with `DSH_MCP_MANAGER_DISABLE_UPDATE_CHECK`). Nothing happens until you press Upgrade — it **never auto-updates**. The upgrade runs through DSH's official pluginManager and tells you to restart the host when done. A local dev copy installed via `link:` / `file:` / `github:` gets no button (upgrading it would replace your link with an npm package)
|
|
@@ -52,10 +53,10 @@ An **MCP management panel and skills manager** for DeepSeek Harness Web: click t
|
|
|
52
53
|
|
|
53
54
|
```sh
|
|
54
55
|
# Recommended: install from npm — `dsh plugin update` then upgrades within ^1.x automatically
|
|
55
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
56
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.2
|
|
56
57
|
|
|
57
58
|
# Or pin a GitHub release tag (no automatic upgrades; re-add with a new tag to upgrade)
|
|
58
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#v1.6.
|
|
59
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#v1.6.2
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
Then **restart `dsh web`**. To upgrade:
|
|
@@ -157,7 +158,7 @@ Three different things, and the detail pane gives the specific reason: **mount f
|
|
|
157
158
|
Respectively: no session of that project holds the shared connection yet / the configuration changed but the old connection is still being reused (see above) / how many sessions currently share it.
|
|
158
159
|
|
|
159
160
|
**How do I upgrade? Why did the panel not notify me?**
|
|
160
|
-
If you installed from npm, `dsh plugin --profile web update dsh-toolbox-ui` upgrades within `^1.x` — or just press Upgrade on the panel banner, which does the same thing without the command. The banner only queries
|
|
161
|
+
If you installed from npm, `dsh plugin --profile web update dsh-toolbox-ui` upgrades within `^1.x` — or just press Upgrade on the panel banner, which does the same thing without the command. The banner only queries the npm registry (this package's latest dist-tag), at most once a day, is dismissible, and never acts on its own. Tag-pinned installs never cross versions — re-add with the new tag. ⚠️ **1.6.1 and earlier asked a different source** (this repository's release feed, whose only latest entry carries the other product line and is marked pre-release), so they will not announce 1.6.2 — the fix ships inside 1.6.2 itself; run `dsh plugin --profile web update dsh-toolbox-ui` once by hand.
|
|
161
162
|
|
|
162
163
|
**Will editing touch my other configuration?**
|
|
163
164
|
No. Global edits only rewrite the entries declared by `@deepseek-ai/dsh-mcp-client` that this plugin manages, preserving other plugin rows, comments, and `!!js` expressions; "replace" imports only replace that part and never delete MCPs owned by another bundle or agent preset.
|
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ DeepSeek Harness Web 的 **MCP 管理面板 + 技能管理面板**:右下角
|
|
|
40
40
|
- **全局 + 项目双作用域**:全局写 Web profile,一次注册所有项目可用;项目写该项目 `.dsh/mcp.json`,只该项目会话可见;项目里还能「屏蔽」某个全局 MCP
|
|
41
41
|
- **两个入口,同一个面板**:右下角悬浮按钮(可拖拽、记忆位置)与官方侧栏入口(展开态在「设置」上方显示「MCP」,收起成 56px 轨道时只剩图标)共享同一开关状态;面板右上角「入口」可以把两者只留一个(不能都关)
|
|
42
42
|
- **MCP 市场**:47 个逐条一手核实过的服务,分 10 类(搜索、开发工具、安全与逆向、数据库与数据、AI 与知识、浏览器自动化、云与基础设施、效率协作、文件与本地、通讯)。搜索为主操作,配分类与「只要免密钥」筛选、排序、卡片直接安装;每条如实标注免费额度与需要准备什么(密钥 / 连接信息 / 装完还得改的参数)。已配置的只识别并跳过,不覆盖
|
|
43
|
+
- **技能面**(工具箱的第二个能力面):看本机全部技能的**真实作用域**与同名遮蔽、启停(`.disabled`)、删除、读正文与诊断,并**一键切换「模型自动调用」**——直接改 `SKILL.md` 的 `disable-model-invocation`,只动那一行、不重排你的文件,状态一律取自文件本身(读不出来就说「未知」);市场接 skillhub.cn,安装前如实披露 + 一次显式确认。详见[技能管理](docs/skills.md)
|
|
43
44
|
- 导入本机其他客户端的 MCP 配置:自动检测 Claude Code、Codex(`config.toml`)、OpenCode(含 `.jsonc`)、pi、Claude Desktop、Cursor、Windsurf、VS Code、Gemini CLI、Roo Code 与跨工具的共享配置,逐个列出,点一条即导入;也能粘贴 Claude/Cursor/Cline/Roo 的 `mcpServers` 或 VS Code 的 `servers` JSON。写入前预览,支持「合并(同名更新)」与「替换」
|
|
44
45
|
- **跨工作区搬运**:在项目标签页里把本项目的 MCP 一次勾选复制到另一个已注册的工作区(在宿主内完成,凭据不经过浏览器),或把整个项目配置导出成 JSON 下载 / 复制(与 `.dsh/mcp.json` 同格式,可直接放进另一个工作区)。详见[工作区之间复制与导出](#工作区之间复制与导出)
|
|
45
46
|
- 跟随 DSH 深色/浅色主题,适配窄屏与移动宽度
|
|
@@ -50,10 +51,10 @@ DeepSeek Harness Web 的 **MCP 管理面板 + 技能管理面板**:右下角
|
|
|
50
51
|
|
|
51
52
|
```sh
|
|
52
53
|
# 推荐:npm 安装 —— 之后 `dsh plugin update` 会在 ^1.x 内自动升到最新小版本
|
|
53
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
54
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.2
|
|
54
55
|
|
|
55
56
|
# 或固定 GitHub release tag(不会自动跨版本,升级要换 tag 重新 add)
|
|
56
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#v1.6.
|
|
57
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#v1.6.2
|
|
57
58
|
```
|
|
58
59
|
|
|
59
60
|
装完**重启 `dsh web`**。升级:
|
|
@@ -155,7 +156,7 @@ dsh plugin --profile web remove dsh-toolbox-ui
|
|
|
155
156
|
它们把两件独立的事分开说:**连接在不在** —— `已连接 · 当前无会话使用(连接保留,下次直接用)`(最后一个会话结束了,连接按空闲策略留着)/ `已连接 · N 个会话在用 · M 工具` / `连接中…` / `连接失败` / `尚未建立连接(新会话自动挂载)`;**几个会话在用** —— 就是那句里的 `N 个会话在用`。旧版本的「待会话挂载」正是把这两件事压成一句,所以容易被读成"还没连上"。
|
|
156
157
|
|
|
157
158
|
**怎么升级?为什么面板没提示?**
|
|
158
|
-
从 npm 装的直接 `dsh plugin --profile web update dsh-toolbox-ui` 就会升小版本,也可以直接点面板提示条上的「升级」(同一件事,免去敲命令)。面板的提示条只查
|
|
159
|
+
从 npm 装的直接 `dsh plugin --profile web update dsh-toolbox-ui` 就会升小版本,也可以直接点面板提示条上的「升级」(同一件事,免去敲命令)。面板的提示条只查 npm registry 上**本包的 latest**、每天最多一次、可关闭、不会自己动;固定 tag 安装的用户不会自动跨版本,需要用新 tag 重新 `add`(见 [安装与升级](docs/installation.md))。⚠️ **1.6.1 及更早的版本问的是另一个源**(本仓库的 release feed,那里只有一条被标成 pre-release 的另一产品线),所以它们不会提示 1.6.2——这个修复装在 1.6.2 自己里,先手动 `dsh plugin --profile web update dsh-toolbox-ui` 升一次即可。
|
|
159
160
|
|
|
160
161
|
**改动会不会碰到我的其他配置?**
|
|
161
162
|
不会。全局改动只动 profile 补丁里由 `@deepseek-ai/dsh-mcp-client` 声明、且由本插件管理的条目,保留其他插件条目、注释与 `!!js` 表达式;导入「替换」也只替换这一部分,不会删除其他 bundle 或 Agent preset 自带的 MCP。
|
package/docs/installation.md
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
推荐用 npm 安装:包里的 `^1.x` 范围让后续 `dsh plugin update` 能自动升级小版本。
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
18
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.2
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
也可以固定 GitHub release tag(适合需要锁定某个具体版本时):
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
# 固定 release tag
|
|
25
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#v1.6.
|
|
25
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#v1.6.2
|
|
26
26
|
|
|
27
27
|
# 或固定某个 commit
|
|
28
28
|
dsh plugin --profile web add github:Imzl-zl/dsh-mcp-manager-ui#<commit>
|
|
@@ -73,7 +73,7 @@ dsh plugin --profile web update dsh-toolbox-ui
|
|
|
73
73
|
|
|
74
74
|
| 安装规格 | `update` 的效果 | 怎么跨版本 |
|
|
75
75
|
|---|---|---|
|
|
76
|
-
| `dsh-toolbox-ui@^1.6.
|
|
76
|
+
| `dsh-toolbox-ui@^1.6.2`(npm,推荐) | 在 `^1.x` 内自动升到最新小版本,不会跨到 2.0.0 | 已是自动;跨大版本显式安装 `@^2` |
|
|
77
77
|
| `github:...#v1.4.0`(tag) | 重新解析同一个 tag,**不会动** | 换成新 tag 重新 `add`:`... #<新 tag>` |
|
|
78
78
|
| `github:...#<commit>` | 同上,固定在 commit | 用新 commit/tag 重新 `add` |
|
|
79
79
|
| `github:...#main`(分支) | 拉取最新 main | 已是自动;但不推荐生产(无 release 门槛) |
|
|
@@ -81,11 +81,11 @@ dsh plugin --profile web update dsh-toolbox-ui
|
|
|
81
81
|
|
|
82
82
|
为什么 tag 不会自动跨版本:`dsh plugin` 是 `pnpm` 的薄转发器,`update` 就是「按 package.json 里的 spec 再解析一次」,而 git tag 是不可变规格、没有范围语义(`pnpm update --help`:"Updates packages to their latest version **based on the specified range**")。
|
|
83
83
|
|
|
84
|
-
面板自带更新提示与一键升级(查
|
|
84
|
+
面板自带更新提示与一键升级(查 npm registry 上**本包的 latest**,每天最多一次、可关闭、不会自己动;点「升级」才装)。npm 安装的用户也可以用命令行 `update` 升级,两者等价。每个 release 都会同时发布到 npm 与 GitHub Releases,两者版本一致。
|
|
85
85
|
|
|
86
86
|
## 发布流程(维护者)
|
|
87
87
|
|
|
88
|
-
npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm
|
|
88
|
+
npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm 升级,而面板的更新提示问的正是 npm 上本包的 latest。两者版本不一致就会出现「提示说有新版、`update` 却拿不到」。这两件事现在由 `.github/workflows/release.yml` 在推 tag 时自动完成,并且会先校验「tag 与 package.json 版本一致」再跑测试。
|
|
89
89
|
|
|
90
90
|
### 一次性准备(只需做一次)
|
|
91
91
|
|
|
@@ -112,7 +112,7 @@ dsh web # 打开面板点一遍
|
|
|
112
112
|
# 3) 提交并推 tag(CI 会校验 tag 与 version 一致,不一致就拒绝发布)
|
|
113
113
|
git add -A && git commit -m "..."
|
|
114
114
|
git push origin main
|
|
115
|
-
git tag -a v1.6.
|
|
115
|
+
git tag -a v1.6.2 -m "v1.4.2 ..." && git push origin v1.6.2
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
推 tag 后 CI 依次做:校验版本 → `pnpm install --frozen-lockfile` → `npm test` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
|
|
@@ -131,7 +131,7 @@ git tag -a v1.6.1 -m "v1.4.2 ..." && git push origin v1.6.1
|
|
|
131
131
|
|
|
132
132
|
```sh
|
|
133
133
|
npm publish # 会弹 2FA 挑战;若用 bypass-2FA 的 granular token 则非交互
|
|
134
|
-
gh release create v1.6.
|
|
134
|
+
gh release create v1.6.2 --title "dsh-toolbox-ui v1.6.2" --notes-file <notes>
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
`npm login` 的会话 token 只有 2 小时,超时需要重新登录;classic token 已被 npm 永久吊销,能非交互发布的只剩勾了 **Bypass 2FA** 的 granular access token——但它正在被 npm 收窄(已不能做账号/包管理类操作,且 2027-01 起将失去直接发布能力),所以长期方案是上面的 trusted publishing。
|
package/docs/skills.md
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
- 技能到底装在哪个作用域(同名技能跨作用域是合法配置,但模型只会收到生效的那一份);
|
|
8
8
|
- 哪些技能被**停用**了(改名后宿主目录里直接消失,模型和面板都看不见它)——面板会自行扫 `.disabled` 把它们列出来,并给一键恢复;
|
|
9
|
+
- 哪些技能**允许被模型自动调用**,以及就地开关(写的是 `SKILL.md` 的 frontmatter,见[模型自动调用](#允许禁止模型自动调用));
|
|
9
10
|
- 装市场里的技能前,你被如实告知了什么、必须确认什么(披露 + 一次显式确认,不做许可判定)。
|
|
10
11
|
|
|
11
|
-
⚠️ 一个**还没做**的事:宿主对 frontmatter 非法的技能只 `warn` 后跳过,那种技能目前**不会**出现在面板里(要报它得自己扫各根的技能文件并跑一遍校验,见 TODO row 21
|
|
12
|
+
⚠️ 一个**还没做**的事:宿主对 frontmatter 非法的技能只 `warn` 后跳过,那种技能目前**不会**出现在面板里(要报它得自己扫各根的技能文件并跑一遍校验,见 TODO row 21)。面板不会假装能看到它——所以遇到这种技能时,面板给出的调用权限是「未知」而不是一个猜的值。
|
|
12
13
|
|
|
13
14
|
## 打开技能面板
|
|
14
15
|
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
|
|
19
20
|
| 模式 | 用途 |
|
|
20
21
|
|---|---|
|
|
21
|
-
| **已安装** | 左列表 +
|
|
22
|
+
| **已安装** | 左列表 + 右详情。看本机已有的技能,启停、改调用权限、删除、查看正文与诊断 |
|
|
22
23
|
| **发现市场** | 卡片网格。**只有一个来源:skillhub.cn**(外部源、直连上游、只做披露 + 显式确认,也是唯一可安装的来源) |
|
|
23
24
|
|
|
24
25
|
**作用域药丸只在「已安装」模式出现**,而且与列表分组同源(点哪个药丸,就筛出哪一组,
|
|
@@ -48,6 +49,26 @@ DSH 的技能没有「disabled」字段(MCP 有,技能没有),所以启
|
|
|
48
49
|
所以已停用的技能由本面板**自己扫描** `.disabled` 得出,否则你根本看不到它、也没法再启用;
|
|
49
50
|
- 内置(bundled)技能只读,不可启停或删除。
|
|
50
51
|
|
|
52
|
+
## 允许/禁止模型自动调用
|
|
53
|
+
|
|
54
|
+
技能可以被**模型自动调用**(模型自己判断该用它),也可以只在你手动点名时才可用。DSH 用 frontmatter 的
|
|
55
|
+
`disable-model-invocation: true` 表达后者;面板把它做成详情页「技能属性」里的一个开关(**模型自动调用**),
|
|
56
|
+
不必再去手工翻 `SKILL.md`。列表行上那个「仅用户可调用」徽章说的就是同一件事。
|
|
57
|
+
|
|
58
|
+
- **状态一律取自技能文件本身**,不取宿主的发现缓存:宿主对同一个文件的改写会继续报旧值(停用改名就是这样,实测过),
|
|
59
|
+
而这里正是面板要**写**的东西——写盘后显示回退,用户看到的是「点了没反应」。所以行与详情里的调用权限都是
|
|
60
|
+
从那个文件里解析出来的,与正文同一份字节。
|
|
61
|
+
- **读不出来就说「不知道」**:frontmatter 非法的技能宿主会整条跳过,此时面板显示「调用权限未知」且开关不可点。
|
|
62
|
+
编一个「不可调用」出来就是撒谎——**已停用**的技能此前正是这样被显示的(它的权限只有那份 `.disabled` 文件能回答),
|
|
63
|
+
这个缺陷随本开关一并修掉。
|
|
64
|
+
- **只改那一行,不重排你的文件**:关闭时把那**一行**删掉(缺省就是「允许」,所以关掉不留痕迹,开→关能逐字节还原原文);
|
|
65
|
+
打开时若已有该字段就地把值改成 `true`,没有才在 frontmatter **末尾**追加一行。字段顺序、缩进、正文、CRLF 风格都不动。
|
|
66
|
+
- **形状读不准就拒绝**:值是块标量或跨了多行、要在带行尾注释的那一行上**打开**它(关掉是整行删除,
|
|
67
|
+
边界确定,注释随行而去)、或文件里有宿主会拒绝的驼峰键(`disableModelInvocation`)时,
|
|
68
|
+
面板会拒绝并在界面上说明原因,让你手工改——猜一个改法等于改坏你的技能文件。
|
|
69
|
+
- **生效时机**:写的是文件,宿主**重新发现**这条技能之后才影响模型(重启 DSH 最稳妥)。面板显示的是磁盘上的值。
|
|
70
|
+
- 内置(bundled)技能只读,开关呈**禁用**(与启停/删除同一约定:渲染出来但点不动)。
|
|
71
|
+
|
|
51
72
|
## 安装技能
|
|
52
73
|
|
|
53
74
|
市场里点「安装」会先给你一份披露(安装确认弹窗):
|
package/lib/client.js
CHANGED
|
@@ -2823,7 +2823,8 @@ const McpTab = (props) => {
|
|
|
2823
2823
|
return startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, timer, POLL_INTERVAL_MS, () => load(false));
|
|
2824
2824
|
}, [load, timer]);
|
|
2825
2825
|
|
|
2826
|
-
// 更新提示:Host 每天最多查一次
|
|
2826
|
+
// 更新提示:Host 每天最多查一次 npm registry(本包的 latest 端点,见 lib/index.js 的
|
|
2827
|
+
// updateLatestUrl);纯增值信息,任何失败都静默,不打扰面板。
|
|
2827
2828
|
useEffect(() => {
|
|
2828
2829
|
let cancelled = false;
|
|
2829
2830
|
call('updateHint').then((res) => {
|
|
@@ -3520,6 +3521,8 @@ const McpTab = (props) => {
|
|
|
3520
3521
|
sortKey: skillSort,
|
|
3521
3522
|
onSort: setSkillSort,
|
|
3522
3523
|
onSummary: onSkillsSummary,
|
|
3524
|
+
// 技能面的行动作失败时要说出宿主的原话(见 SkillsPanel 里 act 的说明)——与 MCP 面共用这一条 flash。
|
|
3525
|
+
flash,
|
|
3523
3526
|
});
|
|
3524
3527
|
|
|
3525
3528
|
const inner = h(
|
|
@@ -4121,12 +4124,16 @@ const SkillGroupHeader = ({ group, collapsed, onToggleCollapse }) => h(
|
|
|
4121
4124
|
);
|
|
4122
4125
|
|
|
4123
4126
|
/** 管理模式的右侧详情:元信息 + 正文 + 启停/删除(与 MCP 面同一张详情卡片)。 */
|
|
4124
|
-
const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, tab = 'overview', onTab }) => {
|
|
4127
|
+
const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, tab = 'overview', onTab }) => {
|
|
4125
4128
|
if (!row) return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' },
|
|
4126
4129
|
h('b', null, '选择左侧的一个技能'),
|
|
4127
4130
|
h('span', null, '这里会显示它的位置与调用权限、SKILL.md 正文,以及遮蔽或校验失败时的诊断。')));
|
|
4128
4131
|
const disabled = row.enabled === false;
|
|
4129
4132
|
const readonly = row.writable === false;
|
|
4133
|
+
// 调用权限的**三态**:true / false / 未知。未知是真实的一态——宿主对 frontmatter 非法的技能
|
|
4134
|
+
// 整条跳过,那时「能不能被模型调用」没有答案。此前它被渲染成「不可调用」,那是凭空的断言
|
|
4135
|
+
// (已停用的行尤其明显:它们的权限只有我们读得到的那份文件能回答)。
|
|
4136
|
+
const invocationKnown = row.modelInvocable === true || row.modelInvocable === false;
|
|
4130
4137
|
const paneOff = (id) => detailPaneOff(tab, id);
|
|
4131
4138
|
const tabBtn = (id, label, count) => detailTab(tab, onTab, id, label, count);
|
|
4132
4139
|
// 「(加载中…)」只能出现在**真的在加载**的时候。之前失败也落到这句兜底文案上
|
|
@@ -4156,8 +4163,10 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
|
|
|
4156
4163
|
),
|
|
4157
4164
|
h('div', { className: 'dsh-mcp-d-meta' },
|
|
4158
4165
|
h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' }, disabled ? '已停用' : shadowedBy ? '被遮蔽' : '已启用'),
|
|
4159
|
-
|
|
4160
|
-
|
|
4166
|
+
invocationKnown
|
|
4167
|
+
? h(Badge, { cls: row.modelInvocable ? null : 's-off' }, row.modelInvocable ? '模型可调用' : '模型不可调用')
|
|
4168
|
+
: h(Badge, { cls: 's-off', title: 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断它的调用权限' }, '调用权限未知'),
|
|
4169
|
+
invocationKnown && !row.userInvocable ? h(Badge, { cls: 's-off' }, '用户不可调用') : null,
|
|
4161
4170
|
),
|
|
4162
4171
|
h('p', { className: 'dsh-mcp-d-desc' }, row.description || '(无说明)'),
|
|
4163
4172
|
),
|
|
@@ -4191,8 +4200,26 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
|
|
|
4191
4200
|
// 作用域与根目录合成一行:分组之后「哪一级」已由组头说过,这里只补完整路径。
|
|
4192
4201
|
h('b', null, '位置'), h('span', null, (row.root || '(未报告)') + '(' + skillScopeLabel(row) + ')'),
|
|
4193
4202
|
h('b', null, '状态'), h('span', null, disabled ? '已停用(SKILL.md.disabled)' : '已启用'),
|
|
4194
|
-
|
|
4195
|
-
|
|
4203
|
+
// 「模型自动调用」是**可操作**的那一项(issue #5:快速改 disable-model-invocation,
|
|
4204
|
+
// 不必手工去翻 SKILL.md)。写在 SKILL.md 的 frontmatter 里,所以只在可写根上给开关;
|
|
4205
|
+
// 权限读不出来时不装懂——开关禁用并写明未知。
|
|
4206
|
+
h('b', null, '模型自动调用'),
|
|
4207
|
+
h('span', null,
|
|
4208
|
+
h(Toggle, {
|
|
4209
|
+
on: row.modelInvocable === true,
|
|
4210
|
+
disabled: busy || readonly || !invocationKnown,
|
|
4211
|
+
label: row.name + ':允许模型自动调用',
|
|
4212
|
+
title: readonly
|
|
4213
|
+
? '内置技能只读'
|
|
4214
|
+
: invocationKnown
|
|
4215
|
+
? '写入 SKILL.md 的 disable-model-invocation;宿主重新发现这条技能后才影响模型(重启 DSH 最稳妥)'
|
|
4216
|
+
: 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断当前状态',
|
|
4217
|
+
onChange: () => onModelInvocable(row, row.modelInvocable !== true),
|
|
4218
|
+
}),
|
|
4219
|
+
' ',
|
|
4220
|
+
invocationKnown ? (row.modelInvocable ? '允许' : '禁止') : '未知',
|
|
4221
|
+
),
|
|
4222
|
+
h('b', null, '用户可调用'), h('span', null, invocationKnown ? (row.userInvocable ? '是' : '否') : '未知'),
|
|
4196
4223
|
),
|
|
4197
4224
|
),
|
|
4198
4225
|
h('div', { className: 'dsh-mcp-card' + paneOff('text') },
|
|
@@ -4403,7 +4430,7 @@ const HubCard = ({ entry, installed, onInstall }) => h(
|
|
|
4403
4430
|
*/
|
|
4404
4431
|
// ⚠️ 它**不接** onClose / floating / onMode:悬浮外壳(wrapFloating)与模式分段都归 McpTab,
|
|
4405
4432
|
// 面板只负责「技能面那具身体」。留着没人用的 prop 会让下一个人以为这里还能控制面板开关。
|
|
4406
|
-
const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', onScopeFilter, sortKey = 'default', onSort, onSummary }) => {
|
|
4433
|
+
const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', onScopeFilter, sortKey = 'default', onSort, onSummary, flash }) => {
|
|
4407
4434
|
const [rows, setRows] = useState([]);
|
|
4408
4435
|
// ⚠️ 选中存的是**身份**(行 key),行对象从当前 rows **派生**——不存对象本身。
|
|
4409
4436
|
// 存对象会留住"上一次取数时的那个对象":面板里刚启停一个技能后列表刷新,详情却还在渲染
|
|
@@ -4535,56 +4562,69 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
|
|
|
4535
4562
|
void loadBody(row);
|
|
4536
4563
|
}, [loadBody]);
|
|
4537
4564
|
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4565
|
+
// 行动作的统一出口——与 MCP 面的 act 同一条纪律:**失败必须说出来**。宿主拒绝时给的是具体
|
|
4566
|
+
// 原因(「这个字段的值跨了多行,请手工修改这一行」这类),吞掉它就只剩「点了没反应」;而技能面
|
|
4567
|
+
// 的动作真的动用户的文件,那句原因正是用户唯一能据以修好的信息。成功才重新取数。
|
|
4568
|
+
// 返回 true = 成功,调用方据此决定要不要做后续(清选中、关弹窗)。
|
|
4569
|
+
const act = useCallback(async (method, payload) => {
|
|
4542
4570
|
setBusy(true);
|
|
4543
4571
|
try {
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4572
|
+
const res = await call(method, payload);
|
|
4573
|
+
if (!res || res.ok !== true) {
|
|
4574
|
+
flash?.((res && res.error) || method + ' 失败', 'err');
|
|
4575
|
+
return false;
|
|
4576
|
+
}
|
|
4547
4577
|
await load();
|
|
4578
|
+
return true;
|
|
4548
4579
|
} finally { setBusy(false); }
|
|
4549
|
-
}, [call, load]);
|
|
4580
|
+
}, [call, flash, load]);
|
|
4581
|
+
|
|
4582
|
+
const toggleRow = useCallback(async (row, enable) => {
|
|
4583
|
+
// 双向:MCP 面每行都有启用/停用开关,技能面必须同样能**恢复**已停用的技能
|
|
4584
|
+
// (原先只有停用单向,已停用条目只能看、没有任何恢复入口)。
|
|
4585
|
+
const next = enable === undefined ? row.enabled === false : enable === true;
|
|
4586
|
+
// 只送**引用**(source + root + 项目路径),不送 writable:可写与否由服务端判定,
|
|
4587
|
+
// 客户端传什么不算数(否则「只读根不可写」只是客户端的礼貌)。
|
|
4588
|
+
await act('setEnabled', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, enabled: next });
|
|
4589
|
+
}, [act]);
|
|
4590
|
+
|
|
4591
|
+
const toggleModelInvocable = useCallback(async (row, modelInvocable) => {
|
|
4592
|
+
// 「允许模型自动调用」的开关:写技能文件里的 disable-model-invocation(issue #5)。
|
|
4593
|
+
// 与启停同一条纪律:只送引用,可写与否由服务端判定。
|
|
4594
|
+
// 重新取数在 act 里:行上的权限来自**磁盘上的那个文件**(宿主侧从文件解析),刷出来的就是
|
|
4595
|
+
// 真正写下去的值;被拒绝时文件没变,开关如实停在原位,而原因由 act 说出来。
|
|
4596
|
+
await act('setModelInvocable', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, modelInvocable: modelInvocable === true });
|
|
4597
|
+
}, [act]);
|
|
4550
4598
|
|
|
4551
4599
|
const removeRow = useCallback(async (row) => {
|
|
4552
4600
|
// 「删除技能」是**真删**(宿主 removeSkill:目录捆绑连目录、扁平删文件)。
|
|
4553
4601
|
// 之前这个菜单项接的是 toggleRow —— 点删除只会把技能停用,还报成功。
|
|
4554
4602
|
if (!confirm('确认删除技能「' + row.name + '」?' + (row.kind === 'bundle' ? '整个目录(含 scripts/references)' : '该文件') + '会从磁盘移除,不可撤销。')) return;
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
setSelectedKey('');
|
|
4559
|
-
await load();
|
|
4560
|
-
} finally { setBusy(false); }
|
|
4561
|
-
}, [call, load]);
|
|
4603
|
+
// 删成功才清选中:失败时那一行还在,清掉选中只会让用户面对一个空详情、还不知道为什么。
|
|
4604
|
+
if (await act('removeSkill', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd })) setSelectedKey('');
|
|
4605
|
+
}, [act]);
|
|
4562
4606
|
|
|
4563
4607
|
const confirmInstall = useCallback(async (target) => {
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
setHubDetail(null);
|
|
4585
|
-
await load();
|
|
4586
|
-
} finally { setBusy(false); }
|
|
4587
|
-
}, [call, installTargets, load, scope]);
|
|
4608
|
+
// 落点按**安装目标的唯一身份**(source + root)解析:两个工作区会有同一个 source,
|
|
4609
|
+
// 按 source `find` 会静默装进第一个(用户以为选了项目 B,实际写进项目 A)。
|
|
4610
|
+
const option = installTargets.find((item) => item.id === scope) || installTargets[0] || {};
|
|
4611
|
+
// writable 是显式声明:宿主默认按可写处理,所以这里的 true 必须与
|
|
4612
|
+
// 「只列可写目标」这个前提一致,不能靠默认值蒙。
|
|
4613
|
+
//
|
|
4614
|
+
// 市场只有外部源这一条路:宿主下载上游 zip 再走**同一个** importSkill 落盘——
|
|
4615
|
+
// 落点因此仍然由面板选,而不是上游说了算。
|
|
4616
|
+
const ok = await act('skillHubInstall', {
|
|
4617
|
+
slug: target.slug,
|
|
4618
|
+
version: target.version,
|
|
4619
|
+
// 落点只送引用;`acknowledged` 是**服务端**要求的确认(UI 的勾选框只是它的界面)。
|
|
4620
|
+
source: option.source,
|
|
4621
|
+
root: option.root,
|
|
4622
|
+
projectPath: option.projectPath,
|
|
4623
|
+
acknowledged: true,
|
|
4624
|
+
});
|
|
4625
|
+
// 装失败时弹窗留在原地(用户要能看到失败原因、能重试),只有成功才收起来。
|
|
4626
|
+
if (ok) { setEntry(null); setHubDetail(null); }
|
|
4627
|
+
}, [act, installTargets, scope]);
|
|
4588
4628
|
|
|
4589
4629
|
// 列表过滤:作用域(第二行的药丸)→ 状态 → 搜索词 → 排序。
|
|
4590
4630
|
// 顺序有讲究:先按作用域收窄,才知道「这个作用域里有几个已停用」。
|
|
@@ -4696,6 +4736,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
|
|
|
4696
4736
|
busy,
|
|
4697
4737
|
onRetry: () => void loadBody(selected),
|
|
4698
4738
|
onToggle: toggleRow,
|
|
4739
|
+
onModelInvocable: toggleModelInvocable,
|
|
4699
4740
|
onRemove: removeRow,
|
|
4700
4741
|
}),
|
|
4701
4742
|
)
|
|
@@ -4999,6 +5040,7 @@ const MCP_CONTRIBUTION = {
|
|
|
4999
5040
|
sk('list', [skParam('payload', 'SkillPayload')], 'SkillListResult'),
|
|
5000
5041
|
sk('detail', [skParam('payload', 'SkillPayload')], 'SkillDetailResult'),
|
|
5001
5042
|
sk('setEnabled', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5043
|
+
sk('setModelInvocable', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5002
5044
|
sk('removeSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5003
5045
|
sk('importSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
|
|
5004
5046
|
sk('skillTargets', [skParam('payload', 'SkillPayload')], 'SkillTargetsResult'),
|
package/lib/index.js
CHANGED
|
@@ -12,8 +12,8 @@ import { admitImportedServers } from "./import-admission.js";
|
|
|
12
12
|
import { collectImportLocations, missingEnvNames, resolveImportLocation } from "./mcp-import-sources.js";
|
|
13
13
|
import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observability.js";
|
|
14
14
|
import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
|
|
15
|
-
import { listSkillViews, resolveTargetReference, skillRowKey, skillTargetsView } from "./skills-registry.js";
|
|
16
|
-
import { importSkill,
|
|
15
|
+
import { invocationOf, listSkillViews, resolveTargetReference, skillRowKey, skillTargetsView } from "./skills-registry.js";
|
|
16
|
+
import { importSkill, readSkillDocument, removeSkill, resolveSkillTarget, scanDisabledSkills, setSkillEnabled, setSkillModelInvocable } from "./skills-file-ops.js";
|
|
17
17
|
import { downloadHubArchive, hubCategories, hubSkillDetail, searchHubSkills } from "./skillhub.js";
|
|
18
18
|
import { setOwn } from "./own-property.js";
|
|
19
19
|
import { WHATS_NEW } from "./whats-new.js";
|
|
@@ -300,10 +300,18 @@ function containsRedactedValue(value) {
|
|
|
300
300
|
return false;
|
|
301
301
|
}
|
|
302
302
|
// ---------- 更新提示(被动、可关闭、绝不自动更新) ----------
|
|
303
|
-
// 面板打开时客户端会调一次 updateHint:Host 每天最多向
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
303
|
+
// 面板打开时客户端会调一次 updateHint:Host 每天最多向 npm registry 发一次查询(进程内缓存),
|
|
304
|
+
// 有新版时返回供面板显示可关闭的提示条。查询失败、超时、限流或被
|
|
305
|
+
// DSH_MCP_MANAGER_DISABLE_UPDATE_CHECK 禁用时静默无提示——这是有意设计:更新提示是纯增值信息,
|
|
306
|
+
// 不允许影响面板可用性;除这一条 GET 外不发送任何数据。
|
|
307
|
+
//
|
|
308
|
+
// ⚠️ 问的是**本包在 npm 上的 latest**,不是本仓库的 `/releases/latest`:同一个仓库承载两条产品线
|
|
309
|
+
// (本线 `dsh-toolbox-ui` 与老线那个包——两个名字见 release.yml),而仓库级 "latest" 是**共用**的单个
|
|
310
|
+
// 槽位,且必须留给老线——老线(≤1.5.3)面板的「升级」按钮读的正是那里,下发的却是**它自己那个包名**的
|
|
311
|
+
// 版本,所以本线的 Release 一直带 `--prerelease`(见 release.yml 的说明),让 latest 停在老线的
|
|
312
|
+
// 版本上。本线若也读那个 feed,读到的永远是旧线的版本号 → 提示恒为「已是最新」:新版本发出去,
|
|
313
|
+
// 装了本线的用户什么也看不到(v1.6.1 → v1.6.2 就这样哑掉过一次)。
|
|
314
|
+
// 包名由 package.json 派生(pluginName):写死任何一个包名都会在另一条线上静默错。
|
|
307
315
|
/**
|
|
308
316
|
* 让位检测(`conflictingManager`)的自身判据:**loader 条目的 `name` 等于本包名**。
|
|
309
317
|
*
|
|
@@ -333,12 +341,20 @@ function isMcpManagerPluginName(name) {
|
|
|
333
341
|
return lower.includes("mcp") && lower.includes("manager");
|
|
334
342
|
}
|
|
335
343
|
|
|
336
|
-
const UPDATE_CHECK_URL = "https://api.github.com/repos/Imzl-zl/dsh-mcp-manager-ui/releases/latest";
|
|
337
344
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
338
345
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
339
346
|
const UPDATE_CHECK_DISABLED_ENV = "DSH_MCP_MANAGER_DISABLE_UPDATE_CHECK";
|
|
340
347
|
let updateCheckState = { checkedAt: 0, result: null };
|
|
341
348
|
|
|
349
|
+
/** 本包在 npm 上的 latest 端点;包名读不到就没有端点(当作无提示,不猜一个包名)。 */
|
|
350
|
+
function updateLatestUrl() {
|
|
351
|
+
const name = pluginName();
|
|
352
|
+
return name === "" ? null : `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 说明页:两条线的 tag 都是 vX.Y.Z,故由版本号拼本仓库的 release 页(与老线的 updateHint 一致)。 */
|
|
356
|
+
const releasePageOf = (version) => `https://github.com/Imzl-zl/dsh-mcp-manager-ui/releases/tag/v${version}`;
|
|
357
|
+
|
|
342
358
|
function pluginVersion() {
|
|
343
359
|
try {
|
|
344
360
|
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "";
|
|
@@ -376,15 +392,22 @@ async function checkForUpdate(now = Date.now(), force = false) {
|
|
|
376
392
|
if (!force && cached && now - updateCheckState.checkedAt < UPDATE_CHECK_INTERVAL_MS && cached.current === current) return cached;
|
|
377
393
|
let latest = null;
|
|
378
394
|
let url = null;
|
|
395
|
+
const endpoint = updateLatestUrl();
|
|
379
396
|
try {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
397
|
+
if (endpoint !== null) {
|
|
398
|
+
const response = await fetch(endpoint, {
|
|
399
|
+
headers: { accept: "application/json" },
|
|
400
|
+
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
|
401
|
+
});
|
|
402
|
+
if (response.ok) {
|
|
403
|
+
const body = await response.json();
|
|
404
|
+
// npm 的 `/<包名>/latest` 只给版本号,没有页面链接——提示条要指得出「更新了什么」,
|
|
405
|
+
// 所以由版本号拼 release 页(两线共用 vX.Y.Z 的 tag 命名)。
|
|
406
|
+
if (typeof body?.version === "string" && body.version !== "") {
|
|
407
|
+
latest = body.version;
|
|
408
|
+
url = releasePageOf(latest);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
388
411
|
}
|
|
389
412
|
} catch {
|
|
390
413
|
// 离线/超时/限流:视作无新版,提示缺失比面板报错更可接受。
|
|
@@ -1425,7 +1448,7 @@ let McpManagerGateway = (() => {
|
|
|
1425
1448
|
let SkillsManagerGateway = (() => {
|
|
1426
1449
|
let _classSuper = TypertRemoteService;
|
|
1427
1450
|
let _instanceExtraInitializers = [];
|
|
1428
|
-
let _list_decorators, _detail_decorators, _setEnabled_decorators, _removeSkill_decorators, _importSkill_decorators, _skillTargets_decorators, _skillHubSearch_decorators, _skillHubDetail_decorators, _skillHubCategories_decorators, _skillHubInstall_decorators, _conflictingManager_decorators;
|
|
1451
|
+
let _list_decorators, _detail_decorators, _setEnabled_decorators, _setModelInvocable_decorators, _removeSkill_decorators, _importSkill_decorators, _skillTargets_decorators, _skillHubSearch_decorators, _skillHubDetail_decorators, _skillHubCategories_decorators, _skillHubInstall_decorators, _conflictingManager_decorators;
|
|
1429
1452
|
return class SkillsManagerGateway extends _classSuper {
|
|
1430
1453
|
static {
|
|
1431
1454
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
@@ -1435,6 +1458,8 @@ let SkillsManagerGateway = (() => {
|
|
|
1435
1458
|
__esDecorate(this, null, _detail_decorators, { kind: "method", name: "detail", static: false, private: false, access: { has: (obj) => "detail" in obj, get: (obj) => obj.detail }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1436
1459
|
_setEnabled_decorators = [Remote("setEnabled")];
|
|
1437
1460
|
__esDecorate(this, null, _setEnabled_decorators, { kind: "method", name: "setEnabled", static: false, private: false, access: { has: (obj) => "setEnabled" in obj, get: (obj) => obj.setEnabled }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1461
|
+
_setModelInvocable_decorators = [Remote("setModelInvocable")];
|
|
1462
|
+
__esDecorate(this, null, _setModelInvocable_decorators, { kind: "method", name: "setModelInvocable", static: false, private: false, access: { has: (obj) => "setModelInvocable" in obj, get: (obj) => obj.setModelInvocable }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1438
1463
|
_removeSkill_decorators = [Remote("removeSkill")];
|
|
1439
1464
|
__esDecorate(this, null, _removeSkill_decorators, { kind: "method", name: "removeSkill", static: false, private: false, access: { has: (obj) => "removeSkill" in obj, get: (obj) => obj.removeSkill }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1440
1465
|
_importSkill_decorators = [Remote("importSkill")];
|
|
@@ -1480,6 +1505,10 @@ let SkillsManagerGateway = (() => {
|
|
|
1480
1505
|
source: target.source,
|
|
1481
1506
|
cwd: target.projectPath,
|
|
1482
1507
|
writable: target.writable !== false,
|
|
1508
|
+
// 调用权限与可见行走**同一处**推导(skills-registry 的 invocationOf):宿主目录里
|
|
1509
|
+
// 看不到停用件,这个文件是唯一能回答「能不能被模型调用」的地方。缺了它,客户端
|
|
1510
|
+
// 会把「未知」渲染成「不可调用」——一个凭空的断言。
|
|
1511
|
+
...(await invocationOf({ name: row.name, root: target.root, scope: target.scope })),
|
|
1483
1512
|
});
|
|
1484
1513
|
}
|
|
1485
1514
|
}
|
|
@@ -1519,10 +1548,15 @@ let SkillsManagerGateway = (() => {
|
|
|
1519
1548
|
scope: target.scope,
|
|
1520
1549
|
writable: target.writable !== false,
|
|
1521
1550
|
});
|
|
1551
|
+
// 正文与调用权限来自**同一次读**(readSkillDocument 的契约):分两次读会配错版本,
|
|
1552
|
+
// 用户看到的就是「这份正文 + 别人的权限」。
|
|
1553
|
+
const document = await readSkillDocument(resolved);
|
|
1522
1554
|
return {
|
|
1523
1555
|
skill: {
|
|
1524
1556
|
name,
|
|
1525
|
-
content:
|
|
1557
|
+
content: document.text,
|
|
1558
|
+
modelInvocable: document.invocation?.modelInvocable ?? null,
|
|
1559
|
+
userInvocable: document.invocation?.userInvocable ?? null,
|
|
1526
1560
|
kind: resolved.kind,
|
|
1527
1561
|
path: resolved.path,
|
|
1528
1562
|
enabled: resolved.disabled !== true,
|
|
@@ -1548,6 +1582,23 @@ let SkillsManagerGateway = (() => {
|
|
|
1548
1582
|
});
|
|
1549
1583
|
return await setSkillEnabled(resolved, payload?.enabled !== false);
|
|
1550
1584
|
}
|
|
1585
|
+
async setModelInvocable(payload) {
|
|
1586
|
+
// 「允许模型自动调用」的开关:写技能文件的 disable-model-invocation。纪律与 setEnabled
|
|
1587
|
+
// 完全一致——wire 只送引用、服务端核对自己算出来的目标、只读根拒绝;改写的形状规则在
|
|
1588
|
+
// lib/skills-frontmatter.js,读不准时那个错误原样上抛给用户。
|
|
1589
|
+
const target = await resolveTargetReference(this.ctx, {
|
|
1590
|
+
source: payload?.source,
|
|
1591
|
+
root: payload?.root,
|
|
1592
|
+
projectPath: payload?.projectPath,
|
|
1593
|
+
});
|
|
1594
|
+
const resolved = await resolveSkillTarget({
|
|
1595
|
+
name: String(payload?.name ?? ""),
|
|
1596
|
+
root: target.root,
|
|
1597
|
+
scope: target.scope,
|
|
1598
|
+
writable: target.writable !== false,
|
|
1599
|
+
});
|
|
1600
|
+
return await setSkillModelInvocable(resolved, payload?.modelInvocable !== false);
|
|
1601
|
+
}
|
|
1551
1602
|
async removeSkill(payload) {
|
|
1552
1603
|
const target = await resolveTargetReference(this.ctx, {
|
|
1553
1604
|
source: payload?.source,
|
package/lib/skills-file-ops.js
CHANGED
|
@@ -14,9 +14,9 @@ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFi
|
|
|
14
14
|
import { randomBytes } from 'node:crypto'
|
|
15
15
|
import { tmpdir } from 'node:os'
|
|
16
16
|
import { join, relative } from 'node:path'
|
|
17
|
-
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
|
17
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
18
18
|
import { MAX_ARCHIVE_BYTES, extractArchive } from './skills-archive.js'
|
|
19
|
-
import { diagnoseSkill } from './skills-frontmatter.js'
|
|
19
|
+
import { diagnoseSkill, setFrontmatterBoolean } from './skills-frontmatter.js'
|
|
20
20
|
|
|
21
21
|
// 与宿主同一套名字语法(dsh-skill 的 SKILL_NAME)。它天然排除 '.'、'/'、'\\',
|
|
22
22
|
// 所以合法名字不可能跳出根目录——但越界校验仍然保留,因为这是系统边界。
|
|
@@ -166,12 +166,53 @@ export async function scanDisabledSkills(input) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
169
|
+
* 读一个技能文件:正文**和它在磁盘上的调用权限**一次给出。
|
|
170
|
+
*
|
|
171
|
+
* 为什么合成一个入口:分两次读会读到两个版本(改动正好落在两次读之间),面板于是显示一份正文
|
|
172
|
+
* 配一个不属于它的权限。调用权限一律从**这份读到的字节**里解析出来,所以它天然与正文同源。
|
|
173
|
+
*
|
|
174
|
+
* 解析不出来时 `invocation` 是 `null`——**不知道就说不知道**。非法 frontmatter 的技能会被宿主
|
|
175
|
+
* 整条跳过,此时「模型能不能调用它」没有答案,编一个布尔值就是让面板撒谎。
|
|
176
|
+
*
|
|
177
|
+
* @param {object} target - resolveSkillTarget 的结果。
|
|
178
|
+
* @returns {Promise<{ text: string, invocation: null | { modelInvocable: boolean, userInvocable: boolean } }>}
|
|
179
|
+
*/
|
|
180
|
+
export async function readSkillDocument(target) {
|
|
181
|
+
const text = await readFile(target.path, 'utf8')
|
|
182
|
+
return { text, invocation: diagnoseSkill(text).invocation ?? null }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 改一个技能的「模型自动调用」权限——写的是它 frontmatter 里的 disable-model-invocation。
|
|
187
|
+
*
|
|
188
|
+
* 与启停同一套纪律:只读根拒绝、同一把锁(root + name,见 setSkillEnabled 的说明)、
|
|
189
|
+
* 读-改-写全在锁内、原子提交(writeFileAtomic 写临时文件再 rename)。改写规则本身在
|
|
190
|
+
* lib/skills-frontmatter.js:形状读不准时它抛错,本层**原样放行那个错误**——拒绝的原因
|
|
191
|
+
* 只有那一处知道,包一层只会把「哪一行不对」这类信息丢掉。
|
|
192
|
+
*
|
|
193
|
+
* @param {object} target - resolveSkillTarget 的结果(已停用的技能也收,权限与启用态无关)。
|
|
194
|
+
* @param {boolean} modelInvocable - true 允许模型自动调用,false 禁止。
|
|
195
|
+
* @returns {Promise<{ changed: boolean, modelInvocable: boolean }>}
|
|
172
196
|
*/
|
|
173
|
-
export async function
|
|
174
|
-
|
|
197
|
+
export async function setSkillModelInvocable(target, modelInvocable) {
|
|
198
|
+
if (target.writable === false) {
|
|
199
|
+
throw new Error('该技能来自只读根(bundled),不可修改调用权限')
|
|
200
|
+
}
|
|
201
|
+
const next = modelInvocable !== false
|
|
202
|
+
// 锁键与启停/删除/导入一致:同名技能上的这些操作必须彼此串行,否则「读到旧内容 → 写回」
|
|
203
|
+
// 会把另一次操作的结果覆盖掉(例如与删除交错时把技能文件重新写出来)。
|
|
204
|
+
return await withFileLock(join(target.root, target.name), async () => {
|
|
205
|
+
const text = await readFile(target.path, 'utf8')
|
|
206
|
+
// 字段名说的是「禁止」而不是「允许」:modelInvocable === false ⇔ disable-model-invocation: true。
|
|
207
|
+
const written = setFrontmatterBoolean(text, 'disable-model-invocation', !next)
|
|
208
|
+
if (written.changed) {
|
|
209
|
+
// 原子替换是「写临时文件再 rename」,新文件的权限位会退回默认值。技能文件是用户自己的
|
|
210
|
+
// 资产,动它一个字不该顺手把它的权限也改掉——照抄 profile 写盘的纪律,保留原 mode。
|
|
211
|
+
const { mode } = await stat(target.path)
|
|
212
|
+
await writeFileAtomic(target.path, written.text, { mode })
|
|
213
|
+
}
|
|
214
|
+
return { changed: written.changed, modelInvocable: next }
|
|
215
|
+
})
|
|
175
216
|
}
|
|
176
217
|
|
|
177
218
|
/**
|
|
@@ -25,15 +25,17 @@ const LEGACY_INVOCATION_KEYS = new Map([
|
|
|
25
25
|
])
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* frontmatter 的字节范围——**解析与改写共用的唯一一套边界判据**。
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* 为什么抽出来:读与写必须认同一个块。解析认得的块和改写动的块一旦分叉,后果是**静默改坏
|
|
31
|
+
* 用户的技能文件**,而且只在手写的、形状不标准的文件上才会踩到——测试里最容易漏的那一类。
|
|
32
32
|
*
|
|
33
33
|
* @param {string} raw - 技能文件全文。
|
|
34
|
-
* @returns {{ ok: true,
|
|
34
|
+
* @returns {{ ok: true, fieldsStart: number, closingStart: number, bodyStart: number, eol: string }
|
|
35
|
+
* | { ok: false, code: string, message: string }} fieldsStart/closingStart 界定字段区,
|
|
36
|
+
* bodyStart 是正文起点,eol 是这份文件自己的行尾风格(插入新行时照用,别把 CRLF 写成混行)。
|
|
35
37
|
*/
|
|
36
|
-
|
|
38
|
+
function frontmatterSpan(raw) {
|
|
37
39
|
if (typeof raw !== 'string') {
|
|
38
40
|
return { ok: false, code: 'frontmatter-missing', message: '技能内容必须是文本' }
|
|
39
41
|
}
|
|
@@ -45,15 +47,37 @@ export function parseSkillFrontmatter(raw) {
|
|
|
45
47
|
return { ok: false, code: 'frontmatter-missing', message: '技能缺少 YAML frontmatter:首行必须是 ---' }
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
const
|
|
49
|
-
const closing = findClosingFrontmatter(raw,
|
|
50
|
+
const fieldsStart = firstLineEnd + 1
|
|
51
|
+
const closing = findClosingFrontmatter(raw, fieldsStart)
|
|
50
52
|
if (closing === undefined) {
|
|
51
53
|
return { ok: false, code: 'frontmatter-missing', message: '技能缺少 YAML frontmatter:找不到结束的 --- 行' }
|
|
52
54
|
}
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
fieldsStart,
|
|
58
|
+
closingStart: closing.start,
|
|
59
|
+
bodyStart: closing.bodyStart,
|
|
60
|
+
eol: raw[firstLineEnd - 1] === '\r' ? '\r\n' : '\n',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 拆出 frontmatter 与正文,逐字照抄宿主 parseFrontmatter(dsh-skill-filesystem:780-806)。
|
|
66
|
+
*
|
|
67
|
+
* 宿主在这里的宽容度比一般实现低:首行必须**恰好**是 `---`(去尾随 `\r` 后比较),
|
|
68
|
+
* 结束行同理。任何偏差都返回 ok:false,而不是猜一个边界。
|
|
69
|
+
*
|
|
70
|
+
* @param {string} raw - 技能文件全文。
|
|
71
|
+
* @returns {{ ok: true, data: object, body: string, span: object }
|
|
72
|
+
* | { ok: false, code: string, message: string }}
|
|
73
|
+
*/
|
|
74
|
+
export function parseSkillFrontmatter(raw) {
|
|
75
|
+
const span = frontmatterSpan(raw)
|
|
76
|
+
if (!span.ok) return span
|
|
53
77
|
|
|
54
78
|
let parsed
|
|
55
79
|
try {
|
|
56
|
-
parsed = parse(raw.slice(
|
|
80
|
+
parsed = parse(raw.slice(span.fieldsStart, span.closingStart))
|
|
57
81
|
} catch (error) {
|
|
58
82
|
return {
|
|
59
83
|
ok: false,
|
|
@@ -64,7 +88,8 @@ export function parseSkillFrontmatter(raw) {
|
|
|
64
88
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
65
89
|
return { ok: false, code: 'frontmatter-not-mapping', message: 'YAML frontmatter 必须是一个键值映射' }
|
|
66
90
|
}
|
|
67
|
-
|
|
91
|
+
// span 一并交出去:改写方要用**同一套**边界(见 frontmatterSpan 的说明),不该自己再算一遍。
|
|
92
|
+
return { ok: true, data: parsed, body: raw.slice(span.bodyStart), span }
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
/** 找结束的 `---` 行(照抄宿主 findClosingFrontmatter:794-806)。 */
|
|
@@ -115,6 +140,121 @@ function frontmatterBoolean(data, key) {
|
|
|
115
140
|
throw new TypeError(`frontmatter 字段 "${key}" 必须是布尔值`)
|
|
116
141
|
}
|
|
117
142
|
|
|
143
|
+
// ── 改写一个顶层布尔字段 ────────────────────────────────────────────────────
|
|
144
|
+
//
|
|
145
|
+
// 面板的「模型自动调用」开关就落在这里(写 SKILL.md 的 disable-model-invocation)。三条不变量:
|
|
146
|
+
//
|
|
147
|
+
// 1. **只动该动的那一行**:字段顺序、缩进、正文、行尾风格全部逐字保留。所以刻意不走
|
|
148
|
+
// `parse → stringify`——那会把用户的文件整体重排(diff 噪声、丢注释),而技能文件是
|
|
149
|
+
// **用户手写的资产**,不是我们的数据。
|
|
150
|
+
// 2. **语义先于文本**:先用宿主那套规则(frontmatterBoolean)算出当前值,已经是要的那个值
|
|
151
|
+
// 就原样返回 changed:false。于是「关掉」= 删掉那一行(缺省即 false),开关不留痕迹。
|
|
152
|
+
// 3. **读不准就抛错**:值跨行、不是布尔、YAML 本身读不动、有宿主拒绝的驼峰键、键写在解析器
|
|
153
|
+
// 认得而逐行扫描认不出的形状里(引号键 / 流式映射)、值带 YAML 锚点——一律拒绝并说清原因;
|
|
154
|
+
// 另外**打开**时若那一行带行尾注释也拒绝(**关掉**不受注释拦阻:整行删除的边界是确定的,
|
|
155
|
+
// 见下方那条「注释随行而去」的用例)。猜一个改法等于改坏用户的文件;说「我做不到」才是诚实的行为。
|
|
156
|
+
|
|
157
|
+
/** frontmatter 字段区里的每一行(含它自己的 `\r`,不含 `\n`)。 */
|
|
158
|
+
function frontmatterLines(raw, span) {
|
|
159
|
+
const lines = []
|
|
160
|
+
let lineStart = span.fieldsStart
|
|
161
|
+
while (lineStart < span.closingStart) {
|
|
162
|
+
const newline = raw.indexOf('\n', lineStart)
|
|
163
|
+
const end = newline < 0 || newline > span.closingStart ? span.closingStart : newline
|
|
164
|
+
lines.push({ start: lineStart, end, text: raw.slice(lineStart, end) })
|
|
165
|
+
lineStart = end + 1
|
|
166
|
+
}
|
|
167
|
+
return lines
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 这一行是不是**顶层**的 `key:`——缩进行属于别的字段或嵌套结构,不算。 */
|
|
171
|
+
function isTopLevelKey(text, key) {
|
|
172
|
+
if (/^[ \t]/.test(text)) return false
|
|
173
|
+
const content = text.endsWith('\r') ? text.slice(0, -1) : text
|
|
174
|
+
const colon = content.indexOf(':')
|
|
175
|
+
return colon >= 0 && content.slice(0, colon).trim() === key
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 把 frontmatter 里的一个顶层布尔字段改成目标值,其余字节原样保留。
|
|
180
|
+
*
|
|
181
|
+
* @param {string} raw - 技能文件全文。
|
|
182
|
+
* @param {string} key - 顶层字段名(本插件用 disable-model-invocation)。
|
|
183
|
+
* @param {boolean} value - 目标值。
|
|
184
|
+
* @returns {{ text: string, changed: boolean }} changed 为 false 时 text 与输入逐字节相同。
|
|
185
|
+
* @throws {Error} 形状读不准时抛错,绝不猜一个改法。
|
|
186
|
+
*/
|
|
187
|
+
export function setFrontmatterBoolean(raw, key, value) {
|
|
188
|
+
const parsed = parseSkillFrontmatter(raw)
|
|
189
|
+
if (!parsed.ok) throw new Error(`无法改写 frontmatter:${parsed.message}`)
|
|
190
|
+
|
|
191
|
+
// 宿主对驼峰式旧键是**抛错**(整条技能被跳过,:850-861)。此时补一个规范键只会让两个键并存、
|
|
192
|
+
// 宿主照样跳过——面板不能给出「我改好了」的假象,只能点名让用户自己改。
|
|
193
|
+
for (const [legacy, canonical] of LEGACY_INVOCATION_KEYS) {
|
|
194
|
+
if (canonical === key && Object.hasOwn(parsed.data, legacy)) {
|
|
195
|
+
throw new Error(`frontmatter 里有宿主不支持的驼峰键 "${legacy}"(整条技能会被跳过):请先手工改成 "${canonical}"`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let current
|
|
200
|
+
try {
|
|
201
|
+
current = frontmatterBoolean(parsed.data, key)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new Error(`无法改写 ${key}:${error instanceof Error ? error.message : String(error)}。宿主会因此跳过整条技能,请先手工修正这一行`)
|
|
204
|
+
}
|
|
205
|
+
const desired = value === true
|
|
206
|
+
if ((current === true) === desired) return { text: raw, changed: false }
|
|
207
|
+
|
|
208
|
+
const lines = frontmatterLines(raw, parsed.span)
|
|
209
|
+
const at = lines.findIndex((line) => isTopLevelKey(line.text, key))
|
|
210
|
+
if (at < 0) {
|
|
211
|
+
// ⚠️ 追加的前提是「键**真的**不在」。行扫描认不出的形状是存在的:引号键(`"key": true`)与
|
|
212
|
+
// 流式映射(`{key: true}`)解析器都认得、宿主也读得到(面板照它显示权限),
|
|
213
|
+
// 而 isTopLevelKey 匹配不上那一行。此时追加会写出**第二个**同名键 → YAML 重复键让整份
|
|
214
|
+
// frontmatter 报废(宿主随即跳过整条技能),而用户要的那个方向根本没生效。
|
|
215
|
+
// 所以这里的判据取**解析结果**,不取行扫描结果:两者分叉时只能拒绝。
|
|
216
|
+
if (Object.hasOwn(parsed.data, key)) {
|
|
217
|
+
throw new Error(`无法改写 ${key}:这个键写在解析器认得、逐行改写认不出的形状里(引号键或流式映射),请手工修改`)
|
|
218
|
+
}
|
|
219
|
+
// 键确实不在、又要求打开:追加到 frontmatter 块**末尾**。插在 name/description 之前会把后面
|
|
220
|
+
// 每个字段的 diff 都推一行,而这里没有任何理由去动别人的字段。
|
|
221
|
+
return {
|
|
222
|
+
text: raw.slice(0, parsed.span.closingStart) + `${key}: true` + parsed.span.eol + raw.slice(parsed.span.closingStart),
|
|
223
|
+
changed: true,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const line = lines[at]
|
|
228
|
+
const next = lines[at + 1]
|
|
229
|
+
// 续行守卫:下一行只要缩进,值就跨了行——删掉首行会留下孤儿缩进行,整份 frontmatter 随之报废。
|
|
230
|
+
if (next !== undefined && /^[ \t]/.test(next.text)) {
|
|
231
|
+
throw new Error(`无法改写 ${key}:这个字段的值跨了多行(下一行有缩进),逐行改写会破坏 YAML,请手工修改`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const hasCr = line.text.endsWith('\r')
|
|
235
|
+
const content = hasCr ? line.text.slice(0, -1) : line.text
|
|
236
|
+
const colon = content.indexOf(':')
|
|
237
|
+
const rest = content.slice(colon + 1)
|
|
238
|
+
// 锚点守卫:布尔字面量里不可能出现 `&`,所以走到这里还见到它只可能是 YAML 锚点(`key: &flag true`)。
|
|
239
|
+
// 这一行正给别处的 `*flag` 供值——整行删掉或就地换值都会让那个引用悬空、整份 frontmatter 报废。
|
|
240
|
+
// 两个方向都拦,所以放在方向分支之前(判据同样取解析结果那一侧的事实,不猜文本)。
|
|
241
|
+
if (rest.includes('&')) {
|
|
242
|
+
throw new Error(`无法改写 ${key}:这个字段的值带 YAML 锚点(&),别处可能正引用它,请手工修改`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (desired) {
|
|
246
|
+
// 能走到这里只可能是「写的是 false 那一类」(no / off / 0 / false):就地把值换成 true,
|
|
247
|
+
// 键的位置不动。值是块标量或带行尾注释时不动手——那两种改法都得猜。
|
|
248
|
+
if (rest.trim() === '' || rest.includes('#')) {
|
|
249
|
+
throw new Error(`无法改写 ${key}:这一行不是「键: 简单值」的形状,请手工修改`)
|
|
250
|
+
}
|
|
251
|
+
const replaced = content.slice(0, colon + 1) + ' true' + (hasCr ? '\r' : '')
|
|
252
|
+
return { text: raw.slice(0, line.start) + replaced + raw.slice(line.end), changed: true }
|
|
253
|
+
}
|
|
254
|
+
// 关掉:整行连同它自己的换行一起删掉,其余行与行尾风格不变。
|
|
255
|
+
return { text: raw.slice(0, line.start) + raw.slice(line.end + 1), changed: true }
|
|
256
|
+
}
|
|
257
|
+
|
|
118
258
|
/**
|
|
119
259
|
* 诊断一条技能原文,产出可展示的逐条诊断。
|
|
120
260
|
*
|
package/lib/skills-registry.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { access, stat } from 'node:fs/promises'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { dirname, join, resolve } from 'node:path'
|
|
12
|
+
import { readSkillDocument, resolveSkillTarget, SkillNotFoundError } from './skills-file-ops.js'
|
|
12
13
|
|
|
13
14
|
// 宿主的 source 标签(见 dsh-skill-filesystem 的 roots()):project-* 属项目级,
|
|
14
15
|
// 其余属全局级。这是唯一可靠的作用域依据——本机就没有 ~/.dsh/skills,技能全在
|
|
@@ -110,24 +111,62 @@ async function presetScope(ctx) {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/** 「这个技能读不到」:定位不到,或文件系统层面读不了(已被删、无权限、目录不可达)。 */
|
|
115
|
+
function isUnreadable(error) {
|
|
116
|
+
if (error instanceof SkillNotFoundError) return true
|
|
117
|
+
return typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 一行的调用权限——**一律来自技能文件本身**,不取宿主的发现缓存。
|
|
122
|
+
*
|
|
123
|
+
* 为什么不能用宿主 summary 里的那份:summary 是**发现缓存**(同一个文件被改写后它继续报旧值,
|
|
124
|
+
* 改名场景已实测复现,见 list() 里那段「陈旧行」的说明)。而调用权限正是本面板要**写**的东西:
|
|
125
|
+
* 写盘后显示回退,用户看到的是「点了没反应」;更糟的是把缓存里的旧值当事实展示。
|
|
126
|
+
* 行与详情因此都从这个文件的字节里解析——它们与用户看到的正文同源。
|
|
127
|
+
*
|
|
128
|
+
* 读不到就是 `null`(不知道):宿主缓存可能指向一个已经被删/搬走的技能,此时「能不能被模型调用」
|
|
129
|
+
* 没有答案,编一个布尔值就是让面板撒谎。
|
|
130
|
+
*
|
|
131
|
+
* @param {{ name: string, root?: string, scope: 'global'|'project' }} row
|
|
132
|
+
* @returns {Promise<{ modelInvocable: boolean|null, userInvocable: boolean|null }>}
|
|
133
|
+
*/
|
|
134
|
+
async function invocationOf(row) {
|
|
135
|
+
const unknown = { modelInvocable: null, userInvocable: null }
|
|
136
|
+
if (typeof row?.name !== 'string' || typeof row?.root !== 'string' || row.root === '') return unknown
|
|
137
|
+
try {
|
|
138
|
+
const target = await resolveSkillTarget({ name: row.name, root: row.root, scope: row.scope })
|
|
139
|
+
const { invocation } = await readSkillDocument(target)
|
|
140
|
+
return {
|
|
141
|
+
modelInvocable: invocation?.modelInvocable ?? null,
|
|
142
|
+
userInvocable: invocation?.userInvocable ?? null,
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// 只吞「读不到」这一类;其余错误照原样抛——把 bug 说成「未知」等于把缺陷埋起来。
|
|
146
|
+
if (isUnreadable(error)) return unknown
|
|
147
|
+
throw error
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
113
151
|
/**
|
|
114
152
|
* 把宿主的一条 SkillSummary 投影成面板行。
|
|
115
153
|
* @param {object} summary - 宿主 SkillSummary。
|
|
116
154
|
* @param {{ cwd?: string }} options - 该条目所属的查询作用域。
|
|
117
|
-
* @returns {object} 面板行。
|
|
155
|
+
* @returns {Promise<object>} 面板行。
|
|
118
156
|
*/
|
|
119
|
-
function toView(summary, { cwd }) {
|
|
120
|
-
const invocation = summary.invocation ?? {}
|
|
157
|
+
async function toView(summary, { cwd }) {
|
|
121
158
|
const located = locateSkill(summary)
|
|
159
|
+
const scope = scopeOfSource(summary.source)
|
|
160
|
+
// 调用权限**不读** summary.invocation:那一份来自宿主的发现缓存(见 invocationOf 的说明)。
|
|
161
|
+
const invocation = await invocationOf({ name: summary.name, root: located.root, scope })
|
|
122
162
|
return {
|
|
123
163
|
name: summary.name,
|
|
124
164
|
description: summary.description ?? '',
|
|
125
165
|
...(summary.whenToUse !== undefined ? { whenToUse: summary.whenToUse } : {}),
|
|
126
|
-
|
|
127
|
-
userInvocable: invocation.userInvocable !== false,
|
|
166
|
+
...invocation,
|
|
128
167
|
source: summary.source ?? 'unknown',
|
|
129
168
|
provider: summary.provider ?? 'unknown',
|
|
130
|
-
scope
|
|
169
|
+
scope,
|
|
131
170
|
// 根目录与形态推不出来就不填(上层据此提示「无法定位」,而不是作用在错的目录上)。
|
|
132
171
|
...(located.root !== undefined ? { root: located.root, kind: located.kind } : {}),
|
|
133
172
|
...(cwd !== undefined ? { cwd } : {}),
|
|
@@ -155,7 +194,7 @@ async function queryScope(skills, options) {
|
|
|
155
194
|
...(signal === undefined ? {} : { signal }),
|
|
156
195
|
}
|
|
157
196
|
const summaries = await skills.list(request)
|
|
158
|
-
return summaries.map((summary) => toView(summary, { cwd }))
|
|
197
|
+
return await Promise.all(summaries.map((summary) => toView(summary, { cwd })))
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
/**
|
|
@@ -199,8 +238,8 @@ async function listSkillViews(ctx, options = {}) {
|
|
|
199
238
|
*
|
|
200
239
|
* 为什么删掉而不是留着:`ctx.skills.get(name)` 按 rank 返回**生效者**,与面板
|
|
201
240
|
* 「(name, root) 精确操作」的模型(D8)冲突——点被遮蔽的那行会读到生效者的正文。
|
|
202
|
-
* 现在的 detail 走 `resolveTargetReference` + `resolveSkillTarget` + `
|
|
203
|
-
* 既精确到根,又能读出已停用技能的 `.disabled`
|
|
241
|
+
* 现在的 detail 走 `resolveTargetReference` + `resolveSkillTarget` + `readSkillDocument`,
|
|
242
|
+
* 既精确到根,又能读出已停用技能的 `.disabled` 正文(并顺带从同一份字节里给出调用权限)。
|
|
204
243
|
*/
|
|
205
244
|
|
|
206
245
|
// ── 安装目标(skillTargets)───────────────────────────────────────────────
|
|
@@ -402,4 +441,4 @@ async function resolveTargetReference(ctx, reference) {
|
|
|
402
441
|
*/
|
|
403
442
|
const skillRowKey = (row) => `${row?.source || 'unknown'}\u0000${row?.cwd || ''}\u0000${row?.name || ''}`
|
|
404
443
|
|
|
405
|
-
export { listSkillViews, queryScope, resolveTargetReference, skillRowKey, skillTargetsView }
|
|
444
|
+
export { invocationOf, listSkillViews, queryScope, resolveTargetReference, skillRowKey, skillTargetsView }
|
package/lib/typert.js
CHANGED
|
@@ -106,6 +106,7 @@ export const TYPERT = {
|
|
|
106
106
|
mvs('list', [skillPayloadParam], 'SkillListResult'),
|
|
107
107
|
mvs('detail', [skillPayloadParam], 'SkillDetailResult'),
|
|
108
108
|
mvs('setEnabled', [skillPayloadParam], 'SkillMutationResult'),
|
|
109
|
+
mvs('setModelInvocable', [skillPayloadParam], 'SkillMutationResult'),
|
|
109
110
|
// ⚠️ 不能叫 `remove`:客户端适配器(dsh-api-gateway 的 RemoteNamespaceService)自己就有原型
|
|
110
111
|
// 方法 `remove(kind, method, token)`,`ctx.remote.$mount` 会把「与命名空间服务自身成员重名」
|
|
111
112
|
// 的 endpoint 直接拒掉——报 `client api: method "skillsManager/remove" conflicts with its
|
package/lib/whats-new.js
CHANGED
|
@@ -184,4 +184,21 @@ export const WHATS_NEW = [
|
|
|
184
184
|
},
|
|
185
185
|
],
|
|
186
186
|
},
|
|
187
|
+
{
|
|
188
|
+
version: '1.6.2',
|
|
189
|
+
items: [
|
|
190
|
+
{
|
|
191
|
+
text: '**技能能一键改成「只在你手动点名时可用」**:详情页新增「模型自动调用」开关,直接改 `SKILL.md` 的 `disable-model-invocation`,不必再手工翻文件。关闭时它把那**一行**删掉(缺省即允许,所以不留痕迹)、只在需要时于 frontmatter 末尾追加一行——字段顺序与正文都不动;形状读不准(值跨行,或要在带行尾注释的那一行上打开它)时它会**拒绝**并说明原因,而不是猜一个改法',
|
|
192
|
+
where: '技能 → 选中一条技能 → 概览',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
text: '**调用权限现在如实显示**:以前已停用的技能一律被显示成「模型不可调用 / 用户不可调用」——那是凭空的断言(它的权限只有那份 `.disabled` 文件能回答)。现在按技能文件里真正写着的内容显示,读不出来(frontmatter 非法、宿主会整条跳过它)就显示「未知」而不猜一个值',
|
|
196
|
+
where: '技能列表的徽章与详情里的属性',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
text: '**修好「有新版本却从不提示」**:更新检查原先读仓库的 release feed,而那里唯一的 latest 被另一条线的 pre-release 规则占着,于是本线的提示恒为「已是最新」——1.6.2 发出来你也看不到它。现在问的是本包在 npm 上的最新版。⚠️ 这条修复装在**这一版**里,所以 1.6.1 及更早的用户请手动 `dsh plugin --profile web update dsh-toolbox-ui` 升一次;从这一版起提示恢复正常',
|
|
200
|
+
where: '面板顶部的更新提示条',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
187
204
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-toolbox-ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "Responsive MCP management UI and skills manager for DeepSeek Harness Web — floating panel, JSON import, profile-backed Host Remote management, and a skill market with a hard license gate.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|