dsh-plugin-capabilities 0.1.5 → 0.2.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/README.md +16 -2
- package/lib/client.js +936 -220
- package/lib/client.js.map +4 -4
- package/lib/index.js +813 -38
- package/lib/index.js.map +4 -4
- package/market/mcp.json +164 -0
- package/market/skills.json +36 -0
- package/package.json +3 -1
- package/skills/find-skills/LICENSE +21 -0
- package/skills/find-skills/SKILL.md +141 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/SKILL.md +485 -0
- package/skills/skill-creator/agents/analyzer.md +274 -0
- package/skills/skill-creator/agents/comparator.md +202 -0
- package/skills/skill-creator/agents/grader.md +223 -0
- package/skills/skill-creator/assets/eval_review.html +146 -0
- package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/skills/skill-creator/references/schemas.md +430 -0
- package/skills/skill-creator/scripts/__init__.py +0 -0
- package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skills/skill-creator/scripts/generate_report.py +326 -0
- package/skills/skill-creator/scripts/improve_description.py +247 -0
- package/skills/skill-creator/scripts/package_skill.py +136 -0
- package/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/skills/skill-creator/scripts/run_eval.py +310 -0
- package/skills/skill-creator/scripts/run_loop.py +328 -0
- package/skills/skill-creator/scripts/utils.py +47 -0
- package/src/client/CapabilitiesSection.tsx +11 -7
- package/src/client/MarketTab.tsx +263 -0
- package/src/client/McpTab.tsx +198 -0
- package/src/client/SkillsTab.tsx +261 -26
- package/src/client/css.ts +42 -0
- package/src/client/index.ts +58 -5
- package/src/client/locales.ts +112 -2
- package/src/index.ts +76 -20
- package/src/market.test.ts +57 -0
- package/src/market.ts +166 -0
- package/src/opener.ts +26 -0
- package/src/packaged-skills.test.ts +40 -0
- package/src/repos.test.ts +213 -0
- package/src/repos.ts +207 -0
- package/src/routes.ts +336 -6
- package/src/skills.test.ts +45 -1
- package/src/skills.ts +22 -1
- package/src/smoke.test.ts +61 -2
- package/src/state.test.ts +64 -0
- package/src/state.ts +109 -0
- package/src/tar.test.ts +100 -0
- package/src/tar.ts +154 -0
- package/src/types.ts +11 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/** Settings “市场” tab: browse and install curated skill repositories and
|
|
2
|
+
* MCP servers. Two segments (skills / MCP) with independently fetched
|
|
3
|
+
* indexes; installs reuse the repositories and MCP-row plumbing, so a
|
|
4
|
+
* skills install lands live in the catalog and an MCP install raises the
|
|
5
|
+
* same pending-restart notice as the MCP tab. */
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
8
|
+
import type { ReactElement } from 'react'
|
|
9
|
+
import { Button, IconRefreshOutline14, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
10
|
+
import { CSS } from './css.ts'
|
|
11
|
+
import type { MarketRepoView, MarketServerView, Translate } from './index.ts'
|
|
12
|
+
import type { McpInjected } from './McpTab.tsx'
|
|
13
|
+
|
|
14
|
+
/** One registered skill repository as the roots route reports it. */
|
|
15
|
+
export interface RootRowView {
|
|
16
|
+
id: string
|
|
17
|
+
kind: 'local' | 'git'
|
|
18
|
+
label: string
|
|
19
|
+
url?: string
|
|
20
|
+
ref?: string
|
|
21
|
+
path?: string
|
|
22
|
+
roots: string[]
|
|
23
|
+
materialDir?: string
|
|
24
|
+
addedAt: number
|
|
25
|
+
/** False once one of its scan roots vanished from disk. */
|
|
26
|
+
live: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MarketInjected {
|
|
30
|
+
skillsIndex(): Promise<{ source: 'remote' | 'bundled'; repos: Array<MarketRepoView & { installedId: string | null }> }>
|
|
31
|
+
mcpIndex(): Promise<{ source: 'remote' | 'bundled'; servers: Array<MarketServerView & { installed: boolean }> }>
|
|
32
|
+
installSkillRepo(url: string): Promise<{ ok: boolean; root: RootRowView }>
|
|
33
|
+
installMcp(id: string): Promise<{ ok: boolean; id: string }>
|
|
34
|
+
removeRoot(id: string): Promise<{ ok: boolean }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** UI language hint for the index's zh display fields. */
|
|
38
|
+
const zhUi = (): boolean => navigator.language.toLowerCase().startsWith('zh')
|
|
39
|
+
|
|
40
|
+
type Segment = 'skills' | 'mcp'
|
|
41
|
+
|
|
42
|
+
export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: McpInjected }): ReactElement {
|
|
43
|
+
const { t, market, mcp } = props
|
|
44
|
+
const [segment, setSegment] = useState<Segment>('skills')
|
|
45
|
+
const [repos, setRepos] = useState<Array<MarketRepoView & { installedId: string | null }> | null>(null)
|
|
46
|
+
const [servers, setServers] = useState<Array<MarketServerView & { installed: boolean }> | null>(null)
|
|
47
|
+
const [source, setSource] = useState<'remote' | 'bundled'>('remote')
|
|
48
|
+
const [busyId, setBusyId] = useState<string | null>(null)
|
|
49
|
+
const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
|
|
50
|
+
const [confirmUninstall, setConfirmUninstall] = useState<{ kind: 'root' | 'mcp'; id: string; name: string } | null>(null)
|
|
51
|
+
const [reload, setReload] = useState(0)
|
|
52
|
+
|
|
53
|
+
const refreshSkills = useCallback((): void => {
|
|
54
|
+
setRepos(null)
|
|
55
|
+
void market.skillsIndex().then(
|
|
56
|
+
(body) => { setRepos(body.repos); setSource(body.source) },
|
|
57
|
+
(error: Error) => { setRepos([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) },
|
|
58
|
+
)
|
|
59
|
+
}, [market, t])
|
|
60
|
+
|
|
61
|
+
const refreshMcp = useCallback((): void => {
|
|
62
|
+
setServers(null)
|
|
63
|
+
void market.mcpIndex().then(
|
|
64
|
+
(body) => { setServers(body.servers); setSource(body.source) },
|
|
65
|
+
(error: Error) => { setServers([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) },
|
|
66
|
+
)
|
|
67
|
+
}, [market, t])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
// Refetch the visible segment when it changes or the reload ticks; the
|
|
71
|
+
// hidden segment keeps its last state as a cache.
|
|
72
|
+
if (segment === 'skills') refreshSkills()
|
|
73
|
+
else refreshMcp()
|
|
74
|
+
}, [segment, reload, refreshSkills, refreshMcp])
|
|
75
|
+
|
|
76
|
+
const doInstallRepo = async (repo: MarketRepoView): Promise<void> => {
|
|
77
|
+
setBusyId(repo.id)
|
|
78
|
+
setOutcome(null)
|
|
79
|
+
try {
|
|
80
|
+
await market.installSkillRepo(repo.url)
|
|
81
|
+
setOutcome({ ok: true, text: t('marketRepoInstalled') })
|
|
82
|
+
refreshSkills()
|
|
83
|
+
} catch (error) {
|
|
84
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
85
|
+
refreshSkills()
|
|
86
|
+
} finally {
|
|
87
|
+
setBusyId(null)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const doInstallMcp = async (server: MarketServerView): Promise<void> => {
|
|
92
|
+
setBusyId(server.id)
|
|
93
|
+
setOutcome(null)
|
|
94
|
+
try {
|
|
95
|
+
await market.installMcp(server.id)
|
|
96
|
+
setOutcome({ ok: true, text: t('restartNeeded') })
|
|
97
|
+
refreshMcp()
|
|
98
|
+
} catch (error) {
|
|
99
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
100
|
+
refreshMcp()
|
|
101
|
+
} finally {
|
|
102
|
+
setBusyId(null)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const doUninstall = async (): Promise<void> => {
|
|
107
|
+
if (confirmUninstall === null) return
|
|
108
|
+
const target = confirmUninstall
|
|
109
|
+
setBusyId(target.id)
|
|
110
|
+
try {
|
|
111
|
+
if (target.kind === 'root') {
|
|
112
|
+
await market.removeRoot(target.id)
|
|
113
|
+
setOutcome({ ok: true, text: t('rootRemoved') })
|
|
114
|
+
refreshSkills()
|
|
115
|
+
} else {
|
|
116
|
+
await mcp.remove(target.id)
|
|
117
|
+
setOutcome({ ok: true, text: t('restartNeeded') })
|
|
118
|
+
refreshMcp()
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
122
|
+
} finally {
|
|
123
|
+
setBusyId(null)
|
|
124
|
+
setConfirmUninstall(null)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pick = <T,>(base: T, zh: T | undefined): T => (zhUi() && zh !== undefined ? zh : base)
|
|
129
|
+
const title = (entry: { name: string; nameZh?: string }): string => pick(entry.name, entry.nameZh)
|
|
130
|
+
const desc = (entry: { description: string; descriptionZh?: string }): string => pick(entry.description, entry.descriptionZh)
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="dpc-section">
|
|
134
|
+
<style>{CSS}</style>
|
|
135
|
+
|
|
136
|
+
<div className="dpc-head">
|
|
137
|
+
<h3>{t('marketTitle')}</h3>
|
|
138
|
+
<span className="dpc-spacer" />
|
|
139
|
+
<button type="button" className="dpc-refresh" aria-label={t('refresh')} title={t('refresh')} onClick={() => setReload((value) => value + 1)}>
|
|
140
|
+
<IconRefreshOutline14 size={14} aria-hidden="true" />
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
<p className="dpc-intro">{t('marketIntro')}</p>
|
|
144
|
+
|
|
145
|
+
<div className="dpc-segments" role="tablist" aria-label={t('marketTitle')}>
|
|
146
|
+
<button type="button" role="tab" aria-selected={segment === 'skills'} className="dpc-segment" data-active={segment === 'skills' ? 'true' : undefined} onClick={() => setSegment('skills')}>
|
|
147
|
+
{t('marketSkills')}
|
|
148
|
+
</button>
|
|
149
|
+
<button type="button" role="tab" aria-selected={segment === 'mcp'} className="dpc-segment" data-active={segment === 'mcp' ? 'true' : undefined} onClick={() => setSegment('mcp')}>
|
|
150
|
+
{t('marketMcp')}
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{outcome !== null && (
|
|
155
|
+
<div className="dpc-banner" data-kind={outcome.ok ? 'ok' : 'error'} role="status">
|
|
156
|
+
<StateDot state={outcome.ok ? 'done' : 'error'} size={10} />
|
|
157
|
+
<div className="dpc-bannerBody"><span>{outcome.text}</span></div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
{source === 'bundled' && (
|
|
161
|
+
<div className="dpc-banner" data-kind="info" role="status">
|
|
162
|
+
<StateDot state="ongoing" size={10} />
|
|
163
|
+
<div className="dpc-bannerBody"><span>{t('marketOffline')}</span></div>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{segment === 'skills' && (
|
|
168
|
+
<>
|
|
169
|
+
<p className="dpc-intro">{t('marketSkillsIntro')}</p>
|
|
170
|
+
{repos === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
171
|
+
{repos !== null && repos.length === 0 && <p className="dpc-empty">{t('marketEmpty')}</p>}
|
|
172
|
+
{repos !== null && repos.length > 0 && (
|
|
173
|
+
<ul className="dpc-cards">
|
|
174
|
+
{repos.map((repo) => (
|
|
175
|
+
<li className="dpc-card" key={repo.id}>
|
|
176
|
+
<div className="dpc-cardTop">
|
|
177
|
+
<strong className="dpc-cardTitle" title={repo.url}>{title(repo)}</strong>
|
|
178
|
+
{repo.skillCount !== undefined && <span className="dpc-tag">{t('marketSkillCount').replace('{n}', String(repo.skillCount))}</span>}
|
|
179
|
+
{repo.installedId !== null && <span className="dpc-tag" data-kind="source">{t('marketInstalled')}</span>}
|
|
180
|
+
</div>
|
|
181
|
+
<p className="dpc-cardDesc">{desc(repo)}</p>
|
|
182
|
+
<div className="dpc-cardRow">
|
|
183
|
+
<a className="dpc-link" href={repo.homepage ?? repo.url} target="_blank" rel="noreferrer">{t('marketHome')}</a>
|
|
184
|
+
<span className="dpc-spacer" />
|
|
185
|
+
{repo.installedId !== null ? (
|
|
186
|
+
<Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => setConfirmUninstall({ kind: 'root', id: repo.installedId as string, name: title(repo) })}>
|
|
187
|
+
{t('marketUninstall')}
|
|
188
|
+
</Button>
|
|
189
|
+
) : (
|
|
190
|
+
<Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallRepo(repo)}>
|
|
191
|
+
{busyId === repo.id ? t('marketInstalling') : t('marketInstall')}
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
)}
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{segment === 'mcp' && (
|
|
203
|
+
<>
|
|
204
|
+
<p className="dpc-intro">{t('marketMcpIntro')}</p>
|
|
205
|
+
{servers === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
206
|
+
{servers !== null && servers.length === 0 && <p className="dpc-empty">{t('marketEmpty')}</p>}
|
|
207
|
+
{servers !== null && servers.length > 0 && (
|
|
208
|
+
<ul className="dpc-cards">
|
|
209
|
+
{servers.map((server) => (
|
|
210
|
+
<li className="dpc-card" key={server.id}>
|
|
211
|
+
<div className="dpc-cardTop">
|
|
212
|
+
<strong className="dpc-cardTitle" title={server.id}>{title(server)}</strong>
|
|
213
|
+
<span className="dpc-tag">{server.transport}</span>
|
|
214
|
+
{server.runtime !== undefined && <span className="dpc-tag">{server.runtime}</span>}
|
|
215
|
+
{server.installed && <span className="dpc-tag" data-kind="source">{t('marketInstalled')}</span>}
|
|
216
|
+
</div>
|
|
217
|
+
<p className="dpc-cardDesc">{desc(server)}</p>
|
|
218
|
+
{server.envKeys !== undefined && server.envKeys.length > 0 && (
|
|
219
|
+
<p className="dpc-envHint">{t('marketNeedsEnv')}: {server.envKeys.join(', ')}</p>
|
|
220
|
+
)}
|
|
221
|
+
<div className="dpc-cardRow">
|
|
222
|
+
<a className="dpc-link" href={server.homepage} target="_blank" rel="noreferrer">{t('marketHome')}</a>
|
|
223
|
+
<span className="dpc-spacer" />
|
|
224
|
+
{server.installed ? (
|
|
225
|
+
<Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => void mcp.list().then(
|
|
226
|
+
(body) => {
|
|
227
|
+
const row = body.servers.find(item => item.serverName === server.id)
|
|
228
|
+
if (row !== undefined) setConfirmUninstall({ kind: 'mcp', id: row.id, name: server.id })
|
|
229
|
+
},
|
|
230
|
+
(error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
|
|
231
|
+
)}>
|
|
232
|
+
{t('marketUninstall')}
|
|
233
|
+
</Button>
|
|
234
|
+
) : (
|
|
235
|
+
<Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallMcp(server)}>
|
|
236
|
+
{busyId === server.id ? t('marketInstalling') : t('marketAdd')}
|
|
237
|
+
</Button>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</li>
|
|
241
|
+
))}
|
|
242
|
+
</ul>
|
|
243
|
+
)}
|
|
244
|
+
</>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
<Modal
|
|
248
|
+
open={confirmUninstall !== null}
|
|
249
|
+
onClose={() => setConfirmUninstall(null)}
|
|
250
|
+
title={t('marketUninstallConfirm')}
|
|
251
|
+
description={confirmUninstall?.name ?? undefined}
|
|
252
|
+
footer={
|
|
253
|
+
<>
|
|
254
|
+
<Button variant="ghost" onClick={() => setConfirmUninstall(null)}>{t('cancel')}</Button>
|
|
255
|
+
<Button variant="primary" disabled={busyId !== null} onClick={() => void doUninstall()}>{t('marketUninstall')}</Button>
|
|
256
|
+
</>
|
|
257
|
+
}
|
|
258
|
+
>
|
|
259
|
+
<p>{confirmUninstall?.kind === 'root' ? t('marketUninstallRootWarn') : t('removeWarn')}</p>
|
|
260
|
+
</Modal>
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
package/src/client/McpTab.tsx
CHANGED
|
@@ -82,6 +82,128 @@ function mapToPairs(map: Record<string, string> | undefined, separator: string):
|
|
|
82
82
|
return Object.entries(map).map(([key, value]) => `${key}${separator}${value.includes('\n') ? JSON.stringify(value) : value}`).join('\n')
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** YAML double-quoted scalar (JSON syntax is valid YAML); plain for simple ids. */
|
|
86
|
+
function yamlScalar(value: string): string {
|
|
87
|
+
return /^[A-Za-z0-9_./@-]+$/.test(value) ? value : JSON.stringify(value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shape the profile patch row exactly as the host would persist it (preview). */
|
|
91
|
+
export function mcpRowYaml(input: { serverName: string; transport: 'stdio' | 'streamable-http'; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string> }): string {
|
|
92
|
+
const name = input.serverName.trim() === '' ? 'server-name' : input.serverName.trim()
|
|
93
|
+
const lines = [
|
|
94
|
+
'- insert:',
|
|
95
|
+
` - id: mcp-${name}`,
|
|
96
|
+
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
97
|
+
' config:',
|
|
98
|
+
` serverName: ${yamlScalar(name)}`,
|
|
99
|
+
` transport: ${input.transport}`,
|
|
100
|
+
]
|
|
101
|
+
if (input.transport === 'stdio') {
|
|
102
|
+
lines.push(` command: ${yamlScalar(input.command ?? '')}`)
|
|
103
|
+
const args = input.args ?? []
|
|
104
|
+
if (args.length > 0) {
|
|
105
|
+
lines.push(' args:')
|
|
106
|
+
for (const arg of args) lines.push(` - ${yamlScalar(arg)}`)
|
|
107
|
+
}
|
|
108
|
+
const env = input.env ?? {}
|
|
109
|
+
if (Object.keys(env).length > 0) {
|
|
110
|
+
lines.push(' env:')
|
|
111
|
+
for (const [key, value] of Object.entries(env)) lines.push(` ${yamlScalar(key)}: ${yamlScalar(value)}`)
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
lines.push(` url: ${yamlScalar(input.url ?? '')}`)
|
|
115
|
+
const headers = input.headers ?? {}
|
|
116
|
+
if (Object.keys(headers).length > 0) {
|
|
117
|
+
lines.push(' headers:')
|
|
118
|
+
for (const [key, value] of Object.entries(headers)) lines.push(` ${yamlScalar(key)}: ${yamlScalar(value)}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The equivalent mcpServers JSON a foreign config or docs page would show. */
|
|
125
|
+
export function mcpJsonExample(transport: 'stdio' | 'streamable-http'): string {
|
|
126
|
+
const entry = transport === 'stdio'
|
|
127
|
+
? {
|
|
128
|
+
command: 'npx',
|
|
129
|
+
args: ['-y', '@example/mcp-server'],
|
|
130
|
+
env: { API_KEY: 'value' },
|
|
131
|
+
}
|
|
132
|
+
: {
|
|
133
|
+
type: 'http',
|
|
134
|
+
url: 'https://example.com/mcp',
|
|
135
|
+
headers: { Authorization: 'Bearer <token>' },
|
|
136
|
+
}
|
|
137
|
+
return JSON.stringify({ mcpServers: { 'server-name': entry } }, null, 2)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fields parsed out of a pasted MCP JSON config (any common shape). */
|
|
141
|
+
export interface ParsedMcpJson {
|
|
142
|
+
serverName?: string
|
|
143
|
+
transport: 'stdio' | 'streamable-http'
|
|
144
|
+
command?: string
|
|
145
|
+
args?: string[]
|
|
146
|
+
env?: Record<string, string>
|
|
147
|
+
url?: string
|
|
148
|
+
headers?: Record<string, string>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse one MCP server from pasted JSON: a bare entry, a dsh row, or a
|
|
152
|
+
* `{"mcpServers": {…}}` wrapper (first entry wins). Returns the reason on bad input. */
|
|
153
|
+
export function parseMcpJson(text: string): ParsedMcpJson | { error: string } {
|
|
154
|
+
let parsed: unknown
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(text)
|
|
157
|
+
} catch {
|
|
158
|
+
return { error: 'not valid JSON' }
|
|
159
|
+
}
|
|
160
|
+
if (typeof parsed !== 'object' || parsed === null) return { error: 'expected a JSON object' }
|
|
161
|
+
let record = parsed as Record<string, unknown>
|
|
162
|
+
let nameFromWrapper: string | undefined
|
|
163
|
+
const wrapped = record.mcpServers ?? record.mcp_servers ?? record.servers
|
|
164
|
+
if (typeof wrapped === 'object' && wrapped !== null && !Array.isArray(wrapped)) {
|
|
165
|
+
const first = Object.entries(wrapped as Record<string, unknown>)[0]
|
|
166
|
+
if (first === undefined) return { error: 'mcpServers object is empty' }
|
|
167
|
+
nameFromWrapper = first[0]
|
|
168
|
+
if (typeof first[1] !== 'object' || first[1] === null) return { error: 'server entry is not an object' }
|
|
169
|
+
record = first[1] as Record<string, unknown>
|
|
170
|
+
}
|
|
171
|
+
const stringMap = (value: unknown): Record<string, string> | undefined => {
|
|
172
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
173
|
+
const out: Record<string, string> = {}
|
|
174
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
175
|
+
if (typeof entry === 'string') out[key] = entry
|
|
176
|
+
}
|
|
177
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
178
|
+
}
|
|
179
|
+
const args = Array.isArray(record.args) && record.args.every(entry => typeof entry === 'string')
|
|
180
|
+
? record.args as string[]
|
|
181
|
+
: undefined
|
|
182
|
+
const command = typeof record.command === 'string' ? record.command : undefined
|
|
183
|
+
const url = typeof record.url === 'string' ? record.url : undefined
|
|
184
|
+
const declared = typeof record.type === 'string' ? record.type : typeof record.transport === 'string' ? record.transport : undefined
|
|
185
|
+
const httpDeclared = declared === 'http' || declared === 'streamable-http' || declared === 'sse'
|
|
186
|
+
const transport: 'stdio' | 'streamable-http' = command !== undefined && !httpDeclared
|
|
187
|
+
? 'stdio'
|
|
188
|
+
: url !== undefined ? 'streamable-http' : httpDeclared ? 'streamable-http' : 'stdio'
|
|
189
|
+
if (transport === 'stdio' && command === undefined) return { error: 'stdio config needs a "command" field' }
|
|
190
|
+
if (transport === 'streamable-http' && url === undefined) return { error: 'http config needs a "url" field' }
|
|
191
|
+
const serverName = nameFromWrapper
|
|
192
|
+
?? (typeof record.serverName === 'string' ? record.serverName : undefined)
|
|
193
|
+
?? (typeof record.name === 'string' && record.name !== '@deepseek-ai/dsh-mcp-client' ? record.name : undefined)
|
|
194
|
+
const env = stringMap(record.env)
|
|
195
|
+
const headers = stringMap(record.headers)
|
|
196
|
+
return {
|
|
197
|
+
...(serverName !== undefined ? { serverName } : {}),
|
|
198
|
+
transport,
|
|
199
|
+
...(command !== undefined ? { command } : {}),
|
|
200
|
+
...(args !== undefined ? { args } : {}),
|
|
201
|
+
...(env !== undefined ? { env } : {}),
|
|
202
|
+
...(url !== undefined ? { url } : {}),
|
|
203
|
+
...(headers !== undefined ? { headers } : {}),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
85
207
|
export function McpTab(props: { t: Translate; injected: McpInjected }): ReactElement {
|
|
86
208
|
const { t, injected } = props
|
|
87
209
|
const [servers, setServers] = useState<McpRow[] | null>(null)
|
|
@@ -96,6 +218,8 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
96
218
|
const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
|
|
97
219
|
const [formError, setFormError] = useState<string | null>(null)
|
|
98
220
|
const [reload, setReload] = useState(0)
|
|
221
|
+
const [pasteJson, setPasteJson] = useState('')
|
|
222
|
+
const [pasteError, setPasteError] = useState<string | null>(null)
|
|
99
223
|
|
|
100
224
|
const openImport = async (): Promise<void> => {
|
|
101
225
|
setImportOpen(true)
|
|
@@ -150,11 +274,15 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
150
274
|
|
|
151
275
|
const openCreate = (): void => {
|
|
152
276
|
setFormError(null)
|
|
277
|
+
setPasteError(null)
|
|
278
|
+
setPasteJson('')
|
|
153
279
|
setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
|
|
154
280
|
}
|
|
155
281
|
|
|
156
282
|
const openEdit = (row: McpRow): void => {
|
|
157
283
|
setFormError(null)
|
|
284
|
+
setPasteError(null)
|
|
285
|
+
setPasteJson('')
|
|
158
286
|
setEditor({
|
|
159
287
|
id: row.id,
|
|
160
288
|
serverName: row.serverName,
|
|
@@ -167,6 +295,40 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
167
295
|
})
|
|
168
296
|
}
|
|
169
297
|
|
|
298
|
+
/** Fill the form from pasted JSON (mcpServers wrapper, bare entry, dsh row). */
|
|
299
|
+
const doPasteFill = (): void => {
|
|
300
|
+
if (editor === null || pasteJson.trim() === '') return
|
|
301
|
+
const parsed = parseMcpJson(pasteJson)
|
|
302
|
+
if ('error' in parsed) {
|
|
303
|
+
setPasteError(parsed.error)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
// Existing rows keep their identity (serverName + transport); a pasted
|
|
307
|
+
// config of the other transport cannot apply to them.
|
|
308
|
+
const lockIdentity = editor.id !== ''
|
|
309
|
+
if (lockIdentity && parsed.transport !== editor.transport) {
|
|
310
|
+
setPasteError(t('pasteTransportMismatch'))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
setPasteError(null)
|
|
314
|
+
setFormError(null)
|
|
315
|
+
setEditor({
|
|
316
|
+
...editor,
|
|
317
|
+
...(parsed.serverName !== undefined && !lockIdentity ? { serverName: parsed.serverName } : {}),
|
|
318
|
+
...(!lockIdentity ? { transport: parsed.transport } : {}),
|
|
319
|
+
...(parsed.transport === 'stdio'
|
|
320
|
+
? {
|
|
321
|
+
command: parsed.command ?? editor.command,
|
|
322
|
+
args: (parsed.args ?? []).join('\n'),
|
|
323
|
+
env: mapToPairs(parsed.env, '='),
|
|
324
|
+
}
|
|
325
|
+
: {
|
|
326
|
+
url: parsed.url ?? editor.url,
|
|
327
|
+
headers: mapToPairs(parsed.headers, ':'),
|
|
328
|
+
}),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
170
332
|
const doSave = async (): Promise<void> => {
|
|
171
333
|
if (editor === null) return
|
|
172
334
|
setBusy(true)
|
|
@@ -373,6 +535,42 @@ export function McpTab(props: { t: Translate; injected: McpInjected }): ReactEle
|
|
|
373
535
|
</label>
|
|
374
536
|
</>
|
|
375
537
|
)}
|
|
538
|
+
<details className="dpc-format">
|
|
539
|
+
<summary>{t('formatTitle')}</summary>
|
|
540
|
+
<div className="dpc-form">
|
|
541
|
+
<label className="dpc-label">
|
|
542
|
+
<span>{t('formatPaste')}</span>
|
|
543
|
+
<textarea
|
|
544
|
+
className="dpc-textarea"
|
|
545
|
+
data-short="true"
|
|
546
|
+
placeholder={'{\n "mcpServers": { "name": { "command": "npx", "args": ["…"] } } }\n'}
|
|
547
|
+
value={pasteJson}
|
|
548
|
+
onChange={(event) => setPasteJson(event.target.value)}
|
|
549
|
+
/>
|
|
550
|
+
</label>
|
|
551
|
+
{pasteError !== null && <p className="dpc-formError">{pasteError}</p>}
|
|
552
|
+
<div className="dpc-cardRow">
|
|
553
|
+
<Button variant="outline" size="sm" disabled={pasteJson.trim() === ''} onClick={doPasteFill}>{t('formatFill')}</Button>
|
|
554
|
+
</div>
|
|
555
|
+
<p className="dpc-formatHint">{t('formatYamlHint')}</p>
|
|
556
|
+
<pre className="dpc-code">{mcpRowYaml({
|
|
557
|
+
serverName: editor.serverName,
|
|
558
|
+
transport: editor.transport,
|
|
559
|
+
...(editor.transport === 'stdio'
|
|
560
|
+
? {
|
|
561
|
+
command: editor.command.trim(),
|
|
562
|
+
args: editor.args.split(/\r?\n/).map(line => line.trim()).filter(line => line !== ''),
|
|
563
|
+
env: parsePairs(editor.env, '='),
|
|
564
|
+
}
|
|
565
|
+
: {
|
|
566
|
+
url: editor.url.trim(),
|
|
567
|
+
headers: parsePairs(editor.headers, ':'),
|
|
568
|
+
}),
|
|
569
|
+
})}</pre>
|
|
570
|
+
<p className="dpc-formatHint">{t('formatJsonHint')}</p>
|
|
571
|
+
<pre className="dpc-code">{mcpJsonExample(editor.transport)}</pre>
|
|
572
|
+
</div>
|
|
573
|
+
</details>
|
|
376
574
|
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
377
575
|
<div className="dpc-cardRow">
|
|
378
576
|
<span className="dpc-spacer" />
|