dsh-armory 1.9.0 → 1.9.1

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 CHANGED
@@ -9,6 +9,7 @@
9
9
  <p align="center">
10
10
  <a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
11
11
  <a href="https://github.com/2026heshao/dsh-armory/releases"><img src="https://img.shields.io/github/v/release/2026heshao/dsh-armory?label=release" alt="release"></a>
12
+ <a href="https://www.npmjs.com/package/dsh-armory"><img src="https://img.shields.io/npm/dt/dsh-armory?label=npm%20downloads" alt="npm downloads"></a>
12
13
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
13
14
  <img src="https://img.shields.io/badge/DSH-%3E%3D0.1.5--rc.1-blue" alt="DSH">
14
15
  <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-8b5cf6" alt="topic: dsh-plugin"></a>
@@ -17,10 +18,14 @@
17
18
  ## Install in one line
18
19
 
19
20
  ```sh
21
+ # npm (recommended: no build-approval step, ready to use)
22
+ dsh plugin --profile web add dsh-armory
23
+
24
+ # GitHub
20
25
  dsh plugin --profile web add github:2026heshao/dsh-armory
21
26
  ```
22
27
 
23
- Restart DSH, then open **Skills · MCP** from the global panel list at the top of the sidebar (or the floating button bottom-right). Desktop users on the `desktop` profile: swap `--profile web` for `--profile desktop`.
28
+ Restart DSH, then open **Skills · MCP · Experts** from the global panel list at the top of the sidebar (or the floating button bottom-right). Desktop users on the `desktop` profile: swap `--profile web` for `--profile desktop`. You can also search `armory` and one-click install inside [dsh-market](https://github.com/dsh-market/dsh-market#readme).
24
29
 
25
30
  ## What it solves
26
31
 
@@ -87,10 +92,14 @@ A **unified board** for DeepSeek Harness (Web and desktop): five top-level tabs
87
92
  ## Install (details)
88
93
 
89
94
  ```sh
95
+ # npm (recommended)
96
+ dsh plugin --profile web add dsh-armory
97
+
98
+ # GitHub
90
99
  dsh plugin --profile web add github:2026heshao/dsh-armory
91
100
  ```
92
101
 
93
- Then **restart `dsh web`**. To upgrade (GitHub installs: re-add with a new tag):
102
+ Then **restart DSH** (restart the desktop app, or `dsh web` for the browser GUI; desktop users: swap `--profile web` for `--profile desktop`). To upgrade: npm installs can click the in-panel upgrade banner; GitHub installs re-add with a new tag:
94
103
 
95
104
  ```sh
96
105
  dsh plugin --profile web update dsh-armory
@@ -113,7 +122,7 @@ Details, local development installs, and troubleshooting: [docs/installation.md]
113
122
  Two entry points, one panel:
114
123
 
115
124
  - **Floating button (bottom-right)**: draggable, position remembered;
116
- - **Sidebar entry at the bottom**: above "Settings" when expanded, icon-only in the 56px rail.
125
+ - **Sidebar entry "Skills · MCP · Experts"**: above "Settings" when expanded, icon-only in the 56px rail.
117
126
 
118
127
  The panel floats above the session; closing it ("Close" or Esc) does not affect the conversation.
119
128
 
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  <p align="center">
10
10
  <a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
11
11
  <a href="https://github.com/2026heshao/dsh-armory/releases"><img src="https://img.shields.io/github/v/release/2026heshao/dsh-armory?label=release" alt="release"></a>
12
+ <a href="https://www.npmjs.com/package/dsh-armory"><img src="https://img.shields.io/npm/dt/dsh-armory?label=npm%20downloads" alt="npm downloads"></a>
12
13
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
13
14
  <img src="https://img.shields.io/badge/DSH-%3E%3D0.1.5--rc.1-blue" alt="DSH">
14
15
  <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-8b5cf6" alt="topic: dsh-plugin"></a>
@@ -17,10 +18,14 @@
17
18
  ## 一键安装
18
19
 
19
20
  ```sh
21
+ # npm(推荐:免构建授权,装完即用)
22
+ dsh plugin --profile web add dsh-armory
23
+
24
+ # GitHub
20
25
  dsh plugin --profile web add github:2026heshao/dsh-armory
21
26
  ```
22
27
 
23
- 装完**重启 DSH**,侧栏顶部「全局面板」里点 **技能 · MCP**(或右下角悬浮按钮)。桌面端装在 `desktop` profile 时把 `--profile web` 换成 `--profile desktop`。
28
+ 装完**重启 DSH**,侧栏顶部「全局面板」里点 **技能 · MCP · 专家**(或右下角悬浮按钮)。桌面端装在 `desktop` profile 时把 `--profile web` 换成 `--profile desktop`。也可以在装了 [dsh-market](https://github.com/dsh-market/dsh-market#readme) 的 DSH 里搜 `armory` 一键安装。
24
29
 
25
30
  ## 它解决什么
26
31
 
@@ -73,10 +78,14 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
73
78
  ## 安装
74
79
 
75
80
  ```sh
81
+ # 从 npm 安装(推荐)
82
+ dsh plugin --profile web add dsh-armory
83
+
84
+ # 从 GitHub 安装
76
85
  dsh plugin --profile web add github:2026heshao/dsh-armory
77
86
  ```
78
87
 
79
- 装完**重启 `dsh web`**(桌面端把 `--profile web` 换成 `--profile desktop`)。升级:换新版 tag 重新 `add`,或在面板「一键升级」提示条上直接点。卸载(不会删除 profile 里已有的 MCP 条目):
88
+ 装完**重启 DSH**(桌面端重启应用,Web 端重启 `dsh web`;桌面端把 `--profile web` 换成 `--profile desktop`)。升级:npm 安装可在面板「一键升级」提示条上直接点,GitHub 安装换新版 tag 重新 `add`。卸载(不会删除 profile 里已有的 MCP 条目):
80
89
 
81
90
  ```sh
82
91
  dsh plugin --profile web remove dsh-armory
@@ -93,7 +102,7 @@ dsh plugin --profile web remove dsh-armory
93
102
  两个入口,同一个面板:
94
103
 
95
104
  - **右下角悬浮按钮**:可拖拽移动,位置会被记住;
96
- - **侧栏底部「MCP」**:展开态在「设置」上方,侧栏收起成 56px 轨道时只剩图标。
105
+ - **侧栏「技能 · MCP · 专家」**:展开态在「设置」上方,侧栏收起成 56px 轨道时只剩图标。
97
106
 
98
107
  面板浮在会话之上,关掉它(右上角「关闭」或 Esc)不会影响会话。
99
108
 
@@ -181,12 +190,18 @@ dsh plugin --profile web remove dsh-armory
181
190
  ## 开发
182
191
 
183
192
  ```sh
184
- npm test # 全部测试(含真机集成用例)
185
- dsh --profile web --dump-config # 看 profile 的有效配置
186
- dsh web # 起 Web 做真实操作验证
193
+ npm install --no-save yaml@2 # 测试唯一的裸依赖(node:test 内置)
194
+ npm test # 专家市场写链 33 断言(与 CI / 发版门槛同一条)
195
+ npm run test:e2e # 可选:用宿主补丁算法验证专家预设进入组合树
196
+ dsh --profile web --dump-config # 看 profile 的有效配置
197
+ dsh web # 起 Web 做真实操作验证
187
198
  ```
188
199
 
189
- `npm test` 里除替身用例(验证插件内部自洽)外,还有一组**真机集成用例**:真 `cordis` + 真 `dsh-tools` + 真 `dsh-scope` + 真 `dsh-mcp-client` + 一个真 stdio MCP 子进程,覆盖「同项目两个会话共用一份连接、工具投射进会话层、不泄漏到全局、子进程随最后一个会话退出」。CI(push 到 main / PR,以及推 tag 发版)跑的是同一条门槛,发版流程见 [安装与升级](docs/installation.md#发布流程维护者)。
200
+ `npm test` 跑的是 `test/expert-manual.mjs`:真网拉 WorkBuddy 专家目录(ETag 命中后走磁盘缓存)与 senior-developer 人设,再对 `lib/expert-market.js` 的 patch 写链做 33 条断言——含 1.9.1 修复的回归用例(召唤预设必须写成顶层 `- insert:` 指令、旧版顶层裸条目要能被迁移)。本机存在 `~/.dsh/profiles/desktop/cordis.patch.yml` 时会追加一组真实 profile 往返断言,没有则记 `SKIP`。
201
+
202
+ CI(push 到 main / PR,见 `.github/workflows/ci.yml`)与发版门槛(推 tag,见 `.github/workflows/release.yml`)跑的是**逐字相同**的命令(装 `yaml@2` → `node ./test/run-expert-manual.mjs`),两处口径不许分叉;发版流程见 [安装与升级](docs/installation.md#发布流程维护者)。
203
+
204
+ > 本机若没有 `yaml`,`npm test` 会以 `ERR_MODULE_NOT_FOUND` 退出——`test/loader.mjs` 会先试标准解析,失败才回退开发机的 profile 副本(`~/.dsh/profiles/desktop/node_modules`),所以干净环境必须先装依赖。
190
205
 
191
206
  ## 文档
192
207
 
@@ -116,25 +116,35 @@ npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm
116
116
 
117
117
  ```sh
118
118
  # 1) 改版本:package.json + README/docs 里的版本与 tag 引用 + lib/whats-new.js 的更新条目
119
- # + .github/release-notes/v<新版本>.md(后两项缺一项,下面的 npm test 就会红)
120
- npm test
119
+ # + .github/release-notes/v<新版本>.md(后两项缺一项,release.yml 的校验步骤就会红)
120
+ npm install --no-save yaml@2 # 测试唯一的裸依赖;干净 checkout 必须先装
121
+ npm test # 专家市场写链 33 断言(与 CI / 发版门槛同一条命令)
121
122
 
122
123
  # 2) 真机过一遍(触发条件见下),**必须在打 tag 之前**
123
124
  dsh web # 打开面板点一遍
124
125
 
125
- # 3) 提交并推 tag(CI 会校验 tag 与 version 一致,不一致就拒绝发布)
126
+ # 3) 提交并推 tag(release.yml 会校验 tag 与 version 一致,不一致就拒绝发布)
126
127
  git add -A && git commit -m "..."
127
128
  git push origin main
128
- git tag -a v1.9.0 -m "v1.8.0 ..." && git push origin v1.9.0
129
+ git tag -a v1.9.1 -m "v1.9.1 ..." && git push origin v1.9.1
129
130
  ```
130
131
 
131
- 推 tag 后 CI 依次做:校验版本 → `pnpm install --frozen-lockfile` → `npm test` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
132
+ 推 tag 后 `.github/workflows/release.yml` 依次做:校验 tag 与版本一致 → 校验 whats-new 有当前版本条目 → 校验 release notes 稿存在 → 装 `yaml@2` → `node ./test/run-expert-manual.mjs` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
132
133
 
133
- **第 2 步的真机检查什么时候必须做**:客户端那一半是替身测试——组件在 node 里渲染不起来(见[设计与限制](design.md#版本范围怎么定以及它管不到什么)),所以 CI 全绿也证明不了面板能显示。因此**只要本次改动碰了 `lib/client.js`、或推进了 DSH 基线,就必须做**:起 `dsh web`,确认侧栏入口能打开面板、悬浮按钮在位,然后导入 / 预览 / 启停各点一次。本次改名恰好动了 `lib/client.js`(插件 id、CSS 去重键、typert method id、localStorage 迁移),所以这一遍不能省。
134
+ CI 分工(两条工作流,门槛命令**逐字相同**,改一处必须改另一处):
135
+
136
+ | 工作流 | 触发 | 跑什么 |
137
+ | --- | --- | --- |
138
+ | `.github/workflows/ci.yml` | push 到 main、PR、手动 | 装 `yaml@2` → `node ./test/run-expert-manual.mjs`(只验证,不发布) |
139
+ | `.github/workflows/release.yml` | 推 `v*.*.*` tag、手动 | 同上门槛 + 版本 / whats-new / release-notes 三项校验 → `npm publish` → GitHub Release |
140
+
141
+ > **本仓库没有 `pnpm-lock.yaml`**(运行依赖全是宿主提供的 peer,不装进包),而 devDependencies 里的 `@deepseek-ai/*` 是宿主内部包、npm registry 上不存在。所以两条工作流都**不能用** `pnpm install --frozen-lockfile`——改回它会立刻红(历史上失败过一次)。测试只需 `yaml`,用 `npm install --no-save` 装。
142
+
143
+ **第 2 步的真机检查什么时候必须做**:客户端那一半是替身测试——组件在 node 里渲染不起来(见[设计与限制](design.md#版本范围怎么定以及它管不到什么)),所以 CI 全绿也证明不了面板能显示。因此**只要本次改动碰了 `lib/client.js`、或推进了 DSH 基线,就必须做**:起 `dsh web`,确认侧栏入口能打开面板、悬浮按钮在位,然后导入 / 预览 / 启停各点一次。
134
144
 
135
145
  为什么不能交给 CI:这类失败的表现是**面板白屏**,不会让任何用例变红(注册契约是好的,渲染不出来而已)。宿主改 slot 就是这种改动的典型——`0.1.6-alpha.2` 的发布说明里有「客户端 Session 会话支持多实例共存,相关 API 及 slot 有变化」,2026-09-24 推进基线到 `0.1.7-rc.1`、09-28 推进到 `0.1.7-rc.2` 时也属于这一类,所以它固定成检查单而不是靠记得。纯 Host 侧改动(`lib/index.js`、`lib/workspace-*.js` 等)可以跳过这一步。
136
146
 
137
- **Release notes 是必写的**,稿子放 `.github/release-notes/vX.Y.Z.md`(**不要**放 `docs/`,docs 会随包发布到 npm);有稿子就用它,没有才退回 `--generate-notes`。为什么必写:本仓库全部直接提交到 main、没有 PR,自动生成几乎生成不出内容(v1.3.0 及之前每个 Release 页面都只有一行 compare 链接),而面板的更新提示正是把用户指到那一页。`test/package-contract.test.mjs` 会拦下「bump 了版本却没写稿」,所以在打 tag 之前本地就会红。
147
+ **Release notes 是必写的**,稿子放 `.github/release-notes/vX.Y.Z.md`(**不要**放 `docs/`,docs 会随包发布到 npm);`release.yml` 的「校验 release notes 稿子存在」步骤会拦下漏写,`gh release create --notes-file` 用它作为 Release 页面正文。为什么必写:本仓库全部直接提交到 main、没有 PR,自动生成几乎生成不出内容(v1.3.0 及之前每个 Release 页面都只有一行 compare 链接),而面板的更新提示正是把用户指到那一页。
138
148
 
139
149
  **应用内的「更新到 vX」提示是另一件事**:它的内容真源是 `lib/whats-new.js`(随包发布,宿主的 `whatsNew` RPC 直接转发它),发布前必须给它写上当前版本那一条——`test/whats-new.test.mjs` 会拦下「bump 了版本却没写更新内容」。只写一条就够,不必和 GitHub Release notes 逐字一致。
140
150
 
package/lib/client.js CHANGED
@@ -1529,21 +1529,35 @@ const McpTab = (props) => {
1529
1529
  // 探测一键升级的可用性:官方 pluginManager 在不在 + 本插件是不是 npm 装的。
1530
1530
  // 与 updateHint 同一条纪律:任何失败都静默(按钮不出现即可),绝不因它让面板报错。
1531
1531
  // 只在确实有新版时才探测——没有新版时按钮本来就不显示,省一次 remote 调用。
1532
+ //
1533
+ // 安装来源必须问宿主(call('installInfo')):宿主 listBundles() 的行里没有 source
1534
+ // 字段,客户端拿它判来源会恒判「npm 装的」;而 installBundle 执行 `pnpm add <spec>`,
1535
+ // 本地 file:/link: 安装的用户一点就把开发副本换成 npm 包。installInfo 读的是 profile
1536
+ // manifest 里本插件那一行的真实 spec,是唯一可信判据。
1532
1537
  useEffect(() => {
1533
1538
  if (!updateHint?.latest || !pluginManager || typeof pluginManager[UPGRADE_METHOD] !== 'function') return undefined;
1534
1539
  let cancelled = false;
1535
1540
  (async () => {
1536
1541
  try {
1537
- const res = await pluginManager.listBundles();
1538
- if (cancelled || !res || res.ok !== true || !Array.isArray(res.value)) return;
1539
- const bundle = res.value.find((b) => b && b.name === UPGRADE_PACKAGE) || null;
1540
- // 只有判定为「可升级」才记 state;不可升级时保持 idle,按钮走旧的链接形态。
1541
- const plan = upgradePlan({ service: pluginManager, hint: updateHint, bundle });
1542
- if (!cancelled && plan.supported) setUpgradeState({ phase: 'idle', bundle, spec: plan.spec });
1542
+ const [bundleRes, installRes] = await Promise.all([
1543
+ pluginManager.listBundles(),
1544
+ call('installInfo').catch(() => null),
1545
+ ]);
1546
+ if (cancelled) return;
1547
+ const bundle = bundleRes && bundleRes.ok === true && Array.isArray(bundleRes.value)
1548
+ ? bundleRes.value.find((b) => b && b.name === UPGRADE_PACKAGE) || null
1549
+ : null;
1550
+ const install = installRes && installRes.ok ? installRes : null;
1551
+ // 只有判定为「可升级」才记 spec;不可升级时也记下 source/reason,
1552
+ // 供横幅按真实来源说清「为什么不能一键升」(本地安装 vs 来源未知)。
1553
+ const plan = upgradePlan({ service: pluginManager, hint: updateHint, bundle, spec: install?.spec ?? null, source: install?.source ?? null });
1554
+ if (cancelled) return;
1555
+ if (plan.supported) setUpgradeState({ phase: 'idle', bundle, spec: plan.spec, source: install?.source ?? null });
1556
+ else setUpgradeState({ phase: 'idle', bundle, source: install?.source ?? null, reason: plan.reason });
1543
1557
  } catch { /* 探测失败 = 没有一键升级,退回链接;不报错 */ }
1544
1558
  })();
1545
1559
  return () => { cancelled = true; };
1546
- }, [pluginManager, updateHint]);
1560
+ }, [pluginManager, updateHint, call]);
1547
1561
 
1548
1562
  // 挂载失败按 serverName 索引:既标到列表行上,也注入详情的错误区,避免「顶部报错但那一行看着正常」。
1549
1563
  // 挂载失败的汇总横幅用的就是这个列表;行与详情都读 Host 标在行上的 mountFailed。
@@ -1710,33 +1724,31 @@ const McpTab = (props) => {
1710
1724
  // 更不该报错(这一整块是纯增值信息,与 updateHint 同一条纪律)。
1711
1725
  const [upgradeState, setUpgradeState] = useState({ phase: 'idle' });
1712
1726
  const runUpgrade = async () => {
1713
- const svc = pluginManager;
1714
- const plan = upgradePlan({ service: svc, hint: updateHint, bundle: upgradeState.bundle || null });
1715
- if (!plan.supported) {
1716
- // 只有「本地链接安装」值得明说:其余情况(宿主没提供、已是最新)按钮本来就不显示。
1717
- setUpgradeState({ phase: 'blocked', reason: plan.reason });
1718
- return;
1719
- }
1720
- setUpgradeState({ phase: 'running', version: plan.version });
1727
+ // 判定在探测 effect 里已用真实 spec(宿主 installInfo)做完;按钮只在 supported 时
1728
+ // 才渲染,所以这里拿到 spec 就能执行。状态过期(spec 被清掉)时只提示、不动安装。
1729
+ const targetSpec = upgradeState.spec;
1730
+ if (!targetSpec) { setUpgradeState({ phase: 'blocked', reason: upgradeState.reason || 'not-ready' }); return; }
1731
+ const targetVersion = targetSpec.split('@^')[1] || updateHint?.latest || '';
1732
+ setUpgradeState((current) => ({ ...current, phase: 'running', version: targetVersion }));
1721
1733
  try {
1722
1734
  // 两个参数:installBundle(spec, options)。少传第二个会被 wire 直接拒绝
1723
1735
  // (client api: pluginManager/installBundle expected 2 argument(s), got 1)。
1724
- const res = await svc[UPGRADE_METHOD](plan.spec, {});
1736
+ const res = await pluginManager[UPGRADE_METHOD](targetSpec, {});
1725
1737
  if (!res || res.ok !== true) {
1726
- setUpgradeState({ phase: 'failed', text: (res && res.error && (res.error.message || res.error.code)) || '调用失败' });
1738
+ setUpgradeState((current) => ({ ...current, phase: 'failed', text: (res && res.error && (res.error.message || res.error.code)) || '调用失败' }));
1727
1739
  return;
1728
1740
  }
1729
1741
  const r = res.value || {};
1730
1742
  if (r.application === 'failed' || r.application === 'cancelled') {
1731
- setUpgradeState({ phase: 'failed', text: upgradeFailureText(r.error, r.packageResult && r.packageResult.exitCode) });
1743
+ setUpgradeState((current) => ({ ...current, phase: 'failed', text: upgradeFailureText(r.error, r.packageResult && r.packageResult.exitCode) }));
1732
1744
  return;
1733
1745
  }
1734
1746
  // applied / restart-required / overridden 都算装上了。宿主代码在进程内执行,升级已存在的包
1735
1747
  // 时官方**无条件**返回 restart-required(installBundle 里 `if (Object.hasOwn(before, name))
1736
1748
  // return 'restart-required'`,与 HMR 开不开无关),所以这里必须告诉用户去重启。
1737
- setUpgradeState({ phase: 'done', version: plan.version || updateHint?.latest || '' });
1749
+ setUpgradeState({ phase: 'done', version: targetVersion });
1738
1750
  } catch (e) {
1739
- setUpgradeState({ phase: 'failed', text: String(e?.message || e) });
1751
+ setUpgradeState((current) => ({ ...current, phase: 'failed', text: String(e?.message || e) }));
1740
1752
  }
1741
1753
  };
1742
1754
  const filtered = useMemo(() => {
@@ -1814,8 +1826,12 @@ const McpTab = (props) => {
1814
1826
  : upgradeState.phase === 'done' ? '已升级到 v' + upgradeState.version + ',重启宿主后生效'
1815
1827
  : upgradeState.phase === 'failed' ? '升级失败:' + upgradeState.text
1816
1828
  : upgradeState.phase === 'blocked' && upgradeState.reason === 'not-npm'
1817
- ? '本地链接安装,无法一键升级' + (updateHint?.current ? '(当前 v' + updateHint.current + ')' : '')
1818
- : '新版本 v' + updateHint.latest + ' 可用'),
1829
+ ? '本地安装(' + (upgradeState.source || 'file') + '),一键升级会把本地副本换成 npm 包,故只提示' + (updateHint?.current ? '(当前 v' + updateHint.current + ')' : '')
1830
+ : upgradeState.phase === 'idle' && upgradeState.reason === 'not-npm'
1831
+ // 探测阶段就判定不可一键升:这是本地开发安装的常态(file:/link:),
1832
+ // 说清原因,免得用户以为按钮坏了。
1833
+ ? '新版本 v' + updateHint.latest + ' 可用;本地安装(' + (upgradeState.source || 'file') + ')请手动更新后重启'
1834
+ : '新版本 v' + updateHint.latest + ' 可用'),
1819
1835
  // 可升级时给按钮,否则退回链接。按钮只在探测确认过 service + npm 来源后出现——
1820
1836
  // 探测失败时这一整条与改动前完全一致,不会出现点了没反应的按钮。
1821
1837
  upgradeState.phase === 'idle' && upgradeState.spec
@@ -3933,26 +3949,43 @@ const UPGRADE_PACKAGE = 'dsh-armory';
3933
3949
  /**
3934
3950
  * 这次升级能不能做、为什么不能——纯判定,便于单测。
3935
3951
  *
3936
- * 三种「不能升」的来源各不相同,报给用户的话也必须不同:
3952
+ * 四种「不能升」的来源各不相同,报给用户的话也必须不同:
3937
3953
  * - 宿主没提供 pluginManager(老宿主 / 非 web 形态)→ 退回手动命令;
3938
3954
  * - 插件不是 npm 装的(link:/file:/github:)→ 覆盖安装会把它换成 npm 包,不能做;
3955
+ * - 安装来源未知(宿主没回 installInfo / 读不到 profile manifest)→ 同样不做:
3956
+ * 宁可少一个按钮,也不做可能毁掉用户本地副本的写操作;
3939
3957
  * - 当前版本不低于最新 → 无需升级。
3940
3958
  *
3941
3959
  * @param {object} input - 判定输入。
3942
3960
  * @param {object|null} input.service - 探测到的 pluginManager 命名空间(null = 宿主没提供)。
3943
3961
  * @param {object|null} input.hint - updateHint 的结果(current / latest / updateAvailable)。
3944
- * @param {object|null} input.bundle - listBundles 里本插件那一行(source / installed / version)。
3945
- * @returns {{supported: boolean, reason: string, spec: string|null, canRestart: boolean}} 判定结果。
3962
+ * @param {object|null} input.bundle - listBundles 里本插件那一行(version / installed / …)。
3963
+ * @param {string|null} input.spec - 宿主 installInfo 报回的 profile manifest spec。
3964
+ * @param {string|null} input.source - 宿主 installInfo 报回的来源分类(registry/file/link/…)。
3965
+ * @returns {{supported: boolean, reason: string, spec: string|null, version?: string}} 判定结果。
3946
3966
  */
3947
- function upgradePlan({ service, hint, bundle }) {
3967
+ function upgradePlan({ service, hint, bundle, spec = null, source = null }) {
3948
3968
  const hasMethod = !!service && typeof service[UPGRADE_METHOD] === 'function';
3949
3969
  if (!hasMethod) return { supported: false, reason: 'no-service', spec: null };
3950
3970
  if (!hint?.updateAvailable || !hint?.latest) return { supported: false, reason: 'up-to-date', spec: null };
3951
- // 本地安装形态(link:/file:/github:/tarball)不归 installBundle 管:拿 npm spec 覆盖会把
3952
- // 用户的本地开发链接换成 npm 包,那是破坏而不是升级。只认 registry 来源。
3953
- const source = typeof bundle?.source === 'string' ? bundle.source : '';
3954
- const isRegistry = source === '' || /^\d|^\^|^~|^latest$/.test(source);
3955
- if (!isRegistry) return { supported: false, reason: 'not-npm', spec: null };
3971
+ // 本地安装形态不归 installBundle 管:它执行的是 `pnpm add <npm spec>`,会把用户的本地开发
3972
+ // 链接/文件依赖换成 npm 包——那是破坏而不是升级。只认 registry 来源。
3973
+ //
3974
+ // 判据必须来自宿主 installInfo(读 profile manifest 的真实 spec),**不能用
3975
+ // bundle.source**:宿主 listBundles() 的行里没有该字段(只有 name/version/description/
3976
+ // meta/enabled/installed/optional/removable/readOnlyReason/rows/overrides),旧实现据此
3977
+ // 恒判「可以升」,本地 file: 安装的用户一点就把开发副本换成了 npm 包。
3978
+ const sourceText = typeof source === 'string' ? source.trim() : '';
3979
+ const specText = typeof spec === 'string' ? spec.trim() : '';
3980
+ // 来源未知(新客户端配旧宿主,installInfo 还没上线 / manifest 读不到)→ 不做写操作。
3981
+ if (sourceText === '' || sourceText === 'unknown') return { supported: false, reason: 'unknown-source', spec: null };
3982
+ if (sourceText !== 'registry') return { supported: false, reason: 'not-npm', spec: null };
3983
+ // 双保险:spec 前缀也必须像 registry spec(宿主分类与客户端判据两处独立成立才算数)。
3984
+ const looksRegistry = specText === '' || /^[\^~]?\d/.test(specText) || /^(latest|\*|next|\*)$/i.test(specText);
3985
+ if (!looksRegistry) return { supported: false, reason: 'not-npm', spec: null };
3986
+ // 宿主将来若补上 bundle.source,两种信号取更保守的一侧(任一像本地就否决)。
3987
+ const legacySource = typeof bundle?.source === 'string' ? bundle.source.trim() : '';
3988
+ if (legacySource !== '' && !/^\d|^\^|^~|^latest$/.test(legacySource)) return { supported: false, reason: 'not-npm', spec: null };
3956
3989
  return {
3957
3990
  supported: true,
3958
3991
  reason: 'ok',
@@ -4276,6 +4309,9 @@ const MCP_CONTRIBUTION = {
4276
4309
  { id: 'dsh-armory#mcpManager/projectConnections', service: 'mcpManager', namespace: 'mcpManager', method: 'projectConnections', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('ProjectConnectionsResult') },
4277
4310
  { id: 'dsh-armory#mcpManager/reconnectWorkspaceServer', service: 'mcpManager', namespace: 'mcpManager', method: 'reconnectWorkspaceServer', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
4278
4311
  { id: 'dsh-armory#mcpManager/updateHint', service: 'mcpManager', namespace: 'mcpManager', method: 'updateHint', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('UpdateHintResult') },
4312
+ // 安装形态(spec / source):一键升级的安全判据。与宿主 method、typert.js 三处必须同步,
4313
+ // 漏一处就是恒 404(toolTailor 踩过)。
4314
+ { id: 'dsh-armory#mcpManager/installInfo', service: 'mcpManager', namespace: 'mcpManager', method: 'installInfo', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('InstallInfoResult') },
4279
4315
  // 「这次更新了什么」:数据在 lib/whats-new.js,宿主只转发,判定“该不该弹”在客户端。
4280
4316
  { id: 'dsh-armory#mcpManager/whatsNew', service: 'mcpManager', namespace: 'mcpManager', method: 'whatsNew', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WhatsNewResult') },
4281
4317
  // ── Skill 看板:与 MCP 同一命名空间、同一信封(payload 进、{ok} 出)──
package/lib/index.js CHANGED
@@ -167,6 +167,32 @@ function patchPath(ctx) {
167
167
  const filename = path.startsWith("file:") ? fileURLToPath(path) : path;
168
168
  return join(dirname(filename), "cordis.patch.yml");
169
169
  }
170
+ // profile 目录:与 patchPath 同一条受信发现路径(根 include 的 path 所在目录),
171
+ // 不是从 ctx.fs 或 cwd 猜出来的——installInfo 只读它下面的 package.json。
172
+ function profileDirOf(ctx) {
173
+ const path = patchPath(ctx);
174
+ return path ? dirname(path) : null;
175
+ }
176
+ /**
177
+ * 安装来源分类:客户端据此决定「一键升级」能不能做。
178
+ * 本仓库历史上踩过的坑:listBundles() 的返回行里**没有 source 字段**,客户端拿它判
179
+ * 来源会恒判「npm 装的」,于是本地 file: 安装的用户一点升级就被 `pnpm add` 换成 npm 包。
180
+ * 这里只按 profile manifest 里的 spec 前缀分类,分类结果直接决定按钮出不出现。
181
+ * @returns {"registry"|"file"|"link"|"git"|"remote"|"path"|"unknown"}
182
+ */
183
+ function classifyInstallSpec(spec) {
184
+ const text = typeof spec === "string" ? spec.trim() : "";
185
+ if (text === "") return "unknown";
186
+ if (/^(file:|portal:|workspace:)/i.test(text)) return "file";
187
+ if (/^link:/i.test(text)) return "link";
188
+ if (/^(github:|git\+|git:|https?:\/\/.*\.git)/i.test(text)) return "git";
189
+ if (/^https?:/i.test(text)) return "remote";
190
+ if (/^(\.{1,2}[\\/]|[A-Za-z]:[\\/]|\/)/.test(text)) return "path";
191
+ // registry spec:版本范围(^1.9.0 / ~1.9.0 / 1.9.0 / latest / * / 空=latest)
192
+ if (/^[\^~]?\d/.test(text) || /^(latest|\*)$/i.test(text)) return "registry";
193
+ if (/^[a-z@]/i.test(text)) return "registry";
194
+ return "unknown";
195
+ }
170
196
  // Profile persistence is a Host-owned configuration capability, not a session workspace mutation.
171
197
  // `ctx.fs` is intentionally fenced by the active workspace-write policy, so this target is
172
198
  // derived only from the trusted root include and committed through the Host atomic writer.
@@ -312,6 +338,10 @@ const UPDATE_CHECK_URL = "https://api.github.com/repos/2026heshao/dsh-armory/rel
312
338
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
313
339
  const UPDATE_CHECK_TIMEOUT_MS = 5000;
314
340
  const UPDATE_CHECK_DISABLED_ENV = "DSH_ARMORY_DISABLE_UPDATE_CHECK";
341
+ // 本插件的两个 profile 依赖名:1.9.0 改名后新名是 dsh-armory,旧名 dsh-skill-mcp-board
342
+ // 是「从旧版升级但还没删掉旧条目」的过渡态——installInfo 两行都查,命中哪行就报哪行。
343
+ const UPGRADE_PACKAGE_NAME = "dsh-armory";
344
+ const LEGACY_PACKAGE_NAME = "dsh-skill-mcp-board";
315
345
  let updateCheckState = { checkedAt: 0, result: null };
316
346
 
317
347
  function pluginVersion() {
@@ -1286,6 +1316,45 @@ let McpManagerGateway = (() => {
1286
1316
  async updateHint() {
1287
1317
  return checkForUpdate();
1288
1318
  }
1319
+ /**
1320
+ * 本插件的安装形态与来源 spec —— 客户端「一键升级」的安全判据。
1321
+ *
1322
+ * 为什么必须由宿主读:客户端问得到的 listBundles() 行里**没有 source 字段**
1323
+ * (只有 name/version/description/meta/enabled/installed/optional/removable/
1324
+ * readOnlyReason/rows/overrides),客户端据此判断会恒判「可以一键升级」,而
1325
+ * installBundle 执行的是 `pnpm add <npm spec>`——本地 file:/link: 安装的用户一点
1326
+ * 就会把开发副本换成 npm 包。真源只有 profile manifest 的 dependencies。
1327
+ *
1328
+ * 只读、只回本插件自己的两行(新名 + 兼容旧名),失败一律降级为 unknown 而不是抛错:
1329
+ * 升级按钮不该因为读不到 manifest 就让面板报错。
1330
+ */
1331
+ async installInfo() {
1332
+ const names = [UPGRADE_PACKAGE_NAME, LEGACY_PACKAGE_NAME];
1333
+ const info = {
1334
+ packageName: UPGRADE_PACKAGE_NAME,
1335
+ version: pluginVersion(),
1336
+ spec: null,
1337
+ source: "unknown",
1338
+ profileDir: null,
1339
+ };
1340
+ try {
1341
+ const dir = profileDirOf(this.ctx);
1342
+ if (dir) {
1343
+ info.profileDir = dir;
1344
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
1345
+ const deps = manifest && typeof manifest.dependencies === "object" && manifest.dependencies ? manifest.dependencies : {};
1346
+ const hit = names.find((name) => typeof deps[name] === "string");
1347
+ if (hit) {
1348
+ info.spec = String(deps[hit]);
1349
+ info.installedAs = hit;
1350
+ info.source = classifyInstallSpec(info.spec);
1351
+ }
1352
+ }
1353
+ } catch {
1354
+ // 读不到就维持 unknown:客户端据此退回「查看更新方式」链接,不做写操作。
1355
+ }
1356
+ return info;
1357
+ }
1289
1358
  async whatsNew() {
1290
1359
  // 只回数据,不在宿主判定「该不该弹」:「上次看到的版本」是每台浏览器自己的事实
1291
1360
  // (localStorage),宿主没有这个视角;判定在客户端(whatsNewFor)。
package/lib/typert.js CHANGED
@@ -71,6 +71,9 @@ export const TYPERT = {
71
71
  mv('reconnectWorkspaceServer', [payloadParam], 'NoteResult'),
72
72
  mv('updateHint', [], 'UpdateHintResult'),
73
73
  mv('whatsNew', [], 'WhatsNewResult'),
74
+ // 安装形态(spec / source / profileDir):客户端「一键升级」的安全判据。
75
+ // 缺这一行 = 客户端 call('installInfo') 恒 404 → 按钮退回链接形态(同 toolTailor 的坑)。
76
+ mv('installInfo', [], 'InstallInfoResult'),
74
77
  // Skill 看板(与 MCP 同一命名空间、同一信封:payload 进、{ok} 出)。
75
78
  mv('listSkills', [payloadParam], 'SkillListResult'),
76
79
  mv('skillDetail', [payloadParam], 'SkillDetailResult'),
package/lib/whats-new.js CHANGED
@@ -8,9 +8,10 @@
8
8
  // **顺序就是语义**:客户端按数组顺序算「上次看到的版本之后还有哪些」,所以只能从旧到新
9
9
  // 追加,不要按版本号大小或字母重排。
10
10
  //
11
- // 维护约定(由 test/whats-new.test.mjs 钉住):`package.json` 的版本必须是这里的最后一条。
12
- // 也就是说——bump 了版本却没写更新内容,本地就会红。这正是「新功能没人发现」的根治点:
13
- // 发布前不写清楚「变了什么、在哪找」,就发不出去。
11
+ // 维护约定(由 test/upgrade-plan.mjs 第 8 组断言钉住):`package.json` 的版本必须是这里的
12
+ // 最后一条,且 `.github/release-notes/v<版本>.md` 必须存在。也就是说——bump 了版本却没写
13
+ // 更新内容、或没写 Release 稿,本地 `npm test` 与 CI 都会红。这正是「新功能没人发现」的
14
+ // 根治点:发布前不写清楚「变了什么、在哪找」,就发不出去。
14
15
  //
15
16
  // 条目形状:`{ version, items: [{ text, where? }] }`。`where` 用来回答「在哪儿能找到它」,
16
17
  // 是这次加这个提示的直接原因(新开关藏在面板头部,不主动指路等于没加)。
@@ -207,4 +208,25 @@ export const WHATS_NEW = [
207
208
  },
208
209
  ],
209
210
  },
211
+ {
212
+ version: '1.9.1',
213
+ items: [
214
+ {
215
+ text: '修复「挂载失败:Unknown agent preset」:召唤专家写的预设行以前是写在补丁顶层的裸条目,宿主按「覆盖已有行」解释后整条跳过(预设永远不注册);现在按宿主规范写成 insert 指令,并自动把旧格式的行迁移过来——升级后首次打开面板/重新召唤即修复,已有会话的正常使用',
216
+ where: '会话输入框召唤菜单 → 专家',
217
+ },
218
+ {
219
+ text: '会话已开始也能用专家:宿主规则不允许中途换预设,改为提供「带「专家」新建会话」一键路径(自动建新会话 → 挂上专家 → 跳过去),不再是死路一条',
220
+ where: '召唤菜单 → 专家 → 提示条下方的按钮',
221
+ },
222
+ {
223
+ text: '召唤菜单两处体验修复:鼠标移过「技能」不再把菜单关掉(开合判定收归整个菜单,提示区不再误判为「离开」);提示条让菜单变高时页面不再抖动(菜单改为视口浮层,不再撑动会话滚动区)',
224
+ where: '会话输入框工具行右侧的看板小图标',
225
+ },
226
+ {
227
+ text: '一键升级更安全:以前它靠宿主返回的一个并不存在的字段判断「是不是 npm 装的」,本地 file:/link: 安装的用户一点升级就会把开发副本换成 npm 包;现在改问宿主读 profile 里的真实安装来源,本地安装与来源不明一律只提示、不写盘',
228
+ where: '面板顶部的更新提示条',
229
+ },
230
+ ],
231
+ },
210
232
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "1.9.0",
4
- "description": "Unified Skill + MCP + Expert board for DeepSeek Harness Web — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert market installs, tool-usage tailor, and profile-backed Host Remote management.",
3
+ "version": "1.9.1",
4
+ "description": "Unified Skill + MCP + Expert board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert market installs, tool-usage tailor, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/2026heshao/dsh-armory.git"
@@ -13,7 +13,12 @@
13
13
  "type": "module",
14
14
  "main": "lib/index.js",
15
15
  "scripts": {
16
- "test": "node --test"
16
+ "test": "node ./test/run-all.mjs",
17
+ "test:expert": "node ./test/run-expert-manual.mjs",
18
+ "test:upgrade": "node ./test/upgrade-plan.mjs",
19
+ "test:install": "node ./test/verify-install-info.mjs",
20
+ "test:e2e": "node ./test/run-e2e.mjs",
21
+ "migrate": "node ./test/run.mjs migrate"
17
22
  },
18
23
  "exports": {
19
24
  "./typert": "./lib/typert.js",