dsh-plugin-capabilities 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.
@@ -0,0 +1,411 @@
1
+ /** Settings → Plugins “MCP” tab: manage the profile's mcp-client rows.
2
+ * Mutations rewrite the profile patch and need a dsh restart — the banner
3
+ * hands the restart to the desktop shell when one is present. */
4
+
5
+ import { useEffect, useState } from 'react'
6
+ import type { ReactElement } from 'react'
7
+ import { Button, IconApiOutline14, IconRefreshOutline14, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
8
+ import { CSS } from './css.ts'
9
+ import type { ImportedServerView, Translate } from './index.ts'
10
+
11
+ export interface McpRow {
12
+ id: string
13
+ serverName: string
14
+ transport: 'stdio' | 'streamable-http'
15
+ disabled: boolean
16
+ command?: string
17
+ args?: string[]
18
+ env?: Record<string, string>
19
+ cwd?: string
20
+ url?: string
21
+ headers?: Record<string, string>
22
+ }
23
+
24
+ export interface McpInjected {
25
+ list(): Promise<{ servers: McpRow[] }>
26
+ save(input: Record<string, unknown>): Promise<{ ok: boolean; id: string }>
27
+ toggle(id: string, disabled: boolean): Promise<{ ok: boolean }>
28
+ remove(id: string): Promise<{ ok: boolean }>
29
+ scanImport(): Promise<{ servers: ImportedServerView[]; existing: string[] }>
30
+ applyImport(items: Array<{ agent: string; name: string }>): Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>
31
+ restart(): void
32
+ desktop: boolean
33
+ }
34
+
35
+ /** Editor dialog state; null when closed. Textareas hold line-based values. */
36
+ interface EditorState {
37
+ id: string
38
+ serverName: string
39
+ transport: 'stdio' | 'streamable-http'
40
+ command: string
41
+ args: string
42
+ env: string
43
+ url: string
44
+ headers: string
45
+ }
46
+
47
+ /** KEY=VALUE / KEY: VALUE lines to a map. */
48
+ function parsePairs(text: string, separator: ':' | '='): Record<string, string> {
49
+ const map: Record<string, string> = {}
50
+ for (const line of text.split(/\r?\n/)) {
51
+ const trimmed = line.trim()
52
+ if (trimmed === '' || trimmed.startsWith('#')) continue
53
+ const at = trimmed.indexOf(separator)
54
+ if (at <= 0) continue
55
+ map[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim()
56
+ }
57
+ return map
58
+ }
59
+
60
+ function mapToPairs(map: Record<string, string> | undefined, separator: string): string {
61
+ if (map === undefined) return ''
62
+ return Object.entries(map).map(([key, value]) => `${key}${separator}${value.includes('\n') ? JSON.stringify(value) : value}`).join('\n')
63
+ }
64
+
65
+ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactElement {
66
+ const { t, injected } = props
67
+ const [servers, setServers] = useState<McpRow[] | null>(null)
68
+ const [editor, setEditor] = useState<EditorState | null>(null)
69
+ const [confirmId, setConfirmId] = useState<string | null>(null)
70
+ const [importOpen, setImportOpen] = useState(false)
71
+ const [importItems, setImportItems] = useState<Array<{ server: ImportedServerView; existing: boolean; checked: boolean }> | null>(null)
72
+ const [busy, setBusy] = useState(false)
73
+ const [pending, setPending] = useState(false)
74
+ const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
75
+ const [formError, setFormError] = useState<string | null>(null)
76
+ const [reload, setReload] = useState(0)
77
+
78
+ const openImport = async (): Promise<void> => {
79
+ setImportOpen(true)
80
+ setImportItems(null)
81
+ try {
82
+ const body = await injected.scanImport()
83
+ const existing = new Set(body.existing)
84
+ setImportItems(body.servers.map(server => ({
85
+ server,
86
+ existing: existing.has(server.name),
87
+ checked: !existing.has(server.name),
88
+ })))
89
+ } catch (error) {
90
+ setImportItems([])
91
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
92
+ }
93
+ }
94
+
95
+ const doImport = async (): Promise<void> => {
96
+ if (importItems === null) return
97
+ const items = importItems.filter(item => item.checked && !item.existing).map(item => ({ agent: item.server.agent, name: item.server.name }))
98
+ setBusy(true)
99
+ try {
100
+ const body = await injected.applyImport(items)
101
+ const failed = body.results.filter(item => !item.ok)
102
+ setOutcome(failed.length === 0
103
+ ? { ok: true, text: t('restartNeeded') }
104
+ : { ok: false, text: `${t('failed')}: ${failed.map(item => `${item.name} (${item.error})`).join(', ')}` })
105
+ setImportOpen(false)
106
+ setPending(true)
107
+ setReload((value) => value + 1)
108
+ } catch (error) {
109
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
110
+ } finally {
111
+ setBusy(false)
112
+ }
113
+ }
114
+
115
+ const reloadList = (showPending: boolean): void => {
116
+ setReload((value) => value + 1)
117
+ if (showPending) setPending(true)
118
+ }
119
+
120
+ useEffect(() => {
121
+ let current = true
122
+ void injected.list().then(
123
+ (body) => { if (current) setServers(body.servers) },
124
+ (error: Error) => { if (current) { setServers([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) } },
125
+ )
126
+ return () => { current = false }
127
+ }, [injected, reload, t])
128
+
129
+ const openCreate = (): void => {
130
+ setFormError(null)
131
+ setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
132
+ }
133
+
134
+ const openEdit = (row: McpRow): void => {
135
+ setFormError(null)
136
+ setEditor({
137
+ id: row.id,
138
+ serverName: row.serverName,
139
+ transport: row.transport,
140
+ command: row.command ?? '',
141
+ args: (row.args ?? []).join('\n'),
142
+ env: mapToPairs(row.env, '='),
143
+ url: row.url ?? '',
144
+ headers: mapToPairs(row.headers, ':'),
145
+ })
146
+ }
147
+
148
+ const doSave = async (): Promise<void> => {
149
+ if (editor === null) return
150
+ setBusy(true)
151
+ setFormError(null)
152
+ try {
153
+ await injected.save({
154
+ id: editor.id,
155
+ serverName: editor.serverName.trim(),
156
+ transport: editor.transport,
157
+ ...(editor.transport === 'stdio'
158
+ ? {
159
+ command: editor.command.trim(),
160
+ args: editor.args.split(/\r?\n/).map(line => line.trim()).filter(line => line !== ''),
161
+ env: parsePairs(editor.env, '='),
162
+ }
163
+ : {
164
+ url: editor.url.trim(),
165
+ headers: parsePairs(editor.headers, ':'),
166
+ }),
167
+ })
168
+ setEditor(null)
169
+ setOutcome({ ok: true, text: t('restartNeeded') })
170
+ reloadList(true)
171
+ } catch (error) {
172
+ setFormError(String(error instanceof Error ? error.message : error))
173
+ } finally {
174
+ setBusy(false)
175
+ }
176
+ }
177
+
178
+ const doToggle = async (row: McpRow): Promise<void> => {
179
+ setBusy(true)
180
+ try {
181
+ await injected.toggle(row.id, !row.disabled)
182
+ setOutcome({ ok: true, text: t('restartNeeded') })
183
+ reloadList(true)
184
+ } catch (error) {
185
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
186
+ } finally {
187
+ setBusy(false)
188
+ }
189
+ }
190
+
191
+ const doRemove = async (): Promise<void> => {
192
+ if (confirmId === null) return
193
+ setBusy(true)
194
+ try {
195
+ await injected.remove(confirmId)
196
+ setOutcome({ ok: true, text: t('restartNeeded') })
197
+ reloadList(true)
198
+ } catch (error) {
199
+ setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
200
+ } finally {
201
+ setBusy(false)
202
+ setConfirmId(null)
203
+ }
204
+ }
205
+
206
+ const restartBanner = (
207
+ <div className="dpc-banner" data-kind="info" role="status">
208
+ <StateDot state="ongoing" size={10} />
209
+ <div className="dpc-bannerBody">
210
+ <span>{t('restartNeeded')}</span>
211
+ <span className="dpc-bannerHint">
212
+ {injected.desktop
213
+ ? <>{t('restartDesktopHint')}{' '}<Button variant="outline" size="sm" onClick={injected.restart}>{t('restartNow')}</Button></>
214
+ : t('restartOtherHint')}
215
+ </span>
216
+ </div>
217
+ </div>
218
+ )
219
+
220
+ return (
221
+ <div className="dpc-section">
222
+ <style>{CSS}</style>
223
+
224
+ <div className="dpc-head">
225
+ <IconApiOutline14 aria-hidden="true" />
226
+ <h3>{t('mcpTitle')}</h3>
227
+ <span className="dpc-spacer" />
228
+ <Button variant="ghost" size="sm" onClick={() => void openImport()}>{t('importServers')}</Button>
229
+ <Button variant="primary" size="sm" onClick={openCreate}>{t('addServer')}</Button>
230
+ </div>
231
+ <p className="dpc-intro">{t('mcpIntro')}</p>
232
+
233
+ {outcome !== null && (
234
+ <div className="dpc-banner" data-kind={outcome.ok ? 'ok' : 'error'} role="status">
235
+ <StateDot state={outcome.ok ? 'done' : 'error'} size={10} />
236
+ <div className="dpc-bannerBody"><span>{outcome.text}</span></div>
237
+ </div>
238
+ )}
239
+ {pending && restartBanner}
240
+
241
+ <div className="dpc-listHead">
242
+ <h3>{t('mcpTab')}</h3>
243
+ {servers !== null && <span className="dpc-count">{servers.length}</span>}
244
+ <span className="dpc-spacer" />
245
+ <button type="button" className="dpc-refresh" aria-label={t('view')} title={t('view')} disabled={busy} onClick={() => setReload((value) => value + 1)}>
246
+ <IconRefreshOutline14 size={14} aria-hidden="true" />
247
+ </button>
248
+ </div>
249
+
250
+ {servers === null && <p className="dpc-empty">{t('loading')}</p>}
251
+ {servers !== null && servers.length === 0 && <p className="dpc-empty">{t('emptyMcp')}</p>}
252
+ {servers !== null && servers.length > 0 && (
253
+ <ul className="dpc-cards">
254
+ {servers.map((row) => (
255
+ <li className="dpc-card" key={row.id}>
256
+ <div className="dpc-cardTop">
257
+ <strong className="dpc-cardTitle" title={row.id}>{row.serverName}</strong>
258
+ <span className="dpc-tag">{row.transport}</span>
259
+ <span className="dpc-tag" data-kind={row.disabled ? 'off' : undefined}>{row.disabled ? t('disabled') : t('enabled')}</span>
260
+ </div>
261
+ <p className="dpc-cardDesc">
262
+ {row.transport === 'stdio' ? `${row.command ?? ''} ${(row.args ?? []).join(' ')}` : row.url ?? ''}
263
+ </p>
264
+ <div className="dpc-cardRow">
265
+ <span className="dpc-spacer" />
266
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => void doToggle(row)}>{t('toggle')}</Button>
267
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => openEdit(row)}>{t('edit')}</Button>
268
+ <Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmId(row.id)}>{t('delete')}</Button>
269
+ </div>
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ )}
274
+
275
+ <Modal
276
+ open={editor !== null}
277
+ onClose={() => setEditor(null)}
278
+ title={editor !== null && editor.id !== '' ? t('editServer') : t('addServer')}
279
+ >
280
+ {editor !== null && (
281
+ <div className="dpc-form">
282
+ <label className="dpc-label">
283
+ <span>{t('serverName')}</span>
284
+ <input
285
+ className="dpc-input"
286
+ value={editor.serverName}
287
+ disabled={editor.id !== ''}
288
+ onChange={(event) => setEditor({ ...editor, serverName: event.target.value })}
289
+ />
290
+ </label>
291
+ <label className="dpc-label">
292
+ <span>{t('transport')}</span>
293
+ <select
294
+ className="dpc-select"
295
+ value={editor.transport}
296
+ disabled={editor.id !== ''}
297
+ onChange={(event) => setEditor({ ...editor, transport: event.target.value as EditorState['transport'] })}
298
+ >
299
+ <option value="stdio">{t('transportStdio')}</option>
300
+ <option value="streamable-http">{t('transportHttp')}</option>
301
+ </select>
302
+ </label>
303
+ {editor.transport === 'stdio' ? (
304
+ <>
305
+ <label className="dpc-label">
306
+ <span>{t('command')}</span>
307
+ <input className="dpc-input" value={editor.command} onChange={(event) => setEditor({ ...editor, command: event.target.value })} />
308
+ </label>
309
+ <label className="dpc-label">
310
+ <span>{t('args')}</span>
311
+ <textarea className="dpc-textarea" data-short="true" value={editor.args} onChange={(event) => setEditor({ ...editor, args: event.target.value })} />
312
+ </label>
313
+ <label className="dpc-label">
314
+ <span>{t('envPairs')}</span>
315
+ <textarea className="dpc-textarea" data-short="true" value={editor.env} onChange={(event) => setEditor({ ...editor, env: event.target.value })} />
316
+ </label>
317
+ </>
318
+ ) : (
319
+ <>
320
+ <label className="dpc-label">
321
+ <span>{t('url')}</span>
322
+ <input className="dpc-input" value={editor.url} onChange={(event) => setEditor({ ...editor, url: event.target.value })} />
323
+ </label>
324
+ <label className="dpc-label">
325
+ <span>{t('headersPairs')}</span>
326
+ <textarea className="dpc-textarea" data-short="true" value={editor.headers} onChange={(event) => setEditor({ ...editor, headers: event.target.value })} />
327
+ </label>
328
+ </>
329
+ )}
330
+ {formError !== null && <p className="dpc-formError">{formError}</p>}
331
+ <div className="dpc-cardRow">
332
+ <span className="dpc-spacer" />
333
+ <Button variant="ghost" onClick={() => setEditor(null)}>{t('cancel')}</Button>
334
+ <Button variant="primary" disabled={busy} onClick={() => void doSave()}>{t('save')}</Button>
335
+ </div>
336
+ </div>
337
+ )}
338
+ </Modal>
339
+
340
+ <Modal
341
+ open={confirmId !== null}
342
+ onClose={() => setConfirmId(null)}
343
+ title={t('confirmRemove')}
344
+ description={confirmId ?? undefined}
345
+ footer={
346
+ <>
347
+ <Button variant="ghost" onClick={() => setConfirmId(null)}>{t('cancel')}</Button>
348
+ <Button variant="primary" disabled={busy} onClick={() => void doRemove()}>{t('delete')}</Button>
349
+ </>
350
+ }
351
+ >
352
+ <p>{t('removeWarn')}</p>
353
+ </Modal>
354
+
355
+ <Modal
356
+ open={importOpen}
357
+ onClose={() => setImportOpen(false)}
358
+ title={t('importServers')}
359
+ >
360
+ <div className="dpc-form">
361
+ <p className="dpc-intro" style={{ margin: 0 }}>{t('importIntro')}</p>
362
+ {importItems === null && <p className="dpc-empty">{t('loading')}</p>}
363
+ {importItems !== null && importItems.length === 0 && <p className="dpc-empty">{t('importEmpty')}</p>}
364
+ {importItems !== null && importItems.length > 0 && (
365
+ <ul className="dpc-cards" style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}>
366
+ {importItems.map((item, index) => (
367
+ <li className="dpc-card" key={`${item.server.agent}/${item.server.name}`} style={{ opacity: item.existing ? 0.55 : 1 }}>
368
+ <div className="dpc-cardTop">
369
+ <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0, cursor: item.existing ? 'default' : 'pointer' }}>
370
+ <input
371
+ type="checkbox"
372
+ checked={item.checked}
373
+ disabled={item.existing}
374
+ onChange={(event) => {
375
+ const next = importItems.slice()
376
+ next[index] = { ...item, checked: event.target.checked }
377
+ setImportItems(next)
378
+ }}
379
+ />
380
+ <strong className="dpc-cardTitle" title={item.server.name}>{item.server.name}</strong>
381
+ </label>
382
+ <span className="dpc-tag" data-kind="source">{item.server.agent === 'claude-code' ? 'Claude Code' : 'Codex'}</span>
383
+ <span className="dpc-tag">{item.server.transport}</span>
384
+ {item.existing && <span className="dpc-tag">{t('importExisting')}</span>}
385
+ </div>
386
+ <p className="dpc-cardDesc">
387
+ {item.server.transport === 'stdio'
388
+ ? `${item.server.command ?? ''} ${(item.server.args ?? []).join(' ')}`
389
+ : item.server.url ?? ''}
390
+ </p>
391
+ </li>
392
+ ))}
393
+ </ul>
394
+ )}
395
+ {formError !== null && <p className="dpc-formError">{formError}</p>}
396
+ <div className="dpc-cardRow">
397
+ <span className="dpc-spacer" />
398
+ <Button variant="ghost" onClick={() => setImportOpen(false)}>{t('cancel')}</Button>
399
+ <Button
400
+ variant="primary"
401
+ disabled={busy || importItems === null || !importItems.some(item => item.checked && !item.existing)}
402
+ onClick={() => void doImport()}
403
+ >
404
+ {t('importSelected')}
405
+ </Button>
406
+ </div>
407
+ </div>
408
+ </Modal>
409
+ </div>
410
+ )
411
+ }