dsh-plugin-toolkit 0.9.6 → 0.10.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,33 @@
1
+ ## 0.10.0
2
+
3
+ - **The checker is docked under the conversation.** It used to live only in the
4
+ right sidebar, behind a guide list nobody opens; now it sits above the input
5
+ bar as one collapsed row, and opens into the full guide: who it is for, how to
6
+ use it, what each check catches, and the caveats (read-only, folder not
7
+ profile, advisory vs hard, runtime behaviour is not provable).
8
+ - The dock and the sidebar tab share one `useChecker()` hook and one `Form` /
9
+ `Result` pair, so the two cannot drift apart.
10
+ - `dsh.client.inject` now names `@deepseek-ai/dsh-client-ui-conversation`, the
11
+ package that owns the dock seat.
12
+ - Three tests: the dock registers under `conversation.input.dock`, it opens onto
13
+ the guide (and stays collapsed before that), and it runs the same request as
14
+ the tab.
15
+
16
+ ## 0.9.7
17
+
18
+ - **The tab type is now reachable.** Registering a tab *type* is not enough: the
19
+ right sidebar's guide list is built from `tabs.guide()`, so a type with no
20
+ `guide` entry is registered, has a body slot, and is still impossible to open —
21
+ no tab appears anywhere. The type now contributes one guide entry
22
+ (`id: 'doctor'`, `order: 60`, localized title and description), which is what
23
+ puts "Plugin doctor" in the sidebar's guide.
24
+ - The title and description come from the panel's own dictionary through a
25
+ module-level `translate()`, because the sidebar calls them from its guide list,
26
+ outside this component.
27
+ - Regression test: mounting the bundle with a recording `sidebarRightTabs` now
28
+ asserts the registered definition carries a guide entry with a title and an
29
+ order.
30
+
1
31
  ## 0.9.6
2
32
 
3
33
  - **The host half now survives a context it does not own.** `apply()` ended with
package/README.md CHANGED
@@ -90,6 +90,14 @@ 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
+
93
101
  ## Test a panel without a browser
94
102
 
95
103
  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,10 @@ cd my-panel && npm install && npm test
53
53
 
54
54
  版本号取自当前运行的 CLI 自身,所以脚手架不会落后于你正在读的文档。
55
55
 
56
+ ## 它在哪里
57
+
58
+ 检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
59
+
56
60
  ## 不用浏览器测面板
57
61
 
58
62
  ```js
package/client.js CHANGED
@@ -20,6 +20,9 @@ window.__ModuleLoader__.load({
20
20
  fail: 'This would not install cleanly.',
21
21
  noDir: 'Name the folder that holds the plugin\'s package.json.',
22
22
  failed: 'Could not run the checks',
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.',
23
26
  },
24
27
  zh: {
25
28
  tabTitle: '插件体检',
@@ -32,9 +35,108 @@ window.__ModuleLoader__.load({
32
35
  fail: '这样装上去会出问题。',
33
36
  noDir: '请填写放着 package.json 的插件文件夹。',
34
37
  failed: '检查没能跑起来',
38
+ guideDescription: '对任意插件文件夹跑一遍安装前的检查',
39
+ dockTitle: '插件体检',
40
+ dockSub: '这个插件装上去还能用吗?发布前先对着文件夹查一遍。',
35
41
  },
36
42
  };
37
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
+ },
127
+ },
128
+ };
129
+
130
+ /**
131
+ * The panel's copy, by the document language. Module scope because the tab
132
+ * type's `title`/`description` are called by the sidebar's guide list, not
133
+ * by this component, and they need the same strings.
134
+ */
135
+ const translate = (key) => {
136
+ const lang = typeof document === 'undefined' ? 'en' : (document.documentElement.lang === 'zh' ? 'zh' : 'en');
137
+ return COPY[lang][key] ?? key;
138
+ };
139
+
38
140
  const h = React.createElement;
39
141
 
40
142
  const CSS = [
@@ -63,6 +165,28 @@ window.__ModuleLoader__.load({
63
165
  '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
64
166
  'color:var(--dsw-alias-label-tertiary,#888)}',
65
167
  '.tk-message{font-size:12px;overflow-wrap:anywhere}',
168
+ '.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
169
+ '.tk-dock-head{display:flex}',
170
+ '.tk-dock-toggle{display:flex;align-items:center;gap:8px;width:100%;padding:7px 2px;',
171
+ 'border:0;background:none;font:inherit;font-size:12px;color:inherit;cursor:pointer;text-align:left}',
172
+ '.tk-dock-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
173
+ 'outline-offset:-1px;border-radius:4px}',
174
+ '.tk-dock-title{font-weight:600}',
175
+ '.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
176
+ 'color:var(--dsw-alias-label-tertiary,#888)}',
177
+ '.tk-dock-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#888)}',
178
+ '.tk-dock-body{display:flex;flex-direction:column;gap:12px;padding:2px 2px 14px;',
179
+ 'max-height:46vh;overflow:auto;font-size:12px}',
180
+ '.tk-guide{margin:0;display:flex;flex-direction:column;gap:5px}',
181
+ '.tk-guide h3{margin:0;font-size:12px;font-weight:600}',
182
+ '.tk-guide p{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
183
+ '.tk-guide ol,.tk-guide ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;',
184
+ 'color:var(--dsw-alias-label-secondary,#555)}',
185
+ '.tk-guide-rows{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:0}',
186
+ '.tk-guide-rows dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;',
187
+ 'color:var(--dsw-alias-label-primary,#222)}',
188
+ '.tk-guide-rows dd{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
189
+ '.tk-guide code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
66
190
  ].join('');
67
191
 
68
192
  function injectStyles(document) {
@@ -84,19 +208,19 @@ window.__ModuleLoader__.load({
84
208
  );
85
209
  }
86
210
 
87
- function Panel() {
211
+ /**
212
+ * The check itself, shared by the dock and the sidebar tab so the two can
213
+ * never drift apart.
214
+ */
215
+ function useChecker() {
88
216
  const [dir, setDir] = useState('');
89
217
  const [result, setResult] = useState(null);
90
218
  const [busy, setBusy] = useState(false);
91
219
  const [failed, setFailed] = useState(null);
92
- const tr = (key) => {
93
- const language = COPY[document.documentElement.lang === 'zh' ? 'zh' : 'en'];
94
- return language[key] ?? key;
95
- };
96
220
 
97
221
  const check = useCallback(() => {
98
222
  if (dir.trim().length === 0) {
99
- setFailed(tr('noDir'));
223
+ setFailed(translate('noDir'));
100
224
  return;
101
225
  }
102
226
  setBusy(true);
@@ -104,35 +228,96 @@ window.__ModuleLoader__.load({
104
228
  fetch(`/toolkit/check?dir=${encodeURIComponent(dir.trim())}`)
105
229
  .then((response) => (response.ok === true ? response.json() : Promise.reject(new Error(String(response.status)))))
106
230
  .then((payload) => setResult(payload))
107
- .catch((error) => setFailed(`${tr('failed')} · ${error.message}`))
231
+ .catch((error) => setFailed(`${translate('failed')} · ${error.message}`))
108
232
  .finally(() => setBusy(false));
109
233
  }, [dir]);
110
234
 
235
+ return { dir, setDir, result, busy, failed, check };
236
+ }
237
+
238
+ function Form({ checker }) {
239
+ return h('div', { className: 'tk-form' },
240
+ h('input', {
241
+ className: 'tk-input',
242
+ type: 'text',
243
+ value: checker.dir,
244
+ placeholder: translate('placeholder'),
245
+ 'aria-label': translate('placeholder'),
246
+ onChange: (event) => checker.setDir(event.target.value),
247
+ onKeyDown: (event) => { if (event.key === 'Enter') checker.check(); },
248
+ }),
249
+ h('button', {
250
+ className: 'tk-button',
251
+ type: 'button',
252
+ disabled: checker.busy === true,
253
+ onClick: checker.check,
254
+ }, checker.busy === true ? translate('checking') : translate('check')));
255
+ }
256
+
257
+ function Result({ checker }) {
258
+ return h('div', { className: 'tk-result' },
259
+ checker.failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, checker.failed) : null,
260
+ checker.result !== null
261
+ ? h('div', { className: 'tk-result-body' },
262
+ h('p', { className: 'tk-verdict', 'data-ok': checker.result.ok === true ? 'true' : 'false', role: 'status' },
263
+ checker.result.ok === true ? translate('pass') : translate('fail')),
264
+ h('ul', { className: 'tk-rows' }, checker.result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
265
+ : null);
266
+ }
267
+
268
+ function Panel() {
269
+ const checker = useChecker();
111
270
  return h('div', { className: 'tk-panel' },
112
- h('h2', { className: 'tk-title' }, tr('title')),
113
- h('p', { className: 'tk-hint' }, tr('hint')),
114
- h('div', { className: 'tk-form' },
115
- h('input', {
116
- className: 'tk-input',
117
- type: 'text',
118
- value: dir,
119
- placeholder: tr('placeholder'),
120
- 'aria-label': tr('placeholder'),
121
- onChange: (event) => setDir(event.target.value),
122
- onKeyDown: (event) => { if (event.key === 'Enter') check(); },
123
- }),
271
+ h('h2', { className: 'tk-title' }, translate('title')),
272
+ h('p', { className: 'tk-hint' }, translate('hint')),
273
+ h(Form, { checker }),
274
+ h(Result, { checker }));
275
+ }
276
+
277
+ /**
278
+ * The same checker, docked under the conversation where people actually
279
+ * look. Collapsed it is one row; opened it carries the whole guide, because
280
+ * "what is this for" is the question the sidebar tab never answered.
281
+ */
282
+ function Dock() {
283
+ const [open, setOpen] = useState(false);
284
+ const checker = useChecker();
285
+ const guide = GUIDE[typeof document === 'undefined' || document.documentElement.lang !== 'zh' ? 'en' : 'zh'];
286
+
287
+ return h('section', { className: 'tk-dock', 'data-open': open === true ? 'true' : 'false' },
288
+ h('div', { className: 'tk-dock-head' },
124
289
  h('button', {
125
- className: 'tk-button',
290
+ className: 'tk-dock-toggle',
126
291
  type: 'button',
127
- disabled: busy === true,
128
- onClick: check,
129
- }, busy === true ? tr('checking') : tr('check'))),
130
- failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, failed) : null,
131
- result !== null
132
- ? h('div', { className: 'tk-result' },
133
- h('p', { className: 'tk-verdict', 'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
134
- result.ok === true ? tr('pass') : tr('fail')),
135
- h('ul', { className: 'tk-rows' }, result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
292
+ 'aria-expanded': open === true ? 'true' : 'false',
293
+ onClick: () => setOpen(open !== true),
294
+ },
295
+ h('span', { className: 'tk-dock-title' }, translate('dockTitle')),
296
+ h('span', { className: 'tk-dock-sub' }, translate('dockSub')),
297
+ h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸'))),
298
+ open === true
299
+ ? h('div', { className: 'tk-dock-body' },
300
+ h('div', { className: 'tk-guide' },
301
+ h('h3', {}, guide.who.title),
302
+ h('p', {}, guide.who.body)),
303
+ h('div', { className: 'tk-guide' },
304
+ h('h3', {}, guide.steps.title),
305
+ h('ol', {}, guide.steps.items.map((item) => h('li', { key: item }, item)))),
306
+ h('div', { className: 'tk-guide' },
307
+ h('h3', {}, guide.checks.title),
308
+ h('dl', { className: 'tk-guide-rows' },
309
+ guide.checks.rows.map(([name, what]) => [
310
+ h('dt', { key: `${name}-k` }, name),
311
+ h('dd', { key: `${name}-v` }, what),
312
+ ]))),
313
+ h('div', { className: 'tk-guide' },
314
+ h('h3', {}, guide.notes.title),
315
+ h('ul', {}, guide.notes.items.map((item) => h('li', { key: item }, item)))),
316
+ h('div', { className: 'tk-guide' },
317
+ h('h3', {}, guide.cli.title),
318
+ h('p', {}, h('code', {}, guide.cli.body))),
319
+ h(Form, { checker }),
320
+ h(Result, { checker }))
136
321
  : null);
137
322
  }
138
323
 
@@ -195,11 +380,21 @@ window.__ModuleLoader__.load({
195
380
  report('no-inject', 'ctx.inject is not a function');
196
381
  return;
197
382
  }
198
- ctx.inject(['sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
383
+ ctx.inject(['slots', 'sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
199
384
  // Failures inside this callback happen after apply() returned, so the
200
385
  // guards above do not cover them — this try/catch is the one that
201
386
  // decides whether the row lands in "failed".
202
387
  try {
388
+ // The dock lives under the conversation and needs nothing but
389
+ // `slots`, so it is registered before the sidebar is required.
390
+ if (tabCtx !== undefined && tabCtx.slots !== undefined) {
391
+ tabCtx.effect(() => tabCtx.slots.inject('conversation.input.dock', () => tabCtx.slots.register({
392
+ name: 'conversation.input.dock',
393
+ id: NS,
394
+ order: 30,
395
+ locale: NS,
396
+ }, Dock)));
397
+ }
203
398
  if (tabCtx === undefined || tabCtx.slots === undefined || tabCtx.sidebarRightTabs === undefined) {
204
399
  report('no-sidebar', `tabCtx slots=${String(tabCtx !== undefined && tabCtx.slots !== undefined)} sidebarRightTabs=${String(tabCtx !== undefined && tabCtx.sidebarRightTabs !== undefined)}`);
205
400
  return;
@@ -209,11 +404,20 @@ window.__ModuleLoader__.load({
209
404
  key: NS,
210
405
  locale: NS,
211
406
  }, Panel)));
407
+ // A tab *type* with no guide entry is registered but unreachable:
408
+ // the sidebar's guide list is built from `tabs.guide()`, so the
409
+ // type would never be offered and nothing would open it.
212
410
  tabCtx.effect(() => tabCtx.sidebarRightTabs.register({
213
411
  id: NS,
214
412
  kind: KIND,
215
413
  priority: 'extension',
216
- title: () => 'Plugin doctor',
414
+ title: () => translate('tabTitle'),
415
+ guide: [{
416
+ id: 'doctor',
417
+ order: 60,
418
+ title: () => translate('tabTitle'),
419
+ description: () => translate('guideDescription'),
420
+ }],
217
421
  }));
218
422
  report('tab-registered', `kind=${KIND}`);
219
423
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.9.6",
3
+ "version": "0.10.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
  }