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 +21 -0
- package/README.md +191 -2
- package/README.zh.md +174 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/lib/client.js +456 -0
- package/lib/index.js +334 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -4
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
|
-
#
|
|
1
|
+
# dsh-plugin-showcase
|
|
2
2
|
|
|
3
|
-
|
|
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 可达,所以没有用到。
|
package/cordis.patch.yml
ADDED
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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-showcase",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|