@mhfire/dsh-im-bridge 0.3.0 → 0.4.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 CHANGED
@@ -14,7 +14,7 @@ One DSH session maps to one WeCom chat window, not “every window of the same u
14
14
 
15
15
  - **1:1**: `single:<userid>` — one Agent for that user
16
16
  - **Group**: `group:<chatid>` — all members share one Agent and one serial queue (two people speaking at once still cannot concurrent-`followup`)
17
- - `allowFrom` still filters by **sender** userid; rejected senders are not enqueued
17
+ - `allowFrom` filters **who may chat** by sender userid; empty = enqueue everyone. Office commands use `wecomCli.allowFrom` separately
18
18
  - Leading `@nickname` mentions are stripped on arrival, so both the model input and the title read `测试一下` instead of `@MediaAgent 测试一下`. Mentions later in the text stay, and a message that is nothing but mentions goes to the model unchanged
19
19
  - Process restart continues the same durable session via a stable `wecom-` + short hash of the key: adopt a live Agent if the process already has one (for example the browser already opened that row), `resume` from persistence otherwise, and `create` only when neither exists. Resume cwd / preset follow the archive, not the current config; a cwd mismatch logs a warning and is not rewritten.
20
20
  - The GUI title is `企微·私聊` / `企微·群` plus the first user prompt (the same automatic title as other sessions), not a userid / chatid; two windows of the same kind with similar first lines stay two sidebar rows
@@ -23,7 +23,7 @@ One DSH session maps to one WeCom chat window, not “every window of the same u
23
23
 
24
24
  ## Compatible DeepSeek Harness versions
25
25
 
26
- DeepSeek Harness is still a developer preview and makes **no semver compatibility promise** to out-of-tree plugins. Package 0.3.0 is aligned to published tags by the APIs it actually calls:
26
+ DeepSeek Harness is still a developer preview and makes **no semver compatibility promise** to out-of-tree plugins. Package 0.4.2 is aligned to published tags by the APIs it actually calls:
27
27
 
28
28
  | DSH | This package |
29
29
  |---|---|
@@ -40,7 +40,7 @@ Pin `dsh` to `0.1.0-rc.8` or later, for example `npx @deepseek-ai/dsh@0.1.1-rc.2
40
40
  ```powershell
41
41
  dsh plugin --profile web add @mhfire/dsh-im-bridge
42
42
  # Or pin a version:
43
- # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.3.0
43
+ # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.4.2
44
44
  ```
45
45
 
46
46
  In `$DSH_HOME/profiles/web/cordis.patch.yml` (or your profile), supply credentials only (other fields ship as bundle defaults and can be overridden):
@@ -81,7 +81,7 @@ Fields, top to bottom:
81
81
  | Welcome message | `welcomeMessage` | Applies on the next message |
82
82
  | WeCom-only provider / model | `provider` / `model` | Applies only to **later new** WeCom-window sessions; both must be set to override, otherwise the GUI default model is used |
83
83
 
84
- `workspace`, `agentPreset`, `persona` / `personaFile`, `thinking`, `maxReplyBytes`, and `reasoningEffort` are not on the card; set them in the profile patch or the table below. This release does not hot-reconnect credentials.
84
+ `workspace`, `agentPreset`, `persona` / `personaFile`, `thinking`, `maxReplyBytes`, `reasoningEffort`, and `wecomCli` are not on the card; set them in the profile patch or the table below. This release does not hot-reconnect credentials; changing `wecomCli` also requires a process restart.
85
85
 
86
86
  ## Configuration
87
87
 
@@ -91,7 +91,7 @@ The bundle `cordis.patch.yml` supplies defaults for every field except `botId` /
91
91
  |---|---|
92
92
  | `botId` / `secret` | WeCom AI Bot credentials (`role('secret')`, redacted in UI); when empty, WeCom side is skipped and the host keeps running |
93
93
  | `workspace` | Agent working directory (session cwd) |
94
- | `allowFrom` | Allowed sender userids; empty = allow everyone |
94
+ | `allowFrom` | Chat allow-list; empty = everyone may ask. Does not gate wecom-cli |
95
95
  | `agentTimeoutSec` | Max seconds per task; also drives progress / ETA |
96
96
  | `startHint` | Placeholder text when processing starts |
97
97
  | `agentPreset` | Agent preset to mount (default `standard`) |
@@ -101,6 +101,7 @@ The bundle `cordis.patch.yml` supplies defaults for every field except `botId` /
101
101
  | `maxReplyBytes` | Reply size cap in bytes (default 20000) |
102
102
  | `deniedMessage` | Reply when the sender is not on `allowFrom` (editable in Settings) |
103
103
  | `welcomeMessage` | Welcome text on `enter_chat` (editable in Settings) |
104
+ | `wecomCli` | Optional WeCom office skills (off by default). See the next section |
104
105
  | `thinking` | Streaming animation. Precedence: tool activity (`toolLabels`) > model stream phase (`reasoningStatus` / `outputStatus` from `assistant/chunk`) > timed `phases` fallback; also `spin` / `reasoningSpin` / `outputSpin` / `eggs` |
105
106
 
106
107
  To give WeCom a different model from the GUI, set both in the profile `cordis.patch.yml`:
@@ -130,6 +131,41 @@ thinking:
130
131
  pwsh: PowerShell
131
132
  ```
132
133
 
134
+ ## WeCom office skills (wecom-cli)
135
+
136
+ The plugin depends on the official [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) binary. Install `wecomcli-*` under **`$DSH_HOME/wecom-cli-skills`** (not workspace `.dsh/skills` / `.agents/skills`, and not `$DSH_HOME/skills`). Only the **office userid’s 1:1** agent gets the office layer: `skills.register()` for the catalog and `tools.register()` for the gated `wecom_cli` tool. Both go through that agent’s own ctx, so group chats and the GUI never see them. Other workspace skills stay on `skill-filesystem`.
137
+
138
+ Office commands run **only through the `wecom_cli` tool**: the model passes `argv` (the arguments after `wecom-cli`), the plugin spawns the official binary directly, and any `auth init` is refused. The `wecom-cli` on PATH is a shim that prints a refusal and exits 1, so group chats, the GUI, and any `pwsh wecom-cli` fail; the shim’s message points back at `wecom_cli`. The credential directory is never exported to the process environment — it is injected per spawn.
139
+
140
+ `wecomCli.enabled` is off by default. Turning it on requires a non-empty **`wecomCli.allowFrom`** (office userids). Root `allowFrom` only gates who may chat; empty means everyone may ask. An empty office list logs a warning and skips the shim / auth.
141
+
142
+ One-time setup:
143
+
144
+ 1. Leave root `allowFrom` empty (everyone may ask) and put **office** userids in `wecomCli.allowFrom` (do not leave that empty)
145
+ 2. In **Settings → Plugins → WeCom Bridge**, click **Install official skills** (the Host downloads the official repo zip into `$DSH_HOME/wecom-cli-skills`). **Do not** use `npx skills add -g` (it leaks into the GUI). The skills CLI has **no `--dir`**, so that flag does not write into the plugin directory. If `wecomcli-*` folders already sit in the workspace, move them here and delete the workspace copies. You can also copy official `skills/wecomcli-*` into `$DSH_HOME/wecom-cli-skills` by hand.
146
+
147
+ 3. Enable it in the profile `cordis.patch.yml` (the plugin then writes wecom-cli credentials from the existing `botId` / `secret` via hidden `auth init --bot-id/--secret`; no QR scan and no `npm install -g @wecom/cli`):
148
+
149
+ ```yaml
150
+ - id: im-bridge
151
+ config:
152
+ allowFrom: []
153
+ wecomCli:
154
+ enabled: true
155
+ allowFrom: ["<office userid>"]
156
+ # skillsDir: '' # empty = $DSH_HOME/wecom-cli-skills
157
+ # configDir: '' # empty = <workspace>/.dsh/wecom-cli; gitignore this directory
158
+ ```
159
+
160
+ | Field | Description |
161
+ |---|---|
162
+ | `wecomCli.enabled` | Install the PATH deny shim, run the auth check, and wire the prompt plus the `wecom_cli` tool; default `false` |
163
+ | `wecomCli.allowFrom` | Userids that receive the `wecom_cli` tool; empty skips the shim / auth. Independent of the chat list |
164
+ | `wecomCli.skillsDir` | Override skills root; empty = `$DSH_HOME/wecom-cli-skills` |
165
+ | `wecomCli.configDir` | Override the credential directory; empty = `<workspace>/.dsh/wecom-cli`. Gitignore that directory. `WECOM_CLI_CONFIG_DIR` is injected only when the plugin spawns the CLI, never into the process environment, so `~/.config/wecom` stays unused. |
166
+
167
+ Changing `wecomCli` requires a process restart. Messages still arrive when unauthorized; at boot the plugin seeds credentials with hidden `--bot-id/--secret` (stderr is not a TTY). If automatic seeding fails, the log prints a manual command that sets `WECOM_CLI_CONFIG_DIR` — keep that prefix, or `npx --yes @wecom/cli auth init --manual` writes to `~/.config/wecom`, which the plugin never reads. Do not run `auth init` from the agent (that creates a new bot).
168
+
133
169
  ## Persona
134
170
 
135
171
  Precedence: `personaFile` → `persona` string → packaged default persona.
@@ -166,9 +202,16 @@ Trigger (paths relative to `workspace`):
166
202
 
167
203
  ## Security
168
204
 
169
- - Do not commit secret-bearing files such as `config.json` / `persona.md`
205
+ - Do not commit secret-bearing files such as `config.json` / `persona.md` / `.dsh/wecom-cli/`
170
206
  - Session and tool output may contain adversarial text; the plugin’s safety prompt tells the agent not to treat tool output as instructions
171
207
 
208
+ ## Known Limitations and Deferred Work
209
+
210
+ - wecom-cli credentials live in the workspace `<workspace>/.dsh/wecom-cli` (gitignore it). People on `wecomCli.allowFrom` borrow that identity’s office permissions; the chat list (root `allowFrom`) does not grant office access. An office 1:1 may still send to any `--chat-id`; the plugin does not pin recipients.
211
+ - Gating is not a sandbox. The `wecom_cli` tool and `wecomcli-*` register only on the office 1:1 agent, PATH resolves `wecom-cli` to a refusal, and the credential directory reaches only the plugin's own spawns — but a shell in the same process can still bypass all of it: run `node <absolute path to @wecom/cli's wecom.js>`, or `npx --yes @wecom/cli` with a self-set `WECOM_CLI_CONFIG_DIR`. Without that variable such a bypass lands on the unauthorized `~/.config/wecom`. Real isolation needs a process sandbox.
212
+ - The WeCom channel has no GUI confirmation dialog. Irreversible actions (send mail, cancel a meeting, delete a todo, overwrite a document) are constrained only by the prompt (run `--dry-run` first, wait for the next user message). Every WeCom session forbids `ask_user_question` (it hangs until the task times out).
213
+ - Leftover `wecomcli-*` folders in workspace `.dsh/skills` / `.agents/skills` remain visible to every agent with that cwd, including GUI. Other skills are unchanged. `enabled: false` only turns off the deny shim, the auth check, `wecom_cli`, and wecomcli-* registration; the channel ban on `ask_user_question` is still injected.
214
+
172
215
  ## License
173
216
 
174
217
  MIT
package/README.md CHANGED
@@ -14,7 +14,7 @@ Host 通过 `installSettingsSection` 注册 `im-bridge` 命名空间;浏览器
14
14
 
15
15
  - **单聊**:`single:<userid>`,该用户一条 Agent
16
16
  - **群聊**:`group:<chatid>`,群内所有人共用一条 Agent 和同一条串行队列(两人同时发也不会并发 `followup`)
17
- - `allowFrom` 仍按**发送者** userid 拦截;被拒的人不进队
17
+ - `allowFrom` 按**发送者** userid 拦截谁能聊天;空 = 所有人可进队。办公命令另用 `wecomCli.allowFrom`
18
18
  - 群里 @机器人 的消息,开头的 `@昵称` 在入站就去掉:模型看到的和标题用的都是「测试一下」而不是「@MediaAgent 测试一下」;正文中间的 @某人 保留,整条只有 @ 时按原文交给模型
19
19
  - 进程重启后用稳定 id `wecom-` + key 的短 hash 续上同一条会话:进程里已有活 Agent 就直接采用(例如浏览器已打开该行),存档里有就 `resume`,都没有才 `create`。resume 的 cwd / preset 跟存档,不跟当前配置;cwd 不一致时打警告,不改写。
20
20
  - GUI 标题为「企微·私聊/群」+ 第一句用户话(与其它会话一样由 DSH 生成),不再露出 userid / chatid;同类型窗口若第一句话相近,侧栏仍是两行
@@ -23,7 +23,7 @@ Host 通过 `installSettingsSection` 注册 `im-bridge` 命名空间;浏览器
23
23
 
24
24
  ## 兼容的 DeepSeek Harness 版本
25
25
 
26
- DeepSeek Harness 仍是 developer preview,对外置插件**没有 semver 兼容承诺**。本包 0.3.0 按实际调用的 API 对齐已发布 tag:
26
+ DeepSeek Harness 仍是 developer preview,对外置插件**没有 semver 兼容承诺**。本包 0.4.2 按实际调用的 API 对齐已发布 tag:
27
27
 
28
28
  | DSH | 本包 |
29
29
  |---|---|
@@ -40,7 +40,7 @@ DeepSeek Harness 仍是 developer preview,对外置插件**没有 semver 兼
40
40
  ```powershell
41
41
  dsh plugin --profile web add @mhfire/dsh-im-bridge
42
42
  # 或钉版本:
43
- # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.3.0
43
+ # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.4.2
44
44
  ```
45
45
 
46
46
  在 `$DSH_HOME/profiles/web/cordis.patch.yml`(或对应 profile)中补密钥即可(其余项已有 bundle 默认,可按需覆盖):
@@ -81,7 +81,7 @@ dsh plugin --profile web add <本包路径>
81
81
  | 进入会话欢迎语 | `welcomeMessage` | 下一轮消息生效 |
82
82
  | 企微专用 provider / model | `provider` / `model` | 只影响之后**新建**的企微窗口会话;须两项都填才覆盖,否则跟随 GUI 默认模型 |
83
83
 
84
- `workspace`、`agentPreset`、`persona` / `personaFile`、`thinking`、`maxReplyBytes`、`reasoningEffort` 不在卡片上,仍在 profile patch 或下表中配置。本版本不做凭证热重连。
84
+ `workspace`、`agentPreset`、`persona` / `personaFile`、`thinking`、`maxReplyBytes`、`reasoningEffort`、`wecomCli` 不在卡片上,仍在 profile patch 或下表中配置。本版本不做凭证热重连;改 `wecomCli` 也须重启进程。
85
85
 
86
86
  ## 配置项
87
87
 
@@ -91,7 +91,7 @@ bundle 的 `cordis.patch.yml` 已为除 `botId` / `secret` 外的字段提供默
91
91
  |---|---|
92
92
  | `botId` / `secret` | 企业微信智能机器人凭证(`role('secret')`,UI 自动脱敏);缺省时跳过企微侧,不阻塞主进程 |
93
93
  | `workspace` | Agent 工作目录(会话 cwd) |
94
- | `allowFrom` | 允许的发送者 userid 白名单;空 = 允许所有人 |
94
+ | `allowFrom` | 聊天白名单;空 = 允许所有人问诊断。不控制 wecom-cli |
95
95
  | `agentTimeoutSec` | 单任务最长执行时间(秒),动画进度条/剩余估算的基准 |
96
96
  | `startHint` | 开始处理时的占位提示语 |
97
97
  | `agentPreset` | Agent 加入的 preset(默认 `standard`) |
@@ -101,6 +101,7 @@ bundle 的 `cordis.patch.yml` 已为除 `botId` / `secret` 外的字段提供默
101
101
  | `maxReplyBytes` | 回复上限(字节,默认 20000) |
102
102
  | `deniedMessage` | 非白名单用户的拒绝文案(Settings 可编) |
103
103
  | `welcomeMessage` | 进入会话欢迎语(Settings 可编) |
104
+ | `wecomCli` | 可选的企业微信办公能力(默认关闭)。见下一节 |
104
105
  | `thinking` | 流式动画。优先级:工具活动(`toolLabels`)> 模型流式阶段(`reasoningStatus` / `outputStatus`,来自 `assistant/chunk`)> 时间轴 `phases` 兜底;另有 `spin` / `reasoningSpin` / `outputSpin` / `eggs` 等 |
105
106
 
106
107
  企微与 GUI 使用不同模型时,在 profile `cordis.patch.yml` 同时填写:
@@ -130,6 +131,41 @@ thinking:
130
131
  pwsh: PowerShell
131
132
  ```
132
133
 
134
+ ## 企业微信办公能力(wecom-cli)
135
+
136
+ 插件依赖官方 [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) 二进制。`wecomcli-*` 装在 **`$DSH_HOME/wecom-cli-skills`**(不要装进工作区 `.dsh/skills` / `.agents/skills`,也不要装进 `$DSH_HOME/skills`)。插件只在**办公 userid 的单聊** Agent 上注入:`skills.register()` 装 catalog,`tools.register()` 装门控工具 `wecom_cli`。两者都走该 Agent 自己的 ctx,群聊与 GUI 看不到。工作区里其它 skill 仍由 `skill-filesystem` 发现,不受影响。
137
+
138
+ 办公命令**只经 `wecom_cli` 工具执行**:模型传 `argv`(`wecom-cli` 之后的参数数组),插件直接 spawn 官方二进制,并拒绝任何 `auth init`。PATH 上的 `wecom-cli` 是一个只打印拒绝信息并 `exit 1` 的 shim,所以群聊、GUI 以及任何 `pwsh wecom-cli` 都跑不通;shim 的文案会指回 `wecom_cli`。凭证目录不进程级导出,只在插件自己 spawn 时注入。
139
+
140
+ `wecomCli.enabled` 默认关闭。开启须同时配置非空 **`wecomCli.allowFrom`**(办公 userid);根级 `allowFrom` 只管谁能聊天,空名单表示所有人可问诊断。办公名单为空时插件会告警并跳过 shim / 授权 / `wecom_cli`。
141
+
142
+ 一次性准备:
143
+
144
+ 1. 根级 `allowFrom` 留空(所有人可问诊断),把 **办公** userid 写进 `wecomCli.allowFrom`(不要留空)
145
+ 2. 在 **Settings → 插件配置 → 企业微信桥接** 点「安装官方 skills」(Host 下载官方仓库 zip,解到 `$DSH_HOME/wecom-cli-skills`)。**不要用** `npx skills add -g`(会泄漏给 GUI);skills CLI **没有 `--dir`**,加了也不会写到程序目录。若工作区里已有 `wecomcli-*`,先挪到该目录再删工作区副本。也可手动把官方仓库 `skills/wecomcli-*` 拷进 `$DSH_HOME/wecom-cli-skills`。
146
+
147
+ 3. 在 profile `cordis.patch.yml` 打开(启用后插件会用已有 `botId` / `secret` 走 `auth init --bot-id/--secret` 写入 wecom-cli 凭据,不必扫码、不必 `npm install -g @wecom/cli`):
148
+
149
+ ```yaml
150
+ - id: im-bridge
151
+ config:
152
+ allowFrom: []
153
+ wecomCli:
154
+ enabled: true
155
+ allowFrom: ["<办公 userid>"]
156
+ # skillsDir: '' # 空 = $DSH_HOME/wecom-cli-skills
157
+ # configDir: '' # 空 = <workspace>/.dsh/wecom-cli;请 gitignore
158
+ ```
159
+
160
+ | 字段 | 说明 |
161
+ |---|---|
162
+ | `wecomCli.enabled` | 装 PATH 拒绝 shim、跑授权检查、给企微 Agent 接上 prompt 与 `wecom_cli` 工具;默认 `false` |
163
+ | `wecomCli.allowFrom` | 能拿到 `wecom_cli` 工具的 userid;空则跳过 shim / 授权。与根级聊天名单独立 |
164
+ | `wecomCli.skillsDir` | 覆盖 skills 根目录;空 = `$DSH_HOME/wecom-cli-skills` |
165
+ | `wecomCli.configDir` | 覆盖凭证目录;空 = `<workspace>/.dsh/wecom-cli`。请把该目录加入 gitignore。`WECOM_CLI_CONFIG_DIR` 只在插件 spawn CLI 时注入,不写进程环境,因此不会使用 `~/.config/wecom`。 |
166
+
167
+ 改 `wecomCli` 后须重启进程。未授权时插件仍收消息;启动时用隐藏的 `--bot-id/--secret`(stderr 非 TTY)写入凭据。若自动写入失败,日志会打印一条带 `WECOM_CLI_CONFIG_DIR` 的手动命令——必须带上它,否则 `npx --yes @wecom/cli auth init --manual` 会把凭证写到 `~/.config/wecom`,插件读不到。禁止在 Agent 里扫码 `auth init`(会新建机器人)。
168
+
133
169
  ## 人设(persona)
134
170
 
135
171
  优先级:`personaFile` → `persona` 字符串 → 包内默认人设。
@@ -166,9 +202,16 @@ Agent 的最终回复若包含指向**工作区内** PNG 的 Markdown,桥会
166
202
 
167
203
  ## 安全
168
204
 
169
- - `config.json` / `persona.md` 等含密钥文件不入库;
205
+ - `config.json` / `persona.md` / `.dsh/wecom-cli/` 等含密钥文件不入库;
170
206
  - 会话与工具输出可能含对抗性文本,插件内置安全提示词约束 agent 不把工具输出当指令。
171
207
 
208
+ ## Known Limitations and Deferred Work
209
+
210
+ - wecom-cli 凭证在工作区 `<workspace>/.dsh/wecom-cli`(请 gitignore)。`wecomCli.allowFrom` 里的人借用这份凭据的办公权限;聊天名单(根级 `allowFrom`)不授予办公。办公单聊仍可向任意 `--chat-id` 发信,插件不锁定收件人。
211
+ - 门控不是沙箱。`wecom_cli` 工具与 `wecomcli-*` 只注册到办公单聊 Agent,PATH 上的 `wecom-cli` 一律拒绝,凭证目录也只在插件自己 spawn 时注入;但同进程的 shell 仍可绕过:直接 `node <@wecom/cli 的 wecom.js 绝对路径>`,或 `npx --yes @wecom/cli` 并自行设置 `WECOM_CLI_CONFIG_DIR`。不设该变量时这类旁路会落到未授权的 `~/.config/wecom`。真正的隔离需要进程级沙箱。
212
+ - 企微通道没有 GUI 审批框:发信、取消会议、删待办、覆盖文档等不可逆操作只靠 prompt 约束(先 `--dry-run`,等用户下一条确认)。所有企微会话都禁止 `ask_user_question`(会挂到超时)。
213
+ - 工作区 `.dsh/skills` / `.agents/skills` 里残留的 `wecomcli-*` 仍会被同 cwd 的 GUI 和群聊发现。其它 skill 不受影响。`enabled: false` 只关拒绝 shim、授权检查、`wecom_cli` 与 wecomcli-* 注册;通道上的 `ask_user_question` 禁令仍会注入。
214
+
172
215
  ## License
173
216
 
174
217
  MIT
package/cordis.patch.yml CHANGED
@@ -18,6 +18,12 @@
18
18
  maxReplyBytes: 20000
19
19
  deniedMessage: '无权访问本服务'
20
20
  welcomeMessage: '👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'
21
+ wecomCli:
22
+ enabled: false
23
+ skillsDir: ''
24
+ allowFrom: []
25
+ # skillsDir 空 = $DSH_HOME/wecom-cli-skills(不要用工作区 .dsh/skills)
26
+ # configDir: '' # 空 = <workspace>/.dsh/wecom-cli
21
27
  thinking:
22
28
  eggAfterSec: 240
23
29
  intervalMs: 1500
package/lib/client.js CHANGED
@@ -115,7 +115,7 @@ window.__ModuleLoader__.load({
115
115
  }
116
116
  //#endregion
117
117
  //#region \0dsh-css:C:\Users\user\Desktop\dsh-im-bridge\plugin\src\client\fields.module.css.mjs
118
- const css = ".OMD7cq_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.OMD7cq_field+.OMD7cq_field{border-top:1px solid var(--dsw-alias-border-l2)}.OMD7cq_head{align-items:center;gap:8px;display:flex}.OMD7cq_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.OMD7cq_badges{align-items:center;gap:8px;display:inline-flex}.OMD7cq_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.OMD7cq_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}.OMD7cq_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.OMD7cq_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.OMD7cq_reset:disabled{cursor:default}.OMD7cq_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.OMD7cq_input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.OMD7cq_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_inputInvalid:focus-visible{outline:none}.OMD7cq_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.OMD7cq_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}";
118
+ const css = ".OMD7cq_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.OMD7cq_field+.OMD7cq_field{border-top:1px solid var(--dsw-alias-border-l2)}.OMD7cq_head{align-items:center;gap:8px;display:flex}.OMD7cq_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.OMD7cq_badges{align-items:center;gap:8px;display:inline-flex}.OMD7cq_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.OMD7cq_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}.OMD7cq_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.OMD7cq_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.OMD7cq_reset:disabled{cursor:default}.OMD7cq_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.OMD7cq_input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.OMD7cq_input::placeholder{color:var(--dsw-alias-label-tertiary)}.OMD7cq_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_inputInvalid:focus-visible{outline:none}.OMD7cq_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.OMD7cq_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}.OMD7cq_install{appearance:none;font:inherit;cursor:pointer;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border:1px solid #0000;border-radius:8px;align-self:flex-start;padding:5px 14px;font-size:13px;line-height:1.5}.OMD7cq_install:hover:not(:disabled){opacity:.92}.OMD7cq_install:disabled{opacity:.4;cursor:default}.OMD7cq_install:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}";
119
119
  const tagId = "@mhfire/dsh-im-bridge/fields.module.css";
120
120
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
121
121
  const tag = document.createElement("style");
@@ -133,6 +133,7 @@ window.__ModuleLoader__.load({
133
133
  "hint": "OMD7cq_hint",
134
134
  "input": "OMD7cq_input",
135
135
  "inputInvalid": "OMD7cq_inputInvalid",
136
+ "install": "OMD7cq_install",
136
137
  "invalid": "OMD7cq_invalid",
137
138
  "label": "OMD7cq_label",
138
139
  "reset": "OMD7cq_reset"
@@ -189,7 +190,8 @@ window.__ModuleLoader__.load({
189
190
  }
190
191
  /**
191
192
  * Write-only credential control. The literal never rides a response, so the
192
- * control starts blank and reports only whether one is configured.
193
+ * control starts blank and reports only whether one is configured. A configured
194
+ * empty draft shows a dots placeholder so the box does not look unset.
193
195
  * @param props - the field's copy, staged text, and configured state.
194
196
  * @returns the labelled control.
195
197
  */
@@ -217,6 +219,7 @@ window.__ModuleLoader__.load({
217
219
  type: "password",
218
220
  autoComplete: "off",
219
221
  value: props.text,
222
+ placeholder: props.configured && props.text === "" ? "••••••••" : void 0,
220
223
  disabled: props.disabled,
221
224
  onChange: (event) => {
222
225
  props.onEdit(event.target.value);
@@ -278,6 +281,16 @@ window.__ModuleLoader__.load({
278
281
  props.edit("secret", text);
279
282
  }
280
283
  }),
284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillsInstall, {
285
+ t,
286
+ disabled,
287
+ available: state.skillsInstallAvailable,
288
+ status: state.skillsInstallStatus,
289
+ dest: state.skillsDest,
290
+ count: state.skillsCount,
291
+ error: state.skillsError,
292
+ onInstall: props.installSkills
293
+ }),
281
294
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
282
295
  id: "im-bridge-allowFrom",
283
296
  label: t("allowFrom"),
@@ -373,6 +386,44 @@ window.__ModuleLoader__.load({
373
386
  ]
374
387
  });
375
388
  }
389
+ function skillsStatusText(t, status, dest, count, error, available) {
390
+ if (!available) return t("skillsUnavailable");
391
+ if (status === "ok") return t("skillsInstalled").replace("{count}", String(count)).replace("{dest}", dest);
392
+ if (status === "error") return error === "" ? t("skillsFailed") : `${t("skillsFailed")} ${error}`;
393
+ return t("skillsHint");
394
+ }
395
+ /**
396
+ * Host-side install of official wecomcli-* (browser never chooses the path).
397
+ * @param props - copy, status, and the install action.
398
+ * @returns the labelled control.
399
+ */
400
+ function SkillsInstall(props) {
401
+ const installing = props.status === "installing";
402
+ const status = skillsStatusText(props.t, props.status, props.dest, props.count, props.error, props.available);
403
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
404
+ className: fields_module_css_default.field,
405
+ children: [
406
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
407
+ className: fields_module_css_default.head,
408
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
+ className: fields_module_css_default.label,
410
+ children: props.t("skillsTitle")
411
+ })
412
+ }),
413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
414
+ type: "button",
415
+ className: fields_module_css_default.install,
416
+ disabled: props.disabled || !props.available || installing,
417
+ onClick: props.onInstall,
418
+ children: props.t(installing ? "skillsInstalling" : "skillsInstall")
419
+ }),
420
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
421
+ className: props.status === "error" || !props.available ? fields_module_css_default.invalid : fields_module_css_default.hint,
422
+ children: status
423
+ })
424
+ ]
425
+ });
426
+ }
376
427
  //#endregion
377
428
  //#region src/client/card-form.ts
378
429
  /**
@@ -577,6 +628,10 @@ window.__ModuleLoader__.load({
577
628
  this.failed = false;
578
629
  this.publish();
579
630
  }
631
+ /** Rebuild bound projections for card-owned state outside the settings draft. */
632
+ notify() {
633
+ this.publish();
634
+ }
580
635
  spec(field) {
581
636
  const spec = this.specs.get(field);
582
637
  if (spec === void 0) throw new Error(`im-bridge card has no field ${field}`);
@@ -610,15 +665,22 @@ window.__ModuleLoader__.load({
610
665
  var WecomCardController = class {
611
666
  scope;
612
667
  describe;
668
+ rpc;
613
669
  form;
614
670
  store;
671
+ skillsInstallStatus = "idle";
672
+ skillsDest = "";
673
+ skillsCount = 0;
674
+ skillsError = "";
615
675
  /**
616
676
  * @param scope - bound settings scope for the `im-bridge` namespace.
617
677
  * @param describe - Host describe face; secret literals never ride it.
678
+ * @param rpc - optional Connection RPC for the skills install button.
618
679
  */
619
- constructor(scope, describe) {
680
+ constructor(scope, describe, rpc) {
620
681
  this.scope = scope;
621
682
  this.describe = describe;
683
+ this.rpc = rpc;
622
684
  this.form = new CardForm(scope, [
623
685
  csvField("allowFrom"),
624
686
  numberField("agentTimeoutSec"),
@@ -649,7 +711,12 @@ window.__ModuleLoader__.load({
649
711
  deniedMessage: this.form.field("deniedMessage"),
650
712
  welcomeMessage: this.form.field("welcomeMessage"),
651
713
  provider: this.form.field("provider"),
652
- model: this.form.field("model")
714
+ model: this.form.field("model"),
715
+ skillsInstallAvailable: this.rpc !== void 0,
716
+ skillsInstallStatus: this.skillsInstallStatus,
717
+ skillsDest: this.skillsDest,
718
+ skillsCount: this.skillsCount,
719
+ skillsError: this.skillsError
653
720
  };
654
721
  }
655
722
  /**
@@ -670,11 +737,45 @@ window.__ModuleLoader__.load({
670
737
  await this.scope.set(field, text);
671
738
  return this.secretConfigured(field);
672
739
  }
740
+ /**
741
+ * Download official wecomcli-* into the Host-resolved directory.
742
+ * The browser does not choose the path.
743
+ */
744
+ installSkills() {
745
+ this.runInstall();
746
+ }
747
+ async runInstall() {
748
+ if (this.rpc === void 0 || this.skillsInstallStatus === "installing") return;
749
+ this.skillsInstallStatus = "installing";
750
+ this.skillsError = "";
751
+ this.form.notify();
752
+ try {
753
+ const result = await this.rpc.call("/im-bridge", "wecomcli.installSkills", {});
754
+ if (!result.ok) {
755
+ this.skillsInstallStatus = "error";
756
+ this.skillsError = result.error.message;
757
+ this.form.notify();
758
+ return;
759
+ }
760
+ const value = result.value;
761
+ this.skillsInstallStatus = "ok";
762
+ this.skillsDest = typeof value.dest === "string" ? value.dest : "";
763
+ this.skillsCount = typeof value.count === "number" ? value.count : 0;
764
+ this.skillsError = "";
765
+ } catch (error) {
766
+ this.skillsInstallStatus = "error";
767
+ this.skillsError = error instanceof Error ? error.message : String(error);
768
+ }
769
+ this.form.notify();
770
+ }
673
771
  /** Face the slot registration injects. */
674
772
  inject() {
675
773
  return {
676
774
  hooks: { wecomCard: this.store },
677
- ...this.form.actions()
775
+ ...this.form.actions(),
776
+ installSkills: () => {
777
+ this.installSkills();
778
+ }
678
779
  };
679
780
  }
680
781
  };
@@ -697,7 +798,7 @@ window.__ModuleLoader__.load({
697
798
  invalidNumber: "Enter a finite number.",
698
799
  botId: "Bot ID",
699
800
  secret: "Secret",
700
- secretHint: "Leave blank to keep the stored value. Save, then restart the process to open the WebSocket.",
801
+ secretHint: "The box stays empty on purpose (the stored value never rides the wire). A Configured badge means it is saved; type a new value to replace it. Save, then restart the process to open the WebSocket.",
701
802
  secretConfigured: "Configured",
702
803
  secretUnset: "Not configured",
703
804
  allowFrom: "Allowed sender userids",
@@ -713,7 +814,14 @@ window.__ModuleLoader__.load({
713
814
  provider: "WeCom-only provider",
714
815
  providerHint: "Empty follows the GUI default model. Both provider and model must be set to override.",
715
816
  model: "WeCom-only model",
716
- modelHint: "Takes effect only together with provider."
817
+ modelHint: "Takes effect only together with provider.",
818
+ skillsTitle: "WeCom office skills",
819
+ skillsHint: "Installs wecomcli-* into the plugin directory ($DSH_HOME/wecom-cli-skills). Do not use npx skills add -g; that CLI has no --dir. An empty Bot ID / Secret box is normal.",
820
+ skillsInstall: "Install official skills",
821
+ skillsInstalling: "Installing…",
822
+ skillsInstalled: "Installed {count} skills into {dest}",
823
+ skillsFailed: "Install failed.",
824
+ skillsUnavailable: "Install needs the Web host Connection."
717
825
  };
718
826
  /** Chinese copy for the im-bridge card. */
719
827
  const zh = {
@@ -732,7 +840,7 @@ window.__ModuleLoader__.load({
732
840
  invalidNumber: "请输入有效数字。",
733
841
  botId: "Bot ID",
734
842
  secret: "Secret",
735
- secretHint: "留空保留已存值。保存后需重启进程才会连 WebSocket。",
843
+ secretHint: "框空是正常的(已存值不会传到浏览器)。徽章「已配置」即已保存;重新输入可覆盖。保存后需重启进程才会连 WebSocket。",
736
844
  secretConfigured: "已配置",
737
845
  secretUnset: "未配置",
738
846
  allowFrom: "允许的发送者 userid",
@@ -748,7 +856,14 @@ window.__ModuleLoader__.load({
748
856
  provider: "企微专用 provider",
749
857
  providerHint: "空 = 跟随 GUI 默认模型。须与 model 同时填写才覆盖。",
750
858
  model: "企微专用 model",
751
- modelHint: "仅在同时填写 provider 时生效。"
859
+ modelHint: "仅在同时填写 provider 时生效。",
860
+ skillsTitle: "企微办公 skills",
861
+ skillsHint: "装到程序目录 $DSH_HOME/wecom-cli-skills。不要用 npx skills add -g;CLI 没有 --dir。Bot ID / Secret 框空是正常的。",
862
+ skillsInstall: "安装官方 skills",
863
+ skillsInstalling: "正在安装…",
864
+ skillsInstalled: "已装 {count} 个到 {dest}",
865
+ skillsFailed: "安装失败。",
866
+ skillsUnavailable: "安装需要 Web Host 的 Connection。"
752
867
  };
753
868
  //#endregion
754
869
  //#region src/client/index.ts
@@ -767,7 +882,8 @@ window.__ModuleLoader__.load({
767
882
  * @param ctx - browser plugin context.
768
883
  */
769
884
  function apply(ctx) {
770
- const card = new WecomCardController(ctx.settingsScope.bind({ namespace: NS }), ctx.settingsScope.describe());
885
+ const connection = ctx.get("connection");
886
+ const card = new WecomCardController(ctx.settingsScope.bind({ namespace: NS }), ctx.settingsScope.describe(), connection?.rpc);
771
887
  ctx.effect(() => ctx.locale.register(NS, {
772
888
  zh,
773
889
  en