dsh-plugin-showcase 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 antonioxue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,192 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-showcase
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A reference plugin for **DeepSeek Harness (DSH)**. It does one job — say one useful
4
+ sentence about the DSH host it runs in — through all eight plugin surfaces at once,
5
+ so the whole surface area can be read in one small package.
6
+
7
+ | Surface | File and anchor |
8
+ | --- | --- |
9
+ | Tool `showcase_inspect` | `lib/index.js:290` (definition), `:309` (registration) |
10
+ | Hook `tools/pre-execute` | `lib/index.js:311` (gate), `:14`–`:31` (patterns) |
11
+ | Slash command `/showcase` | `lib/index.js:322` |
12
+ | Settings card | `lib/client.js:296` (`settings.section`, id `showcase`) |
13
+ | Right-sidebar panel | body at `lib/client.js:314`, tab type via `sidebarRightTabs` |
14
+ | i18n | `lib/client.js:12` (8 runtime keys), `locale/en.json`, `locale/zh.json` |
15
+ | Marketplace metadata | `locale/*.json` meta, `icon.svg`, the `dsh` block in `package.json` |
16
+ | Tests | `tests/host.test.mjs`, `tests/client.test.mjs` |
17
+
18
+ Nothing here is compiled: only native ESM JavaScript, no third-party runtime
19
+ dependency, no `@deepseek-ai` import.
20
+
21
+ ## Install and uninstall
22
+
23
+ Profile used on this machine: `C:\Users\baba\.dsh\profiles\web`.
24
+
25
+ ### A. The documented way
26
+
27
+ ```sh
28
+ dsh plugin --profile web add <path-to-this-directory>
29
+ ```
30
+
31
+ `dsh plugin` forwards its arguments to pnpm inside the profile, links the checkout,
32
+ and — because the package declares `dsh.bundle` — appends it to
33
+ `dsh.profile.bundles`.
34
+
35
+ This host has no global `dsh` on `PATH`, so the same command through the checkout:
36
+
37
+ ```sh
38
+ node "C:\Users\baba\Desktop\deepseek-harness\apps\cli\lib\bin.js" plugin --profile web add "C:\Users\baba\Desktop\DSH工作区\plugins\dsh-plugin-showcase"
39
+ ```
40
+
41
+ Removal: `... plugin --profile web remove dsh-plugin-showcase` (drops the dependency
42
+ and the bundle layer).
43
+
44
+ ### B. What this machine actually uses — the manual equivalent
45
+
46
+ Three edits, no install run:
47
+
48
+ 1. `C:\Users\baba\.dsh\profiles\web\package.json` → `dependencies`:
49
+ `"dsh-plugin-showcase": "link:C:/Users/baba/Desktop/DSH工作区/plugins/dsh-plugin-showcase"`
50
+ 2. The same file → `dsh.profile.bundles`: append `"dsh-plugin-showcase"`
51
+ (it is currently the last entry).
52
+ 3. A directory junction so Node can resolve the name:
53
+ `C:\Users\baba\.dsh\profiles\web\node_modules\dsh-plugin-showcase`
54
+ → `C:\Users\baba\Desktop\DSH工作区\plugins\dsh-plugin-showcase`
55
+ (verified as a `<JUNCTION>` reparse point; recreate it with
56
+ `cmd /c mklink /J <link> <target>`).
57
+
58
+ **Why not the documented way here.** `dsh plugin ... add` runs an install inside the
59
+ profile, and that install re-resolves the *whole* tree. This profile already carries
60
+ eleven third-party plugins (`dsh-cost-meter`, `dsh-better-sidebar`, `dsh-chat-import`,
61
+ `dsh-context`, `dsh-memgas`, `dsh-wallpaper-bg`, `@linxin666/*`, `@liustack/*`,
62
+ `@tt-a1i/archify-dsh`). The three edits above are exactly the state a link install
63
+ produces, without a resolver run that could rewrite one of those eleven.
64
+
65
+ Check the layer without starting the host (verified output):
66
+
67
+ ```sh
68
+ node "C:\Users\baba\Desktop\deepseek-harness\apps\cli\lib\bin.js" --profile web --dump-config
69
+ # ...
70
+ # == dsh-plugin-showcase
71
+ # - id: showcase
72
+ # name: dsh-plugin-showcase
73
+ ```
74
+
75
+ DSH's HMR watches the profile `package.json` (the `bundles` array) and the profile
76
+ patch files, so adding the entry recomposes the running host without a restart; the
77
+ browser half needs one page refresh. Undo by removing the three edits.
78
+
79
+ ## Verifying each surface
80
+
81
+ **1. Tool.** `showcase_inspect` takes no arguments and returns the snapshot. Ask the
82
+ agent to run it; the rendered text starts with `DSH 巡检报告 <generatedAt>` and then
83
+ `## 工作区`, `## 会话`, `## 插件`. It is also where the `null`/`""` degradation is
84
+ visible: an unknown session shows `未知`.
85
+
86
+ **2. Hook.** The gate is on by default. A tool call is denied when its `command`,
87
+ `cmd`, `script` or `code` argument (case-insensitive) contains one of:
88
+
89
+ - `rm -rf /`, `rm -rf ~`
90
+ - fork bombs: `:(){:|:&;}`, `:(){ :|:& };:`, `.(){ :|:& };:`, `bomb(){ bomb|bomb& };bomb`
91
+ - `del /f /s /q c:\`, `format c:`
92
+ - the `shutdown` / `shutdown.exe` word in command position, optionally behind
93
+ `sudo`, `doas` or `pkexec`, e.g. `shutdown /s /t 0` or `sudo shutdown -h now`
94
+
95
+ Whitespace-collapsed spellings such as `rm -rf /tmp`, or the same commands written with tabs
96
+ instead of spaces, are caught too.
97
+ The deny reason is `showcase: 拒绝执行破坏性命令`; everything else calls `next()` and
98
+ is allowed. Argument text outside those four keys is ignored.
99
+
100
+ **3. Command.** `/showcase` prints the same report text; `/showcase on` and
101
+ `/showcase off` switch the gate (`showcase 拦截门: 已开启` / `已关闭`); any other input
102
+ answers `/showcase [on|off]`. State is per plugin instance, so a fresh apply starts
103
+ with the gate on.
104
+
105
+ **4. Settings card.** Open Settings: a nav row labelled **功能展示 / Showcase**
106
+ (registration id `showcase`, order 90) holds the card — a refresh button, an
107
+ "open the panel" button, and then either the snapshot facts or the degraded copy.
108
+
109
+ **5. Right-sidebar panel.** Tab type id `showcase-panel`, kind `showcase`, title
110
+ **巡检面板 / Showcase panel**, body seated under the same key. Two doors open it: the ◎
111
+ button in the left sidebar footer (beside Settings), and the settings card's
112
+ "open the panel" button — both end in `sidebarRight.openTab('showcase')`. The type
113
+ declares no `guide` entry, so it does not appear on the right sidebar's guide page.
114
+
115
+ **6. i18n.** Eight runtime keys — `title`, `subtitle`, `panelTitle`, `panelEmpty`,
116
+ `gateOn`, `gateOff`, `openPanel`, `refresh` — each present in `en` and `zh`.
117
+ Language resolution is `ctx.locale` → `navigator.language` → `en`; a host without a
118
+ locale service still renders real copy. Marketplace metadata comes from
119
+ `locale/en.json` (the anchor) and `locale/zh.json`.
120
+
121
+ **7. Marketplace metadata.** The Plugins page shows `icon.svg` plus the localized
122
+ title and description from the `locale/*.json` `meta` blocks.
123
+
124
+ **8. Tests.** `npm test` — see below.
125
+
126
+ ## Honest limitations
127
+
128
+ 1. **The client cannot read live host data, and under the zero-build rule it cannot.**
129
+ The documented remote-API path needs typert decorators, a code-generation step and
130
+ a third-party dependency — three hard conflicts with "native ESM, no build, no
131
+ deps". `lib/client.js` still probes the plausible shapes (`remote.showcase`,
132
+ `ctx.showcase`) and degrades to `null`. Consequence: the panel and the settings
133
+ card show a **static capability list plus the degraded copy**, not host data.
134
+ 2. **`sidebar.footer.action` does not project `label`.** The sidebar shell renders
135
+ that seat with only `{ wide }`, so the visible text is whatever the component
136
+ draws (`◎`, with the label carried as `aria-label`). The `label` thunk is a legal
137
+ `SlotLabel` and is correct, but no shell reads it for this seat.
138
+ 3. **The gate is a literal-substring heuristic, not a sandbox.** It normalizes case and
139
+ whitespace and understands command position, but an obfuscated spelling (base64,
140
+ variable expansion, another tool's argument key) passes through. It is here to
141
+ demonstrate the extension point and to stop obvious mistakes, nothing more.
142
+ 4. **At 390px the settings page is limited by the host layout.** The host's settings
143
+ dialog does not collapse its left navigation at that width, so the content column is
144
+ very narrow. This card stacks its key/value pairs when the container is under 320px,
145
+ lets long values wrap anywhere and hides horizontal overflow, but the page margins
146
+ belong to the host.
147
+ 5. **The visual regression covers facts, not pixels.** A real browser (Edge driven by
148
+ `playwright-core`, no browser download) captured light, dark and 390px states,
149
+ asserting that elements render, that nothing overflows horizontally, and the measured
150
+ theme colours. Screenshots and the script live in `_visual/`; there is no per-pixel
151
+ baseline diff.
152
+
153
+ ## Extending it
154
+
155
+ - **New tool.** Copy the raw JSON-Schema `ToolDefinition` shape of `inspectTool`
156
+ (`lib/index.js:290`) and register it with
157
+ `ctx.effect(() => ctx.tools.register(<tool>), 'showcase: <name>')` (`:309`). `output`
158
+ must carry `{ schema, render }`. Add its deny patterns to `DESTRUCTIVE_PATTERNS`
159
+ (`:14`), then cover it in `tests/host.test.mjs`.
160
+ - **New slot.** In `apply()` (`lib/client.js:290`) copy the
161
+ `slots.inject(<slot>, () => slots.register({ name, ... }, Component))` shape:
162
+ `settings.section` at `:296`, `sidebar.right.pane.tab` at `:314`,
163
+ `sidebar.footer.action` at `:322`. A right-sidebar tab needs both halves — the body
164
+ key must equal the id of the type registered through `sidebarRightTabs.register`.
165
+ - **New runtime string.** Add the key to both `dict.en` and `dict.zh`
166
+ (`lib/client.js:12`) and extend `DICT_KEYS` in
167
+ `tests/helpers/client-harness.mjs` so the bilingual test keeps guarding it.
168
+
169
+ ## Tests
170
+
171
+ ```sh
172
+ npm install # once, needs registry access (devDependency: vitest)
173
+ npm test # offline from then on, one command, whole suite
174
+ ```
175
+
176
+ 72 tests in 2 files, no machine path baked in — every path derives from
177
+ `import.meta.url`, and the workspace fixture is a temp directory created per run.
178
+
179
+ - `tests/host.test.mjs` (47): module shape, `ctx.effect` lifecycle, tool registration,
180
+ `output` contract with the returned value validated against its own schema, snapshot
181
+ shape, "no `undefined`", `""`/`null` degradation, git-branch discovery, all eight
182
+ destructive patterns (plus collapsed spellings, array arguments, key scoping), the
183
+ word-boundary deny and allow cases for power commands, hook-throw still allowing,
184
+ gate on/off, the command's three inputs, the provided service.
185
+ - `tests/client.test.mjs` (25): lazy-CJS registration, factory exports, exactly three
186
+ seats, tab-type/body-key agreement, the footer's open path, the eight bilingual dict
187
+ keys, language resolution including a host with no locale service, `fetchSnapshot`
188
+ degradation (no service / `ok` / bare snapshot / `ok:false` / throwing / malformed),
189
+ rendering with and without data, refresh clicks.
190
+
191
+ Runner: **vitest 3** (the declared devDependency). The fallback would have been Node's
192
+ built-in `node:test`; it was not needed because the registry was reachable.
package/README.zh.md ADDED
@@ -0,0 +1,174 @@
1
+ # dsh-plugin-showcase
2
+
3
+ 一个 **DeepSeek Harness(DSH)** 参考插件。它只做一件事——用一句话说明它所在的这台
4
+ DSH 主机是什么状况——但把八个插件能力面一次性用满,因此整块接口面可以在一个小包里
5
+ 读完。
6
+
7
+ | 能力面 | 文件与位置 |
8
+ | --- | --- |
9
+ | 工具 `showcase_inspect` | `lib/index.js:290`(定义)、`:309`(注册) |
10
+ | 钩子 `tools/pre-execute` | `lib/index.js:311`(拦截门)、`:14`–`:31`(模式表) |
11
+ | 斜杠命令 `/showcase` | `lib/index.js:322` |
12
+ | 设置卡片 | `lib/client.js:296`(`settings.section`,id `showcase`) |
13
+ | 右侧栏面板 | 面板体在 `lib/client.js:314`,tab 类型走 `sidebarRightTabs` |
14
+ | 国际化 | `lib/client.js:12`(8 个运行期键)、`locale/en.json`、`locale/zh.json` |
15
+ | 市场元数据 | `locale/*.json` 的 meta、`icon.svg`、`package.json` 的 `dsh` 块 |
16
+ | 测试 | `tests/host.test.mjs`、`tests/client.test.mjs` |
17
+
18
+ 全程零构建:只有原生 ESM JavaScript,没有第三方运行时依赖,也没有任何
19
+ `@deepseek-ai` import。
20
+
21
+ ## 安装与卸载
22
+
23
+ 本机使用的 profile:`C:\Users\baba\.dsh\profiles\web`。
24
+
25
+ ### 方式一:官方命令
26
+
27
+ ```sh
28
+ dsh plugin --profile web add <本目录路径>
29
+ ```
30
+
31
+ `dsh plugin` 把参数转发给 profile 目录里的 pnpm,链接该 checkout,并且因为这个包
32
+ 声明了 `dsh.bundle`,会把它追加进 `dsh.profile.bundles`。
33
+
34
+ 本机 `PATH` 上没有全局 `dsh`,所以同一个命令改从检出调用:
35
+
36
+ ```sh
37
+ node "C:\Users\baba\Desktop\deepseek-harness\apps\cli\lib\bin.js" plugin --profile web add "C:\Users\baba\Desktop\DSH工作区\plugins\dsh-plugin-showcase"
38
+ ```
39
+
40
+ 卸载:`... plugin --profile web remove dsh-plugin-showcase`(依赖与 bundle 层一起撤掉)。
41
+
42
+ ### 方式二:本机实际使用的手动等价物
43
+
44
+ 三处改动,不跑安装:
45
+
46
+ 1. `C:\Users\baba\.dsh\profiles\web\package.json` 的 `dependencies` 加一行
47
+ `"dsh-plugin-showcase": "link:C:/Users/baba/Desktop/DSH工作区/plugins/dsh-plugin-showcase"`;
48
+ 2. 同一个文件的 `dsh.profile.bundles` 末尾追加 `"dsh-plugin-showcase"`
49
+ (目前它就是最后一项);
50
+ 3. 建一个目录 junction,让 Node 能解析这个包名:
51
+ `C:\Users\baba\.dsh\profiles\web\node_modules\dsh-plugin-showcase`
52
+ → `C:\Users\baba\Desktop\DSH工作区\plugins\dsh-plugin-showcase`
53
+ (本机实测为 `<JUNCTION>` 重解析点;重建命令是
54
+ `cmd /c mklink /J <链接> <目标>`)。
55
+
56
+ **为什么本机不用方式一。** `dsh plugin ... add` 会在 profile 内跑一次安装,而这次
57
+ 安装会重新解析整棵树。这个 profile 里已经装着十一个第三方插件(`dsh-cost-meter`、
58
+ `dsh-better-sidebar`、`dsh-chat-import`、`dsh-context`、`dsh-memgas`、
59
+ `dsh-wallpaper-bg`、`@linxin666/*`、`@liustack/*`、`@tt-a1i/archify-dsh`)。
60
+ 上面三处改动正是链接安装后的最终状态,但没有那次可能改写这十一个插件版本的解析过程。
61
+
62
+ 不启动、只验证层(以下为实测输出):
63
+
64
+ ```sh
65
+ node "C:\Users\baba\Desktop\deepseek-harness\apps\cli\lib\bin.js" --profile web --dump-config
66
+ # ...
67
+ # == dsh-plugin-showcase
68
+ # - id: showcase
69
+ # name: dsh-plugin-showcase
70
+ ```
71
+
72
+ DSH 的 HMR 监视 profile 的 `package.json`(只看 `bundles` 数组)与 profile 的 patch
73
+ 文件,因此追加条目会在不重启宿主进程的情况下重新组合;浏览器那一半需要刷新一次页面。
74
+ 撤销同样只是把这三处改回去。
75
+
76
+ ## 每个能力面怎么验证
77
+
78
+ **1. 工具。** `showcase_inspect` 无参数,返回 snapshot。让 Agent 跑一次;渲染文本首行
79
+ 是 `DSH 巡检报告 <generatedAt>`,随后是 `## 工作区`、`## 会话`、`## 插件`。降级也在这里
80
+ 看得见:未知会话显示 `未知`。
81
+
82
+ **2. 钩子。** 拦截门默认开启。当一次工具调用的 `command`、`cmd`、`script` 或 `code`
83
+ 参数(不区分大小写)命中下列任一模式时拒绝执行:
84
+
85
+ - `rm -rf /`、`rm -rf ~`
86
+ - fork 炸弹:`:(){:|:&;}`、`:(){ :|:& };:`、`.(){ :|:& };:`、`bomb(){ bomb|bomb& };bomb`
87
+ - `del /f /s /q c:\`、`format c:`
88
+ - 处在命令位置上的 `shutdown` / `shutdown.exe` 词(前面可以带 `sudo`、`doas` 或
89
+ `pkexec`),例如 `shutdown /s /t 0`、`sudo shutdown -h now`
90
+
91
+ 把空格抹掉、或用制表符代替空格的写法(例如 `rm -rf /tmp`)同样拦得住。拒绝理由固定为
92
+ `showcase: 拒绝执行破坏性命令`;其余一律调用 `next()` 放行。这四个键之外的参数文本
93
+ 不参与匹配。
94
+
95
+ **3. 命令。** `/showcase` 输出与工具同形的报告文本;`/showcase on` 与
96
+ `/showcase off` 切换拦截门(`showcase 拦截门: 已开启` / `已关闭`);其它输入回
97
+ `/showcase [on|off]`。状态属于插件实例,重新 apply 会回到开启。
98
+
99
+ **4. 设置卡片。** 打开设置,导航里有一行 **功能展示 / Showcase**(注册 id `showcase`,
100
+ order 90),里面是这张卡:刷新按钮、"打开巡检面板"按钮,然后是快照事实或降级文案。
101
+
102
+ **5. 右侧栏面板。** tab 类型 id `showcase-panel`、kind `showcase`、标题
103
+ **巡检面板 / Showcase panel**,面板体挂在同一个 key 上。打开它有两个入口:左侧栏底部
104
+ Settings 旁边的 ◎ 按钮,以及设置卡上的"打开巡检面板"按钮,两者最终都调用
105
+ `sidebarRight.openTab('showcase')`。这个 tab 类型没有声明 `guide` 条目,所以它不会
106
+ 出现在右侧栏的引导页里。
107
+
108
+ **6. 国际化。** 八个运行期键——`title`、`subtitle`、`panelTitle`、`panelEmpty`、
109
+ `gateOn`、`gateOff`、`openPanel`、`refresh`——`en` 与 `zh` 各一份。语言解析顺序是
110
+ `ctx.locale` → `navigator.language` → `en`;没有 locale 服务的宿主照样渲染真实文案。
111
+ 市场元数据来自 `locale/en.json`(锚点)与 `locale/zh.json`。
112
+
113
+ **7. 市场元数据。** 插件页显示 `icon.svg`,以及 `locale/*.json` 里 meta 的本地化标题
114
+ 与描述。
115
+
116
+ **8. 测试。** `npm test`,见下文。
117
+
118
+ ## 诚实的限制清单
119
+
120
+ 1. **client 侧拿不到宿主实时数据,而且在零构建约束下拿不到。** 官方 remote API 路线需要
121
+ typert 装饰器、代码生成步骤和第三方依赖——这三条与"原生 ESM、不构建、不加依赖"逐条
122
+ 冲突。`lib/client.js` 仍然会去探测几种可能的形状(`remote.showcase`、
123
+ `ctx.showcase`),失败即降级为 `null`。结果就是:面板与设置卡片显示的是**静态能力
124
+ 清单加降级文案**,不是主机数据。
125
+ 2. **`sidebar.footer.action` 座位不投影 `label`。** 侧栏外壳只以 `{ wide }` 渲染这个
126
+ 座位,可见文本由组件自己画(`◎`,标签通过 `aria-label` 承载)。那个 `label` thunk
127
+ 是合法的 `SlotLabel` 也没写错,只是这个座位没有壳去读它。
128
+ 3. **拦截门是按字面串匹配的启发式,不是沙箱。** 它统一大小写与空白、认得命令位置,但
129
+ 混淆写法(base64、变量展开、换到别的参数键)能绕过去。它的用途是演示扩展点并拦住明显
130
+ 的误操作,仅此而已。
131
+ 4. **390px 窄屏下设置页整体受宿主布局限制。** 宿主设置弹窗在这个宽度不收左侧导航,
132
+ 内容列因此很窄。本插件的卡片会在容器 <320px 时把键值对纵向堆叠、长值任意换行,并
133
+ 隐藏横向溢出,但页面整体留白由宿主决定。
134
+ 5. **视觉回归覆盖事实,不覆盖像素。** 已用真浏览器(Edge + `playwright-core`,不下载
135
+ 浏览器)抓过浅色、深色、390px 三种状态,断言的是元素是否渲染、有无横向溢出、主题实测
136
+ 色值;截图与脚本在 `_visual/`,没有做逐像素基线比对。
137
+
138
+ ## 二次开发指引
139
+
140
+ - **加一个工具。** 照抄 `inspectTool`(`lib/index.js:290`)那种原始 JSON-Schema
141
+ `ToolDefinition` 形状,用
142
+ `ctx.effect(() => ctx.tools.register(<tool>), 'showcase: <name>')` 注册(`:309`)。
143
+ `output` 必须带 `{ schema, render }`。要拦截的新模式加进 `DESTRUCTIVE_PATTERNS`
144
+ (`:14`),然后在 `tests/host.test.mjs` 里补用例。
145
+ - **加一个新 slot。** 在 `apply()`(`lib/client.js:290`)里照抄
146
+ `slots.inject(<slot>, () => slots.register({ name, ... }, 组件))` 这个形状:
147
+ `settings.section` 在 `:296`、`sidebar.right.pane.tab` 在 `:314`、
148
+ `sidebar.footer.action` 在 `:322`。右侧栏 tab 必须两半都注册——面板体的 key 要等于
149
+ 通过 `sidebarRightTabs.register` 注册的类型 id。
150
+ - **加一条运行期文案。** 同时加进 `dict.en` 与 `dict.zh`(`lib/client.js:12`),并把
151
+ `tests/helpers/client-harness.mjs` 里的 `DICT_KEYS` 一起扩展,双语用例才会继续守住它。
152
+
153
+ ## 测试
154
+
155
+ ```sh
156
+ npm install # 只做一次,需要访问 registry(devDependency: vitest)
157
+ npm test # 之后完全离线,一条命令跑完整个套件
158
+ ```
159
+
160
+ 两个文件共 72 个用例,不写死任何机器路径——所有路径都由 `import.meta.url` 推导,
161
+ 工作区夹具是每次运行现建的临时目录。
162
+
163
+ - `tests/host.test.mjs`(47 个):模块形状、`ctx.effect` 生命周期、工具注册、
164
+ `output` 契约(返回值用声明的 schema 反向校验)、snapshot 形状、"不出现
165
+ `undefined`"、`""`/`null` 降级、git 分支发现、八条破坏性模式(外加抹空格的写法、
166
+ 数组参数、参数键范围)、关机类命令的词边界拒绝与放行、钩子内部抛错仍放行、拦截门开关、
167
+ 命令的三种输入、对外提供的服务。
168
+ - `tests/client.test.mjs`(25 个):lazy-CJS 登记、factory 导出、恰好三个座位、
169
+ tab 类型与面板体 key 一致、侧栏按钮的打开路径、八键双语词典、语言解析(含没有 locale
170
+ 服务的宿主)、`fetchSnapshot` 的各级降级(无服务 / `ok` / 裸快照 / `ok:false` / 抛错 /
171
+ 形状不对)、有数据与无数据两种渲染、刷新点击。
172
+
173
+ 运行器选用 **vitest 3**(已声明的 devDependency)。备选是 Node 内置的 `node:test`,
174
+ 因为 registry 可达,所以没有用到。
@@ -0,0 +1,5 @@
1
+ # dsh-plugin-showcase 的 bundle 层:一行 host + client 同包的插入。
2
+ # name 必须是裸包名 —— modules 的宿主半侧只把 client 半挂在裸包名行上。
3
+ - insert:
4
+ - id: showcase
5
+ name: 'dsh-plugin-showcase'
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="#2f6fed"/>
3
+ <circle cx="32" cy="32" r="15" fill="none" stroke="#fff" stroke-width="3" stroke-dasharray="7 5"/>
4
+ <text x="32" y="41" font-family="sans-serif" font-size="22" font-weight="700" fill="#fff" text-anchor="middle">S</text>
5
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,456 @@
1
+ // dsh-plugin-showcase · client half (lazy-CJS factory, no build step).
2
+ // Loading this file only registers a factory; the body runs when the module is materialized.
3
+ window.__ModuleLoader__.load({
4
+ id: 'dsh-plugin-showcase',
5
+ factory: (require) => {
6
+ const React = require('react')
7
+ const h = React.createElement
8
+
9
+ const TAB_ID = 'showcase-panel'
10
+ const TAB_KIND = 'showcase'
11
+
12
+ const dict = {
13
+ en: {
14
+ title: 'Showcase',
15
+ subtitle: 'A DeepSeek Harness reference plugin: one tool, one hook, one command, a settings card and a right-sidebar panel.',
16
+ panelTitle: 'Showcase panel',
17
+ panelEmpty: 'No snapshot available — the Showcase host service is not reachable right now.',
18
+ gateOn: 'Guard gate: on',
19
+ gateOff: 'Guard gate: off',
20
+ openPanel: 'Open showcase panel',
21
+ refresh: 'Refresh',
22
+ },
23
+ zh: {
24
+ title: '功能展示',
25
+ subtitle: 'DeepSeek Harness 参考插件:一个工具、一个钩子、一条命令、一张设置卡片与一个右侧栏面板。',
26
+ panelTitle: '巡检面板',
27
+ panelEmpty: '暂时取不到巡检数据:Showcase 宿主服务当前不可达。',
28
+ gateOn: '拦截门:已开启',
29
+ gateOff: '拦截门:已关闭',
30
+ openPanel: '打开巡检面板',
31
+ refresh: '刷新',
32
+ },
33
+ }
34
+
35
+ // The three seats are mounted by different owners; the Client Context of this
36
+ // plugin is the one dependency they share, so it is kept here once apply ran.
37
+ let currentCtx = null
38
+
39
+ function pickLang(value) {
40
+ const tag = typeof value === 'string' ? value.toLowerCase().split(/[-_]/)[0] : ''
41
+ return tag === 'zh' || tag === 'en' ? tag : null
42
+ }
43
+
44
+ function browserLang() {
45
+ const nav = typeof navigator === 'undefined' ? '' : navigator.language
46
+ return typeof nav === 'string' && nav.toLowerCase().startsWith('zh') ? 'zh' : 'en'
47
+ }
48
+
49
+ function lang(ctx) {
50
+ const target = ctx === undefined || ctx === null ? currentCtx : ctx
51
+ try {
52
+ const state = target === null || target === undefined ? null : target.get?.('locale')?.getSnapshot?.()
53
+ return pickLang(state?.active) || pickLang(state?.locale) || browserLang()
54
+ } catch (error) {
55
+ return browserLang()
56
+ }
57
+ }
58
+
59
+ function text(ctx) {
60
+ return dict[lang(ctx)]
61
+ }
62
+
63
+ function getOf(ctx, key) {
64
+ if (ctx === null || ctx === undefined) return undefined
65
+ try {
66
+ const value = ctx[key]
67
+ if (value !== undefined && value !== null) return value
68
+ return typeof ctx.get === 'function' ? ctx.get(key) : undefined
69
+ } catch (error) {
70
+ return undefined
71
+ }
72
+ }
73
+
74
+ // ---- data ----------------------------------------------------------------
75
+
76
+ // B owns the wire name (§7); every shape it may legitimately take is tried in
77
+ // one place, and any failure degrades to null instead of throwing.
78
+ function remoteCandidate(ctx) {
79
+ const target = ctx === undefined || ctx === null ? currentCtx : ctx
80
+ const reads = [
81
+ () => getOf(target, 'remote.showcase'),
82
+ () => getOf(getOf(target, 'remote'), 'showcase'),
83
+ () => getOf(target, 'showcase'),
84
+ ]
85
+ for (const read of reads) {
86
+ try {
87
+ const service = read()
88
+ if (service !== undefined && service !== null && typeof service.snapshot === 'function') return service
89
+ } catch (error) {
90
+ // try the next shape
91
+ }
92
+ }
93
+ return null
94
+ }
95
+
96
+ function asSnapshot(value) {
97
+ if (value === null || typeof value !== 'object') return null
98
+ if (Array.isArray(value.sections) || typeof value.generatedAt === 'string') return value
99
+ return null
100
+ }
101
+
102
+ // A RemoteResult carries { ok, value } | { ok:false, error }; a plain service
103
+ // answers the Snapshot itself.
104
+ function unwrap(result) {
105
+ if (result === null || typeof result !== 'object') return null
106
+ if (typeof result.ok === 'boolean') return result.ok ? asSnapshot(result.value) : null
107
+ return asSnapshot(result)
108
+ }
109
+
110
+ async function fetchSnapshot(ctx) {
111
+ try {
112
+ const service = remoteCandidate(ctx)
113
+ if (service === null) return null
114
+ return unwrap(await service.snapshot())
115
+ } catch (error) {
116
+ return null
117
+ }
118
+ }
119
+
120
+ // ---- shared UI -----------------------------------------------------------
121
+
122
+ const MONO = 'ui-monospace, SFMono-Regular, Consolas, monospace'
123
+
124
+ // Every box is min-width:0 + overflowX:hidden so a squeezed container can only
125
+ // shorten lines, never force the host into a horizontal scrollbar.
126
+ const S = {
127
+ card: { display: 'flex', flexDirection: 'column', gap: 10, padding: '16px 18px', minWidth: 0, maxWidth: '100%', boxSizing: 'border-box', overflowX: 'hidden', fontSize: 13, color: 'inherit' },
128
+ cardNarrow: { gap: 8, padding: '10px 8px' },
129
+ pane: { display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', minWidth: 0, maxWidth: '100%', boxSizing: 'border-box', overflowX: 'hidden', fontSize: 13, color: 'inherit' },
130
+ paneNarrow: { gap: 8, padding: '8px' },
131
+ head: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
132
+ title: { margin: 0, fontSize: 14, fontWeight: 600 },
133
+ subtitle: { margin: 0, fontSize: 12, lineHeight: 1.55, opacity: 0.72 },
134
+ toolbar: { display: 'flex', flexWrap: 'wrap', gap: 8 },
135
+ button: { font: 'inherit', fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid rgba(128,128,128,.45)', background: 'transparent', color: 'inherit', cursor: 'pointer' },
136
+ footerAction: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: 6, border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 14 },
137
+ note: { margin: 0, fontSize: 12, lineHeight: 1.55, opacity: 0.7 },
138
+ group: { display: 'flex', flexDirection: 'column', gap: 3 },
139
+ groupTitle: { margin: '4px 0 0', fontSize: 12, fontWeight: 600, opacity: 0.85 },
140
+ row: { display: 'flex', gap: 8, alignItems: 'baseline', minWidth: 0 },
141
+ rowNarrow: { display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 },
142
+ rowLabel: { flex: '0 0 auto', minWidth: 82, fontSize: 12, opacity: 0.6 },
143
+ rowLabelNarrow: { fontSize: 11, opacity: 0.6 },
144
+ rowValue: { flex: '1 1 auto', minWidth: 0, fontSize: 12, fontFamily: MONO, overflowWrap: 'anywhere', wordBreak: 'break-word' },
145
+ rowValueNarrow: { fontSize: 12, fontFamily: MONO, overflowWrap: 'anywhere', wordBreak: 'break-word' },
146
+ }
147
+
148
+ // Below this the label column alone eats the line, so pairs stack instead of
149
+ // squeezing one character per line.
150
+ const NARROW_MAX = 320
151
+
152
+ function row(key, label, value, narrow) {
153
+ const shown = value === null || value === undefined || value === '' ? '—' : String(value)
154
+ return h('div', { key, style: narrow === true ? S.rowNarrow : S.row },
155
+ h('span', { style: narrow === true ? S.rowLabelNarrow : S.rowLabel }, label),
156
+ h('span', { style: narrow === true ? S.rowValueNarrow : S.rowValue }, shown))
157
+ }
158
+
159
+ function factRows(snapshot, t) {
160
+ const workspace = snapshot.workspace || {}
161
+ const session = snapshot.session || {}
162
+ const plugin = snapshot.plugin || {}
163
+ return [
164
+ ['generatedAt', snapshot.generatedAt],
165
+ ['cwd', workspace.cwd],
166
+ ['gitBranch', workspace.gitBranch],
167
+ ['model', session.model],
168
+ ['version', plugin.version],
169
+ ['gate', plugin.gateEnabled === true ? t.gateOn : t.gateOff],
170
+ ]
171
+ }
172
+
173
+ function snapshotBody(snapshot, t, narrow) {
174
+ const sections = Array.isArray(snapshot.sections) ? snapshot.sections : []
175
+ const facts = factRows(snapshot, t)
176
+ return h('div', { style: S.group },
177
+ h('h4', { style: S.groupTitle }, 'snapshot'),
178
+ facts.map(([label, value], index) => row('fact-' + index, label, value, narrow)),
179
+ sections.map((section, index) => h('div', { key: 'section-' + index, style: S.group },
180
+ h('h4', { style: S.groupTitle }, String((section && section.title) || '')),
181
+ (Array.isArray(section && section.items) ? section.items : []).map((item, itemIndex) =>
182
+ row('item-' + index + '-' + itemIndex, String((item && item.label) || ''), item && item.value, narrow)))))
183
+ }
184
+
185
+ // Contract §7 fallback: when the host exposes no client-reachable remote the
186
+ // seats still say what this plugin offers instead of rendering nothing.
187
+ const CAPABILITIES = [
188
+ ['tool', 'showcase_inspect'],
189
+ ['command', '/showcase'],
190
+ ['plugin', 'dsh-plugin-showcase'],
191
+ ]
192
+
193
+ function fallbackView(t, withNote, narrow) {
194
+ return h('div', { style: S.group },
195
+ withNote ? h('p', { style: S.note }, t.panelEmpty) : null,
196
+ CAPABILITIES.map(([label, value], index) => row('cap-' + index, label, value, narrow)))
197
+ }
198
+
199
+ // Re-renders on a language switch so the visible copy follows the label thunks.
200
+ function useLangTick(ctx) {
201
+ if (typeof React.useState !== 'function' || typeof React.useEffect !== 'function') return
202
+ const [, setGen] = React.useState(0)
203
+ React.useEffect(() => {
204
+ const locale = getOf(ctx, 'locale')
205
+ let dispose = null
206
+ try {
207
+ dispose = typeof locale?.subscribe === 'function' ? locale.subscribe(() => setGen((value) => value + 1)) : null
208
+ } catch (error) {
209
+ dispose = null
210
+ }
211
+ return () => {
212
+ try {
213
+ if (typeof dispose === 'function') dispose()
214
+ } catch (error) {
215
+ // a locale subscription that cannot be released must not break unmount
216
+ }
217
+ }
218
+ }, [])
219
+ }
220
+
221
+ function useSnapshot(ctx) {
222
+ const [state, setState] = React.useState({ loading: true, data: null })
223
+ const refresh = () => {
224
+ setState((previous) => ({ loading: true, data: previous.data }))
225
+ fetchSnapshot(ctx).then(
226
+ (data) => setState({ loading: false, data }),
227
+ () => setState({ loading: false, data: null }))
228
+ }
229
+ React.useEffect(() => { refresh() }, [])
230
+ return [state, refresh]
231
+ }
232
+
233
+ // The host forces a very narrow content column on small screens; stacking is
234
+ // driven by the measured box, not the viewport, because only the box knows.
235
+ function useNarrow(ref) {
236
+ const [narrow, setNarrow] = React.useState(false)
237
+ React.useEffect(() => {
238
+ const element = ref === null || ref === undefined ? null : ref.current
239
+ const box = element !== null && element !== undefined && typeof element.getBoundingClientRect === 'function'
240
+ ? element
241
+ : null
242
+ const viewport = () => (typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : NARROW_MAX + 1)
243
+ const measure = () => {
244
+ const width = box === null ? viewport() : box.getBoundingClientRect().width
245
+ setNarrow(width > 0 && width < NARROW_MAX)
246
+ }
247
+ measure()
248
+ if (box !== null && typeof ResizeObserver === 'function') {
249
+ const observer = new ResizeObserver(measure)
250
+ observer.observe(box)
251
+ return () => observer.disconnect()
252
+ }
253
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
254
+ window.addEventListener('resize', measure)
255
+ return () => window.removeEventListener('resize', measure)
256
+ }
257
+ return undefined
258
+ }, [])
259
+ return narrow === true
260
+ }
261
+
262
+ function useRefOf() {
263
+ return typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
264
+ }
265
+
266
+ // Cached by the service watch below: the controller may arrive after apply.
267
+ let rightController = null
268
+
269
+ function resolveSidebarRight(ctx) {
270
+ const target = ctx === undefined || ctx === null ? currentCtx : ctx
271
+ const reads = [
272
+ () => (target === null || target === undefined ? undefined : target.get?.('sidebarRight')),
273
+ () => (target === null || target === undefined ? undefined : target.sidebarRight),
274
+ () => rightController,
275
+ ]
276
+ for (const read of reads) {
277
+ try {
278
+ const candidate = read()
279
+ if (candidate !== undefined && candidate !== null && typeof candidate.openTab === 'function') return candidate
280
+ } catch (error) {
281
+ // keep looking
282
+ }
283
+ }
284
+ return null
285
+ }
286
+
287
+ function openPanel(ctx) {
288
+ const right = resolveSidebarRight(ctx)
289
+ if (right === null) {
290
+ console.warn('[dsh-plugin-showcase] sidebarRight is not available; the showcase panel cannot be opened')
291
+ return false
292
+ }
293
+ try {
294
+ right.openTab(TAB_KIND)
295
+ return true
296
+ } catch (error) {
297
+ console.warn('[dsh-plugin-showcase] sidebarRight.openTab("' + TAB_KIND + '") failed:', error)
298
+ return false
299
+ }
300
+ }
301
+
302
+ // ---- seats ---------------------------------------------------------------
303
+
304
+ function SettingsCard() {
305
+ const ctx = currentCtx
306
+ const t = text(ctx)
307
+ useLangTick(ctx)
308
+ const [state, refresh] = useSnapshot(ctx)
309
+ const ref = useRefOf()
310
+ const narrow = useNarrow(ref)
311
+ return h('section', { ref, style: narrow ? { ...S.card, ...S.cardNarrow } : S.card },
312
+ h('h3', { style: S.title }, t.title),
313
+ h('p', { style: S.subtitle }, t.subtitle),
314
+ h('div', { style: S.toolbar },
315
+ h('button', { type: 'button', style: S.button, onClick: refresh }, t.refresh),
316
+ h('button', { type: 'button', style: S.button, onClick: () => openPanel(ctx) }, t.openPanel)),
317
+ state.data === null ? fallbackView(t, !state.loading, narrow) : snapshotBody(state.data, t, narrow))
318
+ }
319
+
320
+ function ShowcasePanel() {
321
+ const ctx = currentCtx
322
+ const t = text(ctx)
323
+ useLangTick(ctx)
324
+ const [state, refresh] = useSnapshot(ctx)
325
+ const ref = useRefOf()
326
+ const narrow = useNarrow(ref)
327
+ return h('div', { ref, style: narrow ? { ...S.pane, ...S.paneNarrow } : S.pane },
328
+ h('div', { style: S.head },
329
+ h('h3', { style: S.title }, t.panelTitle),
330
+ h('button', { type: 'button', style: S.button, onClick: refresh }, t.refresh)),
331
+ state.data === null ? fallbackView(t, !state.loading, narrow) : snapshotBody(state.data, t, narrow))
332
+ }
333
+
334
+ function FooterAction(props) {
335
+ const ctx = currentCtx
336
+ const t = text(ctx)
337
+ useLangTick(ctx)
338
+ const open = props !== null && props !== undefined && typeof props.open === 'function'
339
+ ? props.open
340
+ : () => openPanel(ctx)
341
+ return h('button', {
342
+ type: 'button',
343
+ title: t.openPanel,
344
+ 'aria-label': t.openPanel,
345
+ onClick: open,
346
+ style: S.footerAction,
347
+ }, '◎')
348
+ }
349
+
350
+ // ---- apply ---------------------------------------------------------------
351
+
352
+ function effectOf(ctx) {
353
+ if (typeof ctx.effect === 'function') return (fn, label) => ctx.effect(fn, label)
354
+ return (fn) => {
355
+ const dispose = fn()
356
+ return () => {
357
+ if (typeof dispose === 'function') dispose()
358
+ }
359
+ }
360
+ }
361
+
362
+ // ui-sidebar-right provides these services in its own apply, which may run
363
+ // after ours: an immediate ctx.get() read loses the race and the tab type is
364
+ // never registered, so the service is awaited instead of sampled.
365
+ function watchService(ctx, name, receive) {
366
+ const settle = (service, scope) => {
367
+ if (service === undefined || service === null) return
368
+ try {
369
+ receive(service, scope)
370
+ } catch (error) {
371
+ console.warn('[dsh-plugin-showcase] handler for ' + name + ' failed:', error)
372
+ }
373
+ }
374
+ if (typeof ctx.inject === 'function') {
375
+ try {
376
+ ctx.inject([name], (scope) => settle(scope === null || scope === undefined ? undefined : scope[name], scope))
377
+ return
378
+ } catch (error) {
379
+ console.warn('[dsh-plugin-showcase] ctx.inject(' + name + ') is unusable, falling back to a service watch:', error)
380
+ }
381
+ }
382
+ const present = getOf(ctx, name)
383
+ if (present !== undefined && present !== null) {
384
+ settle(present, ctx)
385
+ return
386
+ }
387
+ if (typeof setTimeout !== 'function') return
388
+ effectOf(ctx)(() => {
389
+ let done = false
390
+ let tries = 0
391
+ let timer = null
392
+ const tick = () => {
393
+ if (done) return
394
+ const service = getOf(ctx, name)
395
+ if (service !== undefined && service !== null) {
396
+ done = true
397
+ settle(service, ctx)
398
+ return
399
+ }
400
+ tries += 1
401
+ if (tries < 20) timer = setTimeout(tick, 150)
402
+ }
403
+ timer = setTimeout(tick, 0)
404
+ return () => {
405
+ done = true
406
+ if (timer !== null) clearTimeout(timer)
407
+ }
408
+ }, 'showcase: await ' + name)
409
+ }
410
+
411
+ function apply(ctx) {
412
+ currentCtx = ctx
413
+ const slots = getOf(ctx, 'slots')
414
+ if (slots === undefined || slots === null) return
415
+ const effect = effectOf(ctx)
416
+
417
+ effect(() => slots.inject('settings.section', () => slots.register({
418
+ name: 'settings.section',
419
+ id: 'showcase',
420
+ order: 90,
421
+ label: () => dict[lang(ctx)].title,
422
+ }, SettingsCard)), 'showcase: settings section')
423
+
424
+ // The tab type owns the id; that same id is the key its body seats under.
425
+ watchService(ctx, 'sidebarRightTabs', (tabs) => {
426
+ if (typeof tabs.register !== 'function') return
427
+ effect(() => tabs.register({
428
+ id: TAB_ID,
429
+ kind: TAB_KIND,
430
+ canOpen: () => true,
431
+ title: () => dict[lang(ctx)].panelTitle,
432
+ }), 'showcase: right pane tab type')
433
+ })
434
+
435
+ watchService(ctx, 'sidebarRight', (controller) => { rightController = controller })
436
+
437
+ effect(() => slots.inject('sidebar.right.pane.tab', () => slots.register({
438
+ name: 'sidebar.right.pane.tab',
439
+ key: TAB_ID,
440
+ }, ShowcasePanel)), 'showcase: right pane body')
441
+
442
+ // 'label' is legal list metadata (string | (() => string), resolved by
443
+ // resolveSlotLabel), but this seat is projected with only { wide }, so the
444
+ // button's own glyph and aria-label carry the visible text.
445
+ effect(() => slots.inject('sidebar.footer.action', () => slots.register({
446
+ name: 'sidebar.footer.action',
447
+ id: 'showcase-open',
448
+ order: 90,
449
+ label: () => dict[lang(ctx)].openPanel,
450
+ inject: () => ({ open: () => openPanel(ctx) }),
451
+ }, FooterAction)), 'showcase: sidebar footer action')
452
+ }
453
+
454
+ return { apply, inject: ['slots'], fetchSnapshot, lang, dict, TAB_ID, TAB_KIND }
455
+ },
456
+ })
package/lib/index.js ADDED
@@ -0,0 +1,334 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export const name = 'showcase'
5
+ export const inject = ['tools', 'commands']
6
+
7
+ const VERSION = '0.1.0'
8
+ const TOOL_NAME = 'showcase_inspect'
9
+ const COMMAND_NAME = 'showcase'
10
+ const DENY_REASON = 'showcase: 拒绝执行破坏性命令'
11
+ const USAGE = '/showcase [on|off]'
12
+ const UNKNOWN = '未知'
13
+
14
+ const DESTRUCTIVE_PATTERNS = Object.freeze([
15
+ 'rm -rf /',
16
+ 'rm -rf ~',
17
+ ':(){:|:&;}',
18
+ ':(){ :|:& };:',
19
+ '.(){ :|:& };:',
20
+ 'bomb(){ bomb|bomb& };bomb',
21
+ 'del /f /s /q c:\\',
22
+ 'format c:',
23
+ ])
24
+
25
+ // Whitespace-removed twins: "rm -rf /", "FORMAT\tC:", and the collapsed fork-bomb spellings.
26
+ const COMPACT_DESTRUCTIVE_PATTERNS = Object.freeze(
27
+ DESTRUCTIVE_PATTERNS.map(pattern => pattern.replace(/\s+/gu, '')),
28
+ )
29
+
30
+ const ELEVATION_PREFIXES = Object.freeze(['sudo', 'doas', 'pkexec'])
31
+ const SHUTDOWN_WORD = /^shutdown(\.exe)?(\/(?:s|r|f)\w*)?$/u
32
+
33
+ const COMMAND_ARG_KEYS = Object.freeze(['command', 'cmd', 'script', 'code'])
34
+
35
+ function nullableString() {
36
+ return { oneOf: [{ type: 'string' }, { type: 'null' }] }
37
+ }
38
+
39
+ function buildSnapshotSchema() {
40
+ return {
41
+ type: 'object',
42
+ additionalProperties: false,
43
+ required: ['generatedAt', 'workspace', 'session', 'plugin', 'sections'],
44
+ properties: {
45
+ generatedAt: { type: 'string', description: 'ISO 8601 timestamp of this snapshot.' },
46
+ workspace: {
47
+ type: 'object',
48
+ additionalProperties: false,
49
+ required: ['cwd', 'gitBranch'],
50
+ properties: {
51
+ cwd: { type: 'string', description: 'Effective working directory, empty when unknown.' },
52
+ gitBranch: { ...nullableString(), description: 'Checked-out git branch, null when not a repository.' },
53
+ },
54
+ },
55
+ session: {
56
+ type: 'object',
57
+ additionalProperties: false,
58
+ required: ['id', 'model'],
59
+ properties: {
60
+ id: { ...nullableString(), description: 'Session identity of the last observed agent.' },
61
+ model: { ...nullableString(), description: 'Provider-qualified model id, null when unknown.' },
62
+ },
63
+ },
64
+ plugin: {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ required: ['version', 'gateEnabled', 'toolName', 'command'],
68
+ properties: {
69
+ version: { type: 'string' },
70
+ gateEnabled: { type: 'boolean' },
71
+ toolName: { type: 'string' },
72
+ command: { type: 'string' },
73
+ },
74
+ },
75
+ sections: {
76
+ type: 'array',
77
+ items: {
78
+ type: 'object',
79
+ additionalProperties: false,
80
+ required: ['title', 'items'],
81
+ properties: {
82
+ title: { type: 'string' },
83
+ items: {
84
+ type: 'array',
85
+ items: {
86
+ type: 'object',
87
+ additionalProperties: false,
88
+ required: ['label', 'value'],
89
+ properties: {
90
+ label: { type: 'string' },
91
+ value: { type: 'string' },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ }
100
+ }
101
+
102
+ function readService(ctx, key) {
103
+ try {
104
+ const service = ctx.get(key)
105
+ return service === null || typeof service !== 'object' ? null : service
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ function readAgentId(agent) {
112
+ try {
113
+ return agent !== null && typeof agent === 'object' && typeof agent.id === 'string' && agent.id !== ''
114
+ ? agent.id
115
+ : null
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ function readModel(ctx) {
122
+ const selection = readService(ctx, 'agentDefaultModel')
123
+ if (selection === null) return null
124
+ try {
125
+ const current = typeof selection.currentSelection === 'function' ? selection.currentSelection() : null
126
+ if (current === null || typeof current !== 'object') return null
127
+ const model = typeof current.model === 'string' ? current.model : ''
128
+ if (model === '') return null
129
+ const provider = typeof current.provider === 'string' ? current.provider : ''
130
+ return provider === '' ? model : provider + '/' + model
131
+ } catch {
132
+ return null
133
+ }
134
+ }
135
+
136
+ async function readCwd(ctx, agent) {
137
+ const directory = readService(ctx, 'workingDirectory')
138
+ if (directory === null) return ''
139
+ if (agent !== null) {
140
+ try {
141
+ const ensured = await directory.ensure(agent)
142
+ if (typeof ensured === 'string' && ensured !== '') return ensured
143
+ } catch {
144
+ /* fall through to the deployment fallback */
145
+ }
146
+ }
147
+ return typeof directory.defaultDirectory === 'string' ? directory.defaultDirectory : ''
148
+ }
149
+
150
+ async function readGitBranch(cwd) {
151
+ if (cwd === '') return null
152
+ try {
153
+ const head = (await readFile(join(cwd, '.git', 'HEAD'), 'utf8')).trim()
154
+ const match = /^ref:\s*refs\/heads\/(.+)$/u.exec(head)
155
+ if (match !== null && typeof match[1] === 'string' && match[1] !== '') return match[1]
156
+ return /^[0-9a-f]{7,40}$/u.test(head) ? head : null
157
+ } catch {
158
+ return null
159
+ }
160
+ }
161
+
162
+ function display(value) {
163
+ return value === null || value === '' ? UNKNOWN : value
164
+ }
165
+
166
+ function renderSnapshot(value) {
167
+ const snapshot = value !== null && typeof value === 'object' ? value : {}
168
+ const generatedAt = typeof snapshot.generatedAt === 'string' ? snapshot.generatedAt : ''
169
+ const lines = ['DSH 巡检报告 ' + generatedAt]
170
+ const sections = Array.isArray(snapshot.sections) ? snapshot.sections : []
171
+ for (const section of sections) {
172
+ if (section === null || typeof section !== 'object') continue
173
+ lines.push('## ' + (typeof section.title === 'string' ? section.title : UNKNOWN))
174
+ const items = Array.isArray(section.items) ? section.items : []
175
+ for (const item of items) {
176
+ if (item === null || typeof item !== 'object') continue
177
+ const label = typeof item.label === 'string' ? item.label : ''
178
+ const itemValue = typeof item.value === 'string' ? item.value : ''
179
+ lines.push('- ' + label + ': ' + itemValue)
180
+ }
181
+ }
182
+ return lines.join('\n')
183
+ }
184
+
185
+ async function buildSnapshot(ctx, state, agent) {
186
+ const id = readAgentId(agent)
187
+ const model = readModel(ctx)
188
+ if (id !== null) state.session.id = id
189
+ if (model !== null) state.session.model = model
190
+
191
+ const cwd = await readCwd(ctx, agent)
192
+ const gitBranch = await readGitBranch(cwd)
193
+ const sessionId = state.session.id
194
+ const sessionModel = state.session.model
195
+ const gate = state.gateEnabled
196
+
197
+ return {
198
+ generatedAt: new Date().toISOString(),
199
+ workspace: { cwd, gitBranch },
200
+ session: { id: sessionId, model: sessionModel },
201
+ plugin: { version: VERSION, gateEnabled: gate, toolName: TOOL_NAME, command: '/' + COMMAND_NAME },
202
+ sections: [
203
+ {
204
+ title: '工作区',
205
+ items: [
206
+ { label: '目录', value: display(cwd) },
207
+ { label: 'Git 分支', value: display(gitBranch) },
208
+ ],
209
+ },
210
+ {
211
+ title: '会话',
212
+ items: [
213
+ { label: '会话 ID', value: display(sessionId) },
214
+ { label: '模型', value: display(sessionModel) },
215
+ ],
216
+ },
217
+ {
218
+ title: '插件',
219
+ items: [
220
+ { label: '版本', value: VERSION },
221
+ { label: '拦截门', value: gate ? '开启' : '关闭' },
222
+ { label: '工具', value: TOOL_NAME },
223
+ { label: '命令', value: '/' + COMMAND_NAME },
224
+ ],
225
+ },
226
+ ],
227
+ }
228
+ }
229
+
230
+ function collectCommandText(exec) {
231
+ const args = exec !== null && typeof exec === 'object' ? exec.arguments : null
232
+ if (args === null || typeof args !== 'object') return ''
233
+ const parts = []
234
+ for (const key of COMMAND_ARG_KEYS) {
235
+ if (!Object.hasOwn(args, key)) continue
236
+ const value = args[key]
237
+ if (typeof value === 'string') parts.push(value)
238
+ else if (Array.isArray(value)) {
239
+ for (const entry of value) {
240
+ if (typeof entry === 'string') parts.push(entry)
241
+ }
242
+ }
243
+ }
244
+ return parts.join('\n').toLowerCase()
245
+ }
246
+
247
+ // A shutdown only counts in command position: "echo shutdown" and
248
+ // "npm run shutdown-check" are arguments, not a power action.
249
+ function hasShutdownCommand(haystack) {
250
+ for (const segment of haystack.split(/[\n;&|]+/u)) {
251
+ const tokens = segment.split(/\s+/u).filter(token => token !== '')
252
+ let index = 0
253
+ while (index < tokens.length && ELEVATION_PREFIXES.includes(tokens[index])) index += 1
254
+ const word = tokens[index]
255
+ if (word !== undefined && SHUTDOWN_WORD.test(word)) return true
256
+ }
257
+ return false
258
+ }
259
+
260
+ function isDestructive(exec) {
261
+ const haystack = collectCommandText(exec)
262
+ if (haystack === '') return false
263
+ if (hasShutdownCommand(haystack)) return true
264
+ if (DESTRUCTIVE_PATTERNS.some(pattern => haystack.includes(pattern))) return true
265
+ const compact = haystack.replace(/\s+/gu, '')
266
+ return COMPACT_DESTRUCTIVE_PATTERNS.some(pattern => compact.includes(pattern))
267
+ }
268
+
269
+ async function runCommand(ctx, state, invocation) {
270
+ const raw = invocation !== null && typeof invocation === 'object' && typeof invocation.rawInput === 'string'
271
+ ? invocation.rawInput.trim().toLowerCase()
272
+ : ''
273
+ if (raw === 'on') {
274
+ state.gateEnabled = true
275
+ return { kind: 'success', text: 'showcase 拦截门: 已开启' }
276
+ }
277
+ if (raw === 'off') {
278
+ state.gateEnabled = false
279
+ return { kind: 'success', text: 'showcase 拦截门: 已关闭' }
280
+ }
281
+ if (raw !== '') return { kind: 'success', text: USAGE }
282
+ const agent = invocation !== null && typeof invocation === 'object' ? invocation.agent ?? null : null
283
+ return { kind: 'success', text: renderSnapshot(await buildSnapshot(ctx, state, agent)) }
284
+ }
285
+
286
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
287
+ export function apply(ctx) {
288
+ const state = { gateEnabled: true, session: { id: null, model: null } }
289
+
290
+ const inspectTool = {
291
+ name: TOOL_NAME,
292
+ description: 'Inspect this DSH host and return one snapshot: working directory, git branch, session identity, and the showcase plugin state.',
293
+ parameters: {
294
+ type: 'object',
295
+ additionalProperties: false,
296
+ properties: {},
297
+ required: [],
298
+ },
299
+ output: {
300
+ schema: buildSnapshotSchema(),
301
+ render: (_args, value) => [{ type: 'text', text: renderSnapshot(value) }],
302
+ },
303
+ async execute(_args, exec) {
304
+ const agent = exec !== null && typeof exec === 'object' ? exec.agent ?? null : null
305
+ return await buildSnapshot(ctx, state, agent)
306
+ },
307
+ }
308
+
309
+ ctx.effect(() => ctx.tools.register(inspectTool), 'showcase: inspect tool')
310
+
311
+ ctx.effect(() => ctx.on('tools/pre-execute', async (exec, next) => {
312
+ let decision = null
313
+ try {
314
+ if (state.gateEnabled && isDestructive(exec)) decision = { kind: 'deny', reason: DENY_REASON }
315
+ } catch {
316
+ /* a plugin fault must never block tool calls */
317
+ decision = null
318
+ }
319
+ return decision === null ? next() : decision
320
+ }), 'showcase: pre-execute gate')
321
+
322
+ ctx.effect(() => ctx.commands.register({
323
+ name: COMMAND_NAME,
324
+ description: 'Inspect this DSH host. Usage: /showcase [on|off]',
325
+ input: { hint: '[on|off]' },
326
+ handler: invocation => runCommand(ctx, state, invocation),
327
+ }), 'showcase: /showcase command')
328
+
329
+ ctx.provide('showcase', {
330
+ snapshot: () => buildSnapshot(ctx, state, null),
331
+ gateEnabled: () => state.gateEnabled,
332
+ setGateEnabled: enabled => { state.gateEnabled = enabled === true },
333
+ })
334
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Showcase",
4
+ "description": "DeepSeek Harness reference plugin: tools, hooks, commands, a settings card and a sidebar panel."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "功能展示",
4
+ "description": "DeepSeek Harness 参考插件:工具、钩子、命令、设置卡片与侧栏面板。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "dsh-plugin-showcase",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "vitest run"
7
+ },
8
+ "description": "DeepSeek Harness reference plugin: one tool, one slash command, a settings card and a right-sidebar panel.",
9
+ "icon": "./icon.svg",
10
+ "main": "./lib/index.js",
11
+ "exports": {
12
+ ".": {
13
+ "default": "./lib/index.js"
14
+ },
15
+ "./client": {
16
+ "default": "./lib/client.js"
17
+ },
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "locale",
24
+ "cordis.patch.yml",
25
+ "icon.svg",
26
+ "README.md",
27
+ "README.zh.md",
28
+ "LICENSE"
29
+ ],
30
+ "dsh": {
31
+ "manifestVersion": 1,
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "platform": "web"
37
+ }
38
+ },
39
+ "engines": {
40
+ "node": ">=20",
41
+ "dsh": ">=0.2.1-rc.2"
42
+ },
43
+ "keywords": [
44
+ "deepseek-harness",
45
+ "dsh-plugin",
46
+ "showcase",
47
+ "example"
48
+ ],
49
+ "author": "antonioxue",
50
+ "license": "MIT",
51
+ "devDependencies": {
52
+ "vitest": "^3.2.7"
53
+ }
54
+ }