dsh-plugin-toolkit 0.9.7 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 0.11.0
2
+
3
+ - **A language switch, in the panel.** 中文 / EN sits in the dock's header row and
4
+ in the sidebar tab, opens in Chinese (the audience this plugin is written for),
5
+ and remembers the choice in `localStorage` under `dsh-plugin-toolkit:lang`. The
6
+ panel no longer follows the host's language silently: the app can be in English
7
+ while the person reading it is not.
8
+ - `useLang()` is a tiny subscription store, so the dock, the tab and the tab
9
+ type's `title()` all switch together.
10
+ - **The testkit hands a bundle the real `window`.** `loadBundle` used to pass a
11
+ stand-in that carried only `__ModuleLoader__`, so `window.localStorage` was
12
+ `undefined` and any preference silently failed to save. That bug was found by
13
+ this feature failing its own persistence assertion.
14
+ - Fixed invalid markup: the switch was nested inside the dock's toggle button.
15
+ React warns and drops the inner button, so it now sits beside it.
16
+
17
+ ## 0.10.0
18
+
19
+ - **The checker is docked under the conversation.** It used to live only in the
20
+ right sidebar, behind a guide list nobody opens; now it sits above the input
21
+ bar as one collapsed row, and opens into the full guide: who it is for, how to
22
+ use it, what each check catches, and the caveats (read-only, folder not
23
+ profile, advisory vs hard, runtime behaviour is not provable).
24
+ - The dock and the sidebar tab share one `useChecker()` hook and one `Form` /
25
+ `Result` pair, so the two cannot drift apart.
26
+ - `dsh.client.inject` now names `@deepseek-ai/dsh-client-ui-conversation`, the
27
+ package that owns the dock seat.
28
+ - Three tests: the dock registers under `conversation.input.dock`, it opens onto
29
+ the guide (and stays collapsed before that), and it runs the same request as
30
+ the tab.
31
+
1
32
  ## 0.9.7
2
33
 
3
34
  - **The tab type is now reachable.** Registering a tab *type* is not enough: the
package/README.md CHANGED
@@ -90,6 +90,17 @@ next: npm install && npm test && npm run doctor
90
90
 
91
91
  What you get is not a snippet: it is a plugin whose `doctor` already passes (`engines.dsh`, the patch file, the `files` list), with a client bundle that registers a sidebar tab, a testkit test for it, and a CI workflow that runs both. `init` refuses a directory that is not empty unless you pass `--force`, and refuses a name npm would refuse — those are the two mistakes that cost the most time later.
92
92
 
93
+ ## Where it lives
94
+
95
+ The checker is docked **under the conversation**, above the input bar: a single
96
+ row, collapsed by default. Open it and it carries the whole guide — who it is
97
+ for, how to use it, what each check means, and what it cannot tell you — plus
98
+ the folder field and the results. The same panel is also a **Plugin doctor** tab
99
+ in the right sidebar, for when the conversation is busy.
100
+
101
+ The panel speaks both languages and opens in Chinese; the 中文 / EN switch sits
102
+ in the dock's header row (and in the sidebar tab) and the choice is remembered.
103
+
93
104
  ## Test a panel without a browser
94
105
 
95
106
  A plugin's client code is one file that calls `window.__ModuleLoader__.load({factory})` — there is no module to import, so the usual React test setup does not apply. `testkit` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
package/README.zh.md CHANGED
@@ -53,6 +53,12 @@ cd my-panel && npm install && npm test
53
53
 
54
54
  版本号取自当前运行的 CLI 自身,所以脚手架不会落后于你正在读的文档。
55
55
 
56
+ ## 它在哪里
57
+
58
+ 检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
59
+
60
+ 面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
61
+
56
62
  ## 不用浏览器测面板
57
63
 
58
64
  ```js
package/client.js CHANGED
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  const module = { exports: {} };
5
5
  const React = require('react');
6
- const { useState, useCallback } = React;
6
+ const { useState, useCallback, useEffect } = React;
7
7
 
8
8
  const NS = 'dsh-plugin-toolkit';
9
9
  const KIND = 'toolkit';
@@ -21,6 +21,8 @@ window.__ModuleLoader__.load({
21
21
  noDir: 'Name the folder that holds the plugin\'s package.json.',
22
22
  failed: 'Could not run the checks',
23
23
  guideDescription: 'Run the install checks on a plugin folder',
24
+ dockTitle: 'Plugin doctor',
25
+ dockSub: 'Will this plugin survive an install? Check a folder before you publish it.',
24
26
  },
25
27
  zh: {
26
28
  tabTitle: '插件体检',
@@ -34,6 +36,94 @@ window.__ModuleLoader__.load({
34
36
  noDir: '请填写放着 package.json 的插件文件夹。',
35
37
  failed: '检查没能跑起来',
36
38
  guideDescription: '对任意插件文件夹跑一遍安装前的检查',
39
+ dockTitle: '插件体检',
40
+ dockSub: '这个插件装上去还能用吗?发布前先对着文件夹查一遍。',
41
+ },
42
+ };
43
+
44
+ /**
45
+ * The full guide, rendered in the dock. It is deliberately long: the panel
46
+ * used to be a bare input with no explanation of what it was for, which is
47
+ * the first question anyone asks.
48
+ */
49
+ const GUIDE = {
50
+ en: {
51
+ who: {
52
+ title: 'Who this is for',
53
+ body: 'People who write DSH plugins. It is not a daily tool — if you never publish a plugin, it has nothing for you.',
54
+ },
55
+ steps: {
56
+ title: 'How to use it',
57
+ items: [
58
+ 'Paste the folder that holds the plugin\'s package.json (a folder, not a file).',
59
+ 'Press Check. Nothing is installed and none of your files are touched.',
60
+ 'Read the list: ✓ passes, ✗ would break the install, with one line saying why.',
61
+ ],
62
+ },
63
+ checks: {
64
+ title: 'What it checks',
65
+ rows: [
66
+ ['manifest', 'name, version, engines.dsh'],
67
+ ['root export', 'the package exports apply — the loader mounts nothing without it'],
68
+ ['client declaration', 'dsh.client agrees with exports["./client"], which is what makes the host serve the panel'],
69
+ ['files list', 'npm pack would actually ship every path package.json points at'],
70
+ ['patch', 'cordis.patch.yml is exactly one - insert: row'],
71
+ ['readme', 'the README has a Requirements section (the market index asks for it)'],
72
+ ['tarball', 'the packed tarball installs and its root export imports'],
73
+ ],
74
+ },
75
+ notes: {
76
+ title: 'Before you trust it',
77
+ items: [
78
+ 'Read-only: it does not install anything, write files, or make network calls beyond this one request.',
79
+ 'It looks at a folder, not at what you have installed — use dsh-plugin audit --host … for that.',
80
+ 'engines.dsh and README are market-index requirements; a plugin missing them still runs.',
81
+ 'It cannot prove runtime behaviour. A panel can still fail inside the real app when a service arrives late or a registration throws; those failures are appended to ~/.dsh/toolkit-client-report.ndjson.',
82
+ ],
83
+ },
84
+ cli: {
85
+ title: 'The same checks in a terminal',
86
+ body: 'dsh-plugin verify . · dsh-plugin doctor . · dsh-plugin audit --host http://127.0.0.1:PORT',
87
+ },
88
+ },
89
+ zh: {
90
+ who: {
91
+ title: '这是给谁用的',
92
+ body: '写 DSH 插件的人。它不是日常工具——如果你不发布插件,它对你没有用处。',
93
+ },
94
+ steps: {
95
+ title: '怎么用',
96
+ items: [
97
+ '粘贴放着 package.json 的那个文件夹(是文件夹,不是文件)。',
98
+ '按「检查」。不会安装任何东西,也不会改你的文件。',
99
+ '看列表:✓ 通过,✗ 是装上去会坏的地方,右边一行说明原因。',
100
+ ],
101
+ },
102
+ checks: {
103
+ title: '检查什么',
104
+ rows: [
105
+ ['manifest', 'name、version、engines.dsh'],
106
+ ['root export', '包导出的东西里有没有 apply——没有它加载器什么都挂不上'],
107
+ ['client declaration', 'dsh.client 与 exports["./client"] 是否一致,这决定宿主会不会加载面板'],
108
+ ['files list', 'npm pack 是否真的会带上 package.json 指到的每个文件'],
109
+ ['patch', 'cordis.patch.yml 是不是恰好一条 - insert:'],
110
+ ['readme', 'README 有没有 Requirements 段(市场索引会看)'],
111
+ ['tarball', '打包后的 tarball 能不能装上、根导出能不能 import'],
112
+ ],
113
+ },
114
+ notes: {
115
+ title: '用之前要知道的',
116
+ items: [
117
+ '只读:不安装、不写文件、除这一次请求外不联网。',
118
+ '它看的是文件夹,不是你已装的插件——查已装的用 dsh-plugin audit --host …。',
119
+ 'engines.dsh 与 README 是市场索引的要求,缺了插件照样能跑。',
120
+ '它证明不了运行期行为。面板在真实应用里仍可能因为服务来晚了、注册抛错而失败;那些失败会写进 ~/.dsh/toolkit-client-report.ndjson。',
121
+ ],
122
+ },
123
+ cli: {
124
+ title: '同样的检查,命令行里',
125
+ body: 'dsh-plugin verify . · dsh-plugin doctor . · dsh-plugin audit --host http://127.0.0.1:PORT',
126
+ },
37
127
  },
38
128
  };
39
129
 
@@ -42,11 +132,74 @@ window.__ModuleLoader__.load({
42
132
  * type's `title`/`description` are called by the sidebar's guide list, not
43
133
  * by this component, and they need the same strings.
44
134
  */
45
- const translate = (key) => {
46
- const lang = typeof document === 'undefined' ? 'en' : (document.documentElement.lang === 'zh' ? 'zh' : 'en');
47
- return COPY[lang][key] ?? key;
135
+ const translate = (key, lang = readLang()) => {
136
+ return (COPY[lang] ?? COPY.en)[key] ?? key;
48
137
  };
49
138
 
139
+ /**
140
+ * The panel's language is the user's choice, not the host's: the app may be
141
+ * in English while the person reading it is not. Chinese is the default,
142
+ * because that is the audience this plugin is written for, and the choice is
143
+ * remembered per browser.
144
+ */
145
+ const LANG_KEY = 'dsh-plugin-toolkit:lang';
146
+ const LANGUAGES = ['zh', 'en'];
147
+ const langListeners = new Set();
148
+ let langCache = null;
149
+
150
+ function readLang() {
151
+ if (langCache !== null) return langCache;
152
+ try {
153
+ const stored = window.localStorage.getItem(LANG_KEY);
154
+ if (LANGUAGES.includes(stored)) {
155
+ langCache = stored;
156
+ return langCache;
157
+ }
158
+ } catch {
159
+ // No storage (private mode, a sandboxed frame): fall through to the default.
160
+ }
161
+ langCache = 'zh';
162
+ return langCache;
163
+ }
164
+
165
+ function setLang(next) {
166
+ if (!LANGUAGES.includes(next)) return;
167
+ langCache = next;
168
+ try {
169
+ window.localStorage.setItem(LANG_KEY, next);
170
+ } catch {
171
+ // Remembering the choice is a convenience, never a requirement.
172
+ }
173
+ for (const listener of langListeners) listener();
174
+ }
175
+
176
+ function useLang() {
177
+ const [lang, set] = useState(readLang);
178
+ useEffect(() => {
179
+ const listener = () => set(readLang());
180
+ langListeners.add(listener);
181
+ return () => langListeners.delete(listener);
182
+ }, []);
183
+ return lang;
184
+ }
185
+
186
+ function LangSwitch({ lang }) {
187
+ return h('span', { className: 'tk-lang', role: 'group', 'aria-label': 'Language' },
188
+ LANGUAGES.map((code) => h('button', {
189
+ key: code,
190
+ type: 'button',
191
+ className: 'tk-lang-button',
192
+ 'data-on': code === lang ? 'true' : 'false',
193
+ 'aria-pressed': code === lang ? 'true' : 'false',
194
+ onClick: (event) => {
195
+ // The dock sits inside the composer: a click here must not reach
196
+ // the conversation's own handlers.
197
+ event.stopPropagation();
198
+ setLang(code);
199
+ },
200
+ }, code === 'zh' ? '中文' : 'EN')));
201
+ }
202
+
50
203
  const h = React.createElement;
51
204
 
52
205
  const CSS = [
@@ -75,6 +228,35 @@ window.__ModuleLoader__.load({
75
228
  '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
76
229
  'color:var(--dsw-alias-label-tertiary,#888)}',
77
230
  '.tk-message{font-size:12px;overflow-wrap:anywhere}',
231
+ '.tk-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
232
+ '.tk-lang{display:inline-flex;flex:none;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
233
+ 'border-radius:6px;overflow:hidden}',
234
+ '.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
235
+ 'color:var(--dsw-alias-label-secondary,#555);cursor:pointer}',
236
+ '.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
237
+ '.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
238
+ '.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
239
+ '.tk-dock-head{display:flex}',
240
+ '.tk-dock-toggle{display:flex;align-items:center;gap:8px;width:100%;padding:7px 2px;',
241
+ 'border:0;background:none;font:inherit;font-size:12px;color:inherit;cursor:pointer;text-align:left}',
242
+ '.tk-dock-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
243
+ 'outline-offset:-1px;border-radius:4px}',
244
+ '.tk-dock-title{font-weight:600}',
245
+ '.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
246
+ 'color:var(--dsw-alias-label-tertiary,#888)}',
247
+ '.tk-dock-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#888)}',
248
+ '.tk-dock-body{display:flex;flex-direction:column;gap:12px;padding:2px 2px 14px;',
249
+ 'max-height:46vh;overflow:auto;font-size:12px}',
250
+ '.tk-guide{margin:0;display:flex;flex-direction:column;gap:5px}',
251
+ '.tk-guide h3{margin:0;font-size:12px;font-weight:600}',
252
+ '.tk-guide p{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
253
+ '.tk-guide ol,.tk-guide ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;',
254
+ 'color:var(--dsw-alias-label-secondary,#555)}',
255
+ '.tk-guide-rows{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:0}',
256
+ '.tk-guide-rows dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;',
257
+ 'color:var(--dsw-alias-label-primary,#222)}',
258
+ '.tk-guide-rows dd{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
259
+ '.tk-guide code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
78
260
  ].join('');
79
261
 
80
262
  function injectStyles(document) {
@@ -96,16 +278,19 @@ window.__ModuleLoader__.load({
96
278
  );
97
279
  }
98
280
 
99
- function Panel() {
281
+ /**
282
+ * The check itself, shared by the dock and the sidebar tab so the two can
283
+ * never drift apart.
284
+ */
285
+ function useChecker() {
100
286
  const [dir, setDir] = useState('');
101
287
  const [result, setResult] = useState(null);
102
288
  const [busy, setBusy] = useState(false);
103
289
  const [failed, setFailed] = useState(null);
104
- const tr = translate;
105
290
 
106
291
  const check = useCallback(() => {
107
292
  if (dir.trim().length === 0) {
108
- setFailed(tr('noDir'));
293
+ setFailed(translate('noDir'));
109
294
  return;
110
295
  }
111
296
  setBusy(true);
@@ -113,35 +298,108 @@ window.__ModuleLoader__.load({
113
298
  fetch(`/toolkit/check?dir=${encodeURIComponent(dir.trim())}`)
114
299
  .then((response) => (response.ok === true ? response.json() : Promise.reject(new Error(String(response.status)))))
115
300
  .then((payload) => setResult(payload))
116
- .catch((error) => setFailed(`${tr('failed')} · ${error.message}`))
301
+ .catch((error) => setFailed(`${translate('failed')} · ${error.message}`))
117
302
  .finally(() => setBusy(false));
118
303
  }, [dir]);
119
304
 
305
+ return { dir, setDir, result, busy, failed, check };
306
+ }
307
+
308
+ function Form({ checker }) {
309
+ const lang = useLang();
310
+ const tr = (key) => translate(key, lang);
311
+ return h('div', { className: 'tk-form' },
312
+ h('input', {
313
+ className: 'tk-input',
314
+ type: 'text',
315
+ value: checker.dir,
316
+ placeholder: tr('placeholder'),
317
+ 'aria-label': tr('placeholder'),
318
+ onChange: (event) => checker.setDir(event.target.value),
319
+ onKeyDown: (event) => { if (event.key === 'Enter') checker.check(); },
320
+ }),
321
+ h('button', {
322
+ className: 'tk-button',
323
+ type: 'button',
324
+ disabled: checker.busy === true,
325
+ onClick: checker.check,
326
+ }, checker.busy === true ? tr('checking') : tr('check')));
327
+ }
328
+
329
+ function Result({ checker }) {
330
+ const lang = useLang();
331
+ const tr = (key) => translate(key, lang);
332
+ return h('div', { className: 'tk-result' },
333
+ checker.failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, checker.failed) : null,
334
+ checker.result !== null
335
+ ? h('div', { className: 'tk-result-body' },
336
+ h('p', { className: 'tk-verdict', 'data-ok': checker.result.ok === true ? 'true' : 'false', role: 'status' },
337
+ checker.result.ok === true ? tr('pass') : tr('fail')),
338
+ h('ul', { className: 'tk-rows' }, checker.result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
339
+ : null);
340
+ }
341
+
342
+ function Panel() {
343
+ const checker = useChecker();
344
+ const lang = useLang();
345
+ const tr = (key) => translate(key, lang);
120
346
  return h('div', { className: 'tk-panel' },
121
- h('h2', { className: 'tk-title' }, tr('title')),
347
+ h('div', { className: 'tk-panel-head' },
348
+ h('h2', { className: 'tk-title' }, tr('title')),
349
+ h(LangSwitch, { lang })),
122
350
  h('p', { className: 'tk-hint' }, tr('hint')),
123
- h('div', { className: 'tk-form' },
124
- h('input', {
125
- className: 'tk-input',
126
- type: 'text',
127
- value: dir,
128
- placeholder: tr('placeholder'),
129
- 'aria-label': tr('placeholder'),
130
- onChange: (event) => setDir(event.target.value),
131
- onKeyDown: (event) => { if (event.key === 'Enter') check(); },
132
- }),
351
+ h(Form, { checker }),
352
+ h(Result, { checker }));
353
+ }
354
+
355
+ /**
356
+ * The same checker, docked under the conversation where people actually
357
+ * look. Collapsed it is one row; opened it carries the whole guide, because
358
+ * "what is this for" is the question the sidebar tab never answered.
359
+ */
360
+ function Dock() {
361
+ const [open, setOpen] = useState(false);
362
+ const checker = useChecker();
363
+ const lang = useLang();
364
+ const guide = GUIDE[lang] ?? GUIDE.zh;
365
+
366
+ return h('section', { className: 'tk-dock', 'data-open': open === true ? 'true' : 'false' },
367
+ h('div', { className: 'tk-dock-head' },
368
+ // The language switch is a sibling of the toggle, never inside it: a
369
+ // button inside a button is invalid and React drops the inner one.
133
370
  h('button', {
134
- className: 'tk-button',
371
+ className: 'tk-dock-toggle',
135
372
  type: 'button',
136
- disabled: busy === true,
137
- onClick: check,
138
- }, busy === true ? tr('checking') : tr('check'))),
139
- failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, failed) : null,
140
- result !== null
141
- ? h('div', { className: 'tk-result' },
142
- h('p', { className: 'tk-verdict', 'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
143
- result.ok === true ? tr('pass') : tr('fail')),
144
- h('ul', { className: 'tk-rows' }, result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
373
+ 'aria-expanded': open === true ? 'true' : 'false',
374
+ onClick: () => setOpen(open !== true),
375
+ },
376
+ h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
377
+ h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
378
+ h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸')),
379
+ h(LangSwitch, { lang })),
380
+ open === true
381
+ ? h('div', { className: 'tk-dock-body' },
382
+ h('div', { className: 'tk-guide' },
383
+ h('h3', {}, guide.who.title),
384
+ h('p', {}, guide.who.body)),
385
+ h('div', { className: 'tk-guide' },
386
+ h('h3', {}, guide.steps.title),
387
+ h('ol', {}, guide.steps.items.map((item) => h('li', { key: item }, item)))),
388
+ h('div', { className: 'tk-guide' },
389
+ h('h3', {}, guide.checks.title),
390
+ h('dl', { className: 'tk-guide-rows' },
391
+ guide.checks.rows.map(([name, what]) => [
392
+ h('dt', { key: `${name}-k` }, name),
393
+ h('dd', { key: `${name}-v` }, what),
394
+ ]))),
395
+ h('div', { className: 'tk-guide' },
396
+ h('h3', {}, guide.notes.title),
397
+ h('ul', {}, guide.notes.items.map((item) => h('li', { key: item }, item)))),
398
+ h('div', { className: 'tk-guide' },
399
+ h('h3', {}, guide.cli.title),
400
+ h('p', {}, h('code', {}, guide.cli.body))),
401
+ h(Form, { checker }),
402
+ h(Result, { checker }))
145
403
  : null);
146
404
  }
147
405
 
@@ -204,11 +462,21 @@ window.__ModuleLoader__.load({
204
462
  report('no-inject', 'ctx.inject is not a function');
205
463
  return;
206
464
  }
207
- ctx.inject(['sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
465
+ ctx.inject(['slots', 'sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
208
466
  // Failures inside this callback happen after apply() returned, so the
209
467
  // guards above do not cover them — this try/catch is the one that
210
468
  // decides whether the row lands in "failed".
211
469
  try {
470
+ // The dock lives under the conversation and needs nothing but
471
+ // `slots`, so it is registered before the sidebar is required.
472
+ if (tabCtx !== undefined && tabCtx.slots !== undefined) {
473
+ tabCtx.effect(() => tabCtx.slots.inject('conversation.input.dock', () => tabCtx.slots.register({
474
+ name: 'conversation.input.dock',
475
+ id: NS,
476
+ order: 30,
477
+ locale: NS,
478
+ }, Dock)));
479
+ }
212
480
  if (tabCtx === undefined || tabCtx.slots === undefined || tabCtx.sidebarRightTabs === undefined) {
213
481
  report('no-sidebar', `tabCtx slots=${String(tabCtx !== undefined && tabCtx.slots !== undefined)} sidebarRightTabs=${String(tabCtx !== undefined && tabCtx.sidebarRightTabs !== undefined)}`);
214
482
  return;
@@ -46,7 +46,13 @@ export function createModuleLoader({ require: provide, onLoad } = {}) {
46
46
  export function loadBundle(file, options = {}) {
47
47
  const source = readBundleSource(file);
48
48
  const loader = createModuleLoader(options);
49
- const context = { window: { __ModuleLoader__: loader }, __ModuleLoader__: loader, ...(options.globals ?? {}) };
49
+ // The bundle's `window` must be the real one: a plugin reads
50
+ // `window.localStorage`, `window.matchMedia`, `window.navigator`, and a
51
+ // stand-in that only carries the loader turns every one of those into a
52
+ // silent `undefined`.
53
+ const hostWindow = options.window ?? {};
54
+ hostWindow.__ModuleLoader__ = loader;
55
+ const context = { window: hostWindow, __ModuleLoader__: loader, ...(options.globals ?? {}) };
50
56
  // The bundle is an IIFE or a top-level statement list; the browser would run it
51
57
  // as a classic script, so a function body is the closest equivalent.
52
58
  const run = new Function('window', '__ModuleLoader__', 'require', 'module', 'exports', source);
@@ -74,7 +74,7 @@ export async function mountClient(options) {
74
74
  const previousFetch = globalThis.fetch;
75
75
  globalThis.fetch = host.fetch;
76
76
  try {
77
- const exported = loadBundle(file, { require: moduleShim(modules) });
77
+ const exported = loadBundle(file, { require: moduleShim(modules), window });
78
78
  const apply = exported.apply ?? exported.default ?? exported.install ?? exported;
79
79
  if (typeof apply !== 'function') {
80
80
  throw new Error(`${file} exports no apply(ctx) function, so there is nothing to mount`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.9.7",
3
+ "version": "0.11.0",
4
4
  "description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,6 +81,7 @@
81
81
  "platform": "web",
82
82
  "immediately": true,
83
83
  "inject": [
84
+ "@deepseek-ai/dsh-client-ui-conversation",
84
85
  "@deepseek-ai/dsh-client-ui-sidebar-right"
85
86
  ]
86
87
  }