dsh-plugin-capabilities 0.1.6 → 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.
@@ -0,0 +1,164 @@
1
+ {
2
+ "version": 1,
3
+ "updatedAt": "2026-08-18",
4
+ "servers": [
5
+ {
6
+ "id": "filesystem",
7
+ "name": "Filesystem",
8
+ "nameZh": "文件系统",
9
+ "description": "Read/write access to a restricted set of directories. The default argument (\".\") allows the dsh working directory — edit the row to list the roots you want to expose.",
10
+ "descriptionZh": "受控目录的读写访问。默认参数 \".\" 只放开 dsh 工作目录,安装后可编辑该行,列出你要开放的具体目录。",
11
+ "transport": "stdio",
12
+ "command": "npx",
13
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
14
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
15
+ "category": "files",
16
+ "runtime": "npx (Node.js)"
17
+ },
18
+ {
19
+ "id": "memory",
20
+ "name": "Memory",
21
+ "nameZh": "知识图谱记忆",
22
+ "description": "Persistent knowledge-graph memory across sessions (entities, relations, observations).",
23
+ "descriptionZh": "跨会话的持久知识图谱记忆(实体、关系、观察)。",
24
+ "transport": "stdio",
25
+ "command": "npx",
26
+ "args": ["-y", "@modelcontextprotocol/server-memory"],
27
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
28
+ "category": "knowledge",
29
+ "runtime": "npx (Node.js)"
30
+ },
31
+ {
32
+ "id": "sequentialthinking",
33
+ "name": "Sequential Thinking",
34
+ "nameZh": "顺序思考",
35
+ "description": "Dynamic, reflective problem-solving through structured chains of thought.",
36
+ "descriptionZh": "通过结构化的思考链进行动态、反思式的问题求解。",
37
+ "transport": "stdio",
38
+ "command": "npx",
39
+ "args": ["-y", "@modelcontextprotocol/server-sequentialthinking"],
40
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
41
+ "category": "reasoning",
42
+ "runtime": "npx (Node.js)"
43
+ },
44
+ {
45
+ "id": "everything",
46
+ "name": "Everything",
47
+ "nameZh": "Everything 测试服务器",
48
+ "description": "Reference server exercising every MCP feature — prompts, resources, tools, sampling. Handy for verifying a client setup.",
49
+ "descriptionZh": "覆盖 MCP 全部能力的参考服务器——提示、资源、工具、采样,适合验证客户端配置。",
50
+ "transport": "stdio",
51
+ "command": "npx",
52
+ "args": ["-y", "@modelcontextprotocol/server-everything"],
53
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
54
+ "category": "test",
55
+ "runtime": "npx (Node.js)"
56
+ },
57
+ {
58
+ "id": "git",
59
+ "name": "Git",
60
+ "nameZh": "Git",
61
+ "description": "Operations on local Git repositories: status, diff, branches, commits, tags.",
62
+ "descriptionZh": "操作本地 Git 仓库:状态、差异、分支、提交、标签。",
63
+ "transport": "stdio",
64
+ "command": "uvx",
65
+ "args": ["mcp-server-git"],
66
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
67
+ "category": "dev",
68
+ "runtime": "uvx (Python/uv)"
69
+ },
70
+ {
71
+ "id": "fetch",
72
+ "name": "Fetch",
73
+ "nameZh": "网页抓取",
74
+ "description": "Web content fetching and conversion, optimized for LLM usage.",
75
+ "descriptionZh": "网页内容抓取与转换,面向 LLM 使用优化。",
76
+ "transport": "stdio",
77
+ "command": "uvx",
78
+ "args": ["mcp-server-fetch"],
79
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
80
+ "category": "web",
81
+ "runtime": "uvx (Python/uv)"
82
+ },
83
+ {
84
+ "id": "sqlite",
85
+ "name": "SQLite",
86
+ "nameZh": "SQLite",
87
+ "description": "SQLite database access with schema inspection and query analysis. Edit the row's --db-path argument after installing.",
88
+ "descriptionZh": "SQLite 数据库访问,支持结构检查与查询分析。安装后可编辑该行的 --db-path 参数。",
89
+ "transport": "stdio",
90
+ "command": "uvx",
91
+ "args": ["mcp-server-sqlite", "--db-path", "sqlite.db"],
92
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite",
93
+ "category": "data",
94
+ "runtime": "uvx (Python/uv)"
95
+ },
96
+ {
97
+ "id": "time",
98
+ "name": "Time",
99
+ "nameZh": "时间与时区",
100
+ "description": "Current time and timezone conversion.",
101
+ "descriptionZh": "当前时间查询与时区转换。",
102
+ "transport": "stdio",
103
+ "command": "uvx",
104
+ "args": ["mcp-server-time"],
105
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
106
+ "category": "knowledge",
107
+ "runtime": "uvx (Python/uv)"
108
+ },
109
+ {
110
+ "id": "github",
111
+ "name": "GitHub",
112
+ "nameZh": "GitHub",
113
+ "description": "Repositories, issues, PRs, and more. Requires a personal access token in the environment after install.",
114
+ "descriptionZh": "仓库、Issue、PR 等操作。安装后需在环境变量中填入个人访问令牌。",
115
+ "transport": "stdio",
116
+ "command": "npx",
117
+ "args": ["-y", "@modelcontextprotocol/server-github"],
118
+ "envKeys": ["GITHUB_PERSONAL_ACCESS_TOKEN"],
119
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/github",
120
+ "category": "dev",
121
+ "runtime": "npx (Node.js)"
122
+ },
123
+ {
124
+ "id": "brave-search",
125
+ "name": "Brave Search",
126
+ "nameZh": "Brave 搜索",
127
+ "description": "Web/local/image/news search via the Brave API. Requires a Brave API key.",
128
+ "descriptionZh": "通过 Brave API 进行网页/本地/图片/新闻搜索,需要 Brave API 密钥。",
129
+ "transport": "stdio",
130
+ "command": "npx",
131
+ "args": ["-y", "@modelcontextprotocol/server-brave-search"],
132
+ "envKeys": ["BRAVE_API_KEY"],
133
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search",
134
+ "category": "web",
135
+ "runtime": "npx (Node.js)"
136
+ },
137
+ {
138
+ "id": "puppeteer",
139
+ "name": "Puppeteer",
140
+ "nameZh": "Puppeteer 浏览器",
141
+ "description": "Browser automation: navigation, screenshots, clicks, form fills, JS evaluation.",
142
+ "descriptionZh": "浏览器自动化:导航、截图、点击、表单填写、JS 执行。",
143
+ "transport": "stdio",
144
+ "command": "npx",
145
+ "args": ["-y", "@modelcontextprotocol/server-puppeteer"],
146
+ "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer",
147
+ "category": "web",
148
+ "runtime": "npx (Node.js)"
149
+ },
150
+ {
151
+ "id": "context7",
152
+ "name": "Context7",
153
+ "nameZh": "Context7 文档查询",
154
+ "description": "Up-to-date, version-specific documentation and code examples for libraries and frameworks.",
155
+ "descriptionZh": "获取各类库与框架的最新版本文档与代码示例。",
156
+ "transport": "stdio",
157
+ "command": "npx",
158
+ "args": ["-y", "@upstash/context7-mcp"],
159
+ "homepage": "https://github.com/upstash/context7",
160
+ "category": "knowledge",
161
+ "runtime": "npx (Node.js)"
162
+ }
163
+ ]
164
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "version": 1,
3
+ "updatedAt": "2026-08-18",
4
+ "repos": [
5
+ {
6
+ "id": "anthropics-skills",
7
+ "name": "Anthropic Official Skills",
8
+ "nameZh": "Anthropic 官方技能集",
9
+ "description": "Document skills (docx/pdf/pptx/xlsx), artifacts-builder, canvas-design, mcp-builder and more from Anthropic's skills repository.",
10
+ "descriptionZh": "Anthropic 官方技能仓库:文档技能(docx/pdf/pptx/xlsx)、artifacts-builder、canvas-design、mcp-builder 等。",
11
+ "url": "https://github.com/anthropics/skills",
12
+ "homepage": "https://github.com/anthropics/skills",
13
+ "skillCount": 13
14
+ },
15
+ {
16
+ "id": "obra-superpowers",
17
+ "name": "Superpowers",
18
+ "nameZh": "Superpowers 工作流技能",
19
+ "description": "Jesse Vincent's curated workflow skills: brainstorming, systematic debugging, test-driven development, writing plans, and the dispatch/review loop around them.",
20
+ "descriptionZh": "Jesse Vincent 维护的工作流技能集:头脑风暴、系统化调试、测试驱动开发、撰写计划及配套的调度/评审流程。",
21
+ "url": "https://github.com/obra/superpowers",
22
+ "homepage": "https://github.com/obra/superpowers",
23
+ "skillCount": 18
24
+ },
25
+ {
26
+ "id": "vercel-labs-skills",
27
+ "name": "Vercel Labs Skills",
28
+ "nameZh": "Vercel Labs 技能",
29
+ "description": "Small practical set from Vercel Labs, including find-skills (discover and install community skills).",
30
+ "descriptionZh": "Vercel Labs 的实用小技能集,包含 find-skills(发现并安装社区技能)。",
31
+ "url": "https://github.com/vercel-labs/skills",
32
+ "homepage": "https://github.com/vercel-labs/skills",
33
+ "skillCount": 4
34
+ }
35
+ ]
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-capabilities",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Manage skills and MCP servers from the Web UI Settings. 在设置页管理 dsh 的技能与 MCP 服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -47,6 +47,7 @@
47
47
  "lib",
48
48
  "src",
49
49
  "skills",
50
+ "market",
50
51
  "cordis.patch.yml",
51
52
  "LICENSE"
52
53
  ],
@@ -1,12 +1,12 @@
1
1
  /** Settings top-level “技能与 MCP” section: one nav entry beside 通用设置 and
2
- * 模型, with the Skills and MCP pages as internal tabs. The two pages are the
3
- * same components the Plugins section used to host promoted a level instead
4
- * of squeezed under 插件. Pure composition; data still arrives through the
5
- * injected faces. */
2
+ * 模型, with the Skills, MCP, and 市场 pages as internal tabs. The pages are
3
+ * pure composition; data still arrives through the injected faces. */
6
4
 
7
5
  import { useEffect, useId, useRef, useState } from 'react'
8
6
  import type { ReactElement } from 'react'
9
7
  import { CSS } from './css.ts'
8
+ import { MarketTab } from './MarketTab.tsx'
9
+ import type { MarketInjected } from './MarketTab.tsx'
10
10
  import { McpTab } from './McpTab.tsx'
11
11
  import type { McpInjected } from './McpTab.tsx'
12
12
  import { SkillsTab } from './SkillsTab.tsx'
@@ -17,18 +17,20 @@ export function CapabilitiesSection(props: {
17
17
  t: Translate
18
18
  skills: SkillsInjected
19
19
  mcp: McpInjected
20
+ market: MarketInjected
20
21
  }): ReactElement {
21
- const { t, skills, mcp } = props
22
+ const { t, skills, mcp, market } = props
22
23
  const tabsId = useId()
23
24
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
24
25
  const rows = [
25
26
  { id: 'skills', label: t('skillsTab') },
26
27
  { id: 'mcp', label: t('mcpTab') },
28
+ { id: 'market', label: t('marketTab') },
27
29
  ]
28
30
  const [activeId, setActiveId] = useState('skills')
29
31
  // The skills page mounts immediately; a tab mounts only when first
30
32
  // selected, then stays mounted while hidden so editor drafts and outcome
31
- // banners survive switching between the two views.
33
+ // banners survive switching between the views.
32
34
  const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set(['skills']))
33
35
 
34
36
  useEffect(() => {
@@ -93,7 +95,9 @@ export function CapabilitiesSection(props: {
93
95
  >
94
96
  {row.id === 'skills'
95
97
  ? <SkillsTab t={t} injected={skills} />
96
- : <McpTab t={t} injected={mcp} />}
98
+ : row.id === 'mcp'
99
+ ? <McpTab t={t} injected={mcp} />
100
+ : <MarketTab t={t} market={market} mcp={mcp} />}
97
101
  </div>
98
102
  )
99
103
  })}
@@ -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
+ }