dsh-plugin-capabilities 0.2.0 → 0.3.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/market/mcp.json CHANGED
@@ -13,7 +13,8 @@
13
13
  "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
14
14
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
15
15
  "category": "files",
16
- "runtime": "npx (Node.js)"
16
+ "runtime": "npx (Node.js)",
17
+ "tools": ["read_file", "read_multiple_files", "write_file", "edit_file", "create_directory", "list_directory", "directory_tree", "move_file", "search_files", "get_file_info", "list_allowed_directories"]
17
18
  },
18
19
  {
19
20
  "id": "memory",
@@ -26,7 +27,8 @@
26
27
  "args": ["-y", "@modelcontextprotocol/server-memory"],
27
28
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
28
29
  "category": "knowledge",
29
- "runtime": "npx (Node.js)"
30
+ "runtime": "npx (Node.js)",
31
+ "tools": ["create_entities", "create_relations", "add_observations", "delete_entities", "delete_observations", "delete_relations", "read_graph", "search_nodes", "open_nodes"]
30
32
  },
31
33
  {
32
34
  "id": "sequentialthinking",
@@ -39,7 +41,8 @@
39
41
  "args": ["-y", "@modelcontextprotocol/server-sequentialthinking"],
40
42
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
41
43
  "category": "reasoning",
42
- "runtime": "npx (Node.js)"
44
+ "runtime": "npx (Node.js)",
45
+ "tools": ["sequentialthinking"]
43
46
  },
44
47
  {
45
48
  "id": "everything",
@@ -52,7 +55,8 @@
52
55
  "args": ["-y", "@modelcontextprotocol/server-everything"],
53
56
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
54
57
  "category": "test",
55
- "runtime": "npx (Node.js)"
58
+ "runtime": "npx (Node.js)",
59
+ "tools": ["echo", "add", "sampleLLM", "longRunningOperation", "getTinyImage", "annotatedMessage"]
56
60
  },
57
61
  {
58
62
  "id": "git",
@@ -65,7 +69,8 @@
65
69
  "args": ["mcp-server-git"],
66
70
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
67
71
  "category": "dev",
68
- "runtime": "uvx (Python/uv)"
72
+ "runtime": "uvx (Python/uv)",
73
+ "tools": ["git_status", "git_diff_unstaged", "git_diff_staged", "git_diff", "git_add", "git_commit", "git_reset", "git_log", "git_create_branch", "git_checkout_branch", "git_list_branches", "git_create_tag", "git_delete_tag", "git_list_tags", "git_push", "git_pull"]
69
74
  },
70
75
  {
71
76
  "id": "fetch",
@@ -78,7 +83,8 @@
78
83
  "args": ["mcp-server-fetch"],
79
84
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
80
85
  "category": "web",
81
- "runtime": "uvx (Python/uv)"
86
+ "runtime": "uvx (Python/uv)",
87
+ "tools": ["fetch"]
82
88
  },
83
89
  {
84
90
  "id": "sqlite",
@@ -91,7 +97,8 @@
91
97
  "args": ["mcp-server-sqlite", "--db-path", "sqlite.db"],
92
98
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite",
93
99
  "category": "data",
94
- "runtime": "uvx (Python/uv)"
100
+ "runtime": "uvx (Python/uv)",
101
+ "tools": ["read_query", "write_query", "create_table", "list_tables", "describe_table", "append_insight"]
95
102
  },
96
103
  {
97
104
  "id": "time",
@@ -104,7 +111,8 @@
104
111
  "args": ["mcp-server-time"],
105
112
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
106
113
  "category": "knowledge",
107
- "runtime": "uvx (Python/uv)"
114
+ "runtime": "uvx (Python/uv)",
115
+ "tools": ["get_current_time", "convert_time"]
108
116
  },
109
117
  {
110
118
  "id": "github",
@@ -118,7 +126,8 @@
118
126
  "envKeys": ["GITHUB_PERSONAL_ACCESS_TOKEN"],
119
127
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/github",
120
128
  "category": "dev",
121
- "runtime": "npx (Node.js)"
129
+ "runtime": "npx (Node.js)",
130
+ "tools": ["create_or_update_file", "create_branch", "create_issue", "add_issue_comment", "create_pull_request", "create_pull_request_review", "fork_repository", "push_files", "get_file_contents", "get_issue", "get_pull_request", "get_pull_request_files", "get_pull_request_status", "get_pull_request_comments", "list_commits", "list_issues", "list_pull_requests", "list_branches", "merge_pull_request", "search_code", "search_issues", "search_repositories", "search_users", "update_issue", "update_pull_request_branch"]
122
131
  },
123
132
  {
124
133
  "id": "brave-search",
@@ -132,7 +141,8 @@
132
141
  "envKeys": ["BRAVE_API_KEY"],
133
142
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search",
134
143
  "category": "web",
135
- "runtime": "npx (Node.js)"
144
+ "runtime": "npx (Node.js)",
145
+ "tools": ["brave_web_search", "brave_local_search"]
136
146
  },
137
147
  {
138
148
  "id": "puppeteer",
@@ -145,7 +155,8 @@
145
155
  "args": ["-y", "@modelcontextprotocol/server-puppeteer"],
146
156
  "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer",
147
157
  "category": "web",
148
- "runtime": "npx (Node.js)"
158
+ "runtime": "npx (Node.js)",
159
+ "tools": ["puppeteer_navigate", "puppeteer_screenshot", "puppeteer_click", "puppeteer_fill", "puppeteer_evaluate", "puppeteer_select"]
149
160
  },
150
161
  {
151
162
  "id": "context7",
@@ -158,7 +169,8 @@
158
169
  "args": ["-y", "@upstash/context7-mcp"],
159
170
  "homepage": "https://github.com/upstash/context7",
160
171
  "category": "knowledge",
161
- "runtime": "npx (Node.js)"
172
+ "runtime": "npx (Node.js)",
173
+ "tools": ["resolve-library-id", "get-library-docs"]
162
174
  }
163
175
  ]
164
176
  }
@@ -10,7 +10,8 @@
10
10
  "descriptionZh": "Anthropic 官方技能仓库:文档技能(docx/pdf/pptx/xlsx)、artifacts-builder、canvas-design、mcp-builder 等。",
11
11
  "url": "https://github.com/anthropics/skills",
12
12
  "homepage": "https://github.com/anthropics/skills",
13
- "skillCount": 13
13
+ "skillCount": 19,
14
+ "skills": ["algorithmic-art", "brand-guidelines", "canvas-design", "claude-academy-guide", "claude-api", "discernment-nudge", "doc-coauthoring", "docx", "frontend-design", "internal-comms", "mcp-builder", "pdf", "pptx", "skill-creator", "slack-gif-creator", "theme-factory", "web-artifacts-builder", "webapp-testing", "xlsx"]
14
15
  },
15
16
  {
16
17
  "id": "obra-superpowers",
@@ -20,7 +21,8 @@
20
21
  "descriptionZh": "Jesse Vincent 维护的工作流技能集:头脑风暴、系统化调试、测试驱动开发、撰写计划及配套的调度/评审流程。",
21
22
  "url": "https://github.com/obra/superpowers",
22
23
  "homepage": "https://github.com/obra/superpowers",
23
- "skillCount": 18
24
+ "skillCount": 14,
25
+ "skills": ["brainstorming", "dispatching-parallel-agents", "executing-plans", "finishing-a-development-branch", "receiving-code-review", "requesting-code-review", "subagent-driven-development", "systematic-debugging", "test-driven-development", "using-git-worktrees", "using-superpowers", "verification-before-completion", "writing-plans", "writing-skills"]
24
26
  },
25
27
  {
26
28
  "id": "vercel-labs-skills",
@@ -30,7 +32,8 @@
30
32
  "descriptionZh": "Vercel Labs 的实用小技能集,包含 find-skills(发现并安装社区技能)。",
31
33
  "url": "https://github.com/vercel-labs/skills",
32
34
  "homepage": "https://github.com/vercel-labs/skills",
33
- "skillCount": 4
35
+ "skillCount": 1,
36
+ "skills": ["find-skills"]
34
37
  }
35
38
  ]
36
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-capabilities",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -48,6 +48,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
48
48
  const [busyId, setBusyId] = useState<string | null>(null)
49
49
  const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
50
50
  const [confirmUninstall, setConfirmUninstall] = useState<{ kind: 'root' | 'mcp'; id: string; name: string } | null>(null)
51
+ const [detail, setDetail] = useState<{ kind: Segment; id: string } | null>(null)
51
52
  const [reload, setReload] = useState(0)
52
53
 
53
54
  const refreshSkills = useCallback((): void => {
@@ -129,6 +130,13 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
129
130
  const title = (entry: { name: string; nameZh?: string }): string => pick(entry.name, entry.nameZh)
130
131
  const desc = (entry: { description: string; descriptionZh?: string }): string => pick(entry.description, entry.descriptionZh)
131
132
 
133
+ const cardStop = (event: { stopPropagation(): void }): void => { event.stopPropagation() }
134
+
135
+ // The card list refreshes on install/uninstall, so the detail modal resolves
136
+ // its entry by id each render and follows the fresh installed state.
137
+ const detailRepo = detail !== null && detail.kind === 'skills' && repos !== null ? repos.find(repo => repo.id === detail.id) ?? null : null
138
+ const detailServer = detail !== null && detail.kind === 'mcp' && servers !== null ? servers.find(server => server.id === detail.id) ?? null : null
139
+
132
140
  return (
133
141
  <div className="dpc-section">
134
142
  <style>{CSS}</style>
@@ -172,7 +180,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
172
180
  {repos !== null && repos.length > 0 && (
173
181
  <ul className="dpc-cards">
174
182
  {repos.map((repo) => (
175
- <li className="dpc-card" key={repo.id}>
183
+ <li className="dpc-card" key={repo.id} onClick={() => setDetail({ kind: 'skills', id: repo.id })}>
176
184
  <div className="dpc-cardTop">
177
185
  <strong className="dpc-cardTitle" title={repo.url}>{title(repo)}</strong>
178
186
  {repo.skillCount !== undefined && <span className="dpc-tag">{t('marketSkillCount').replace('{n}', String(repo.skillCount))}</span>}
@@ -180,14 +188,15 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
180
188
  </div>
181
189
  <p className="dpc-cardDesc">{desc(repo)}</p>
182
190
  <div className="dpc-cardRow">
183
- <a className="dpc-link" href={repo.homepage ?? repo.url} target="_blank" rel="noreferrer">{t('marketHome')}</a>
191
+ <button type="button" className="dpc-link" onClick={() => setDetail({ kind: 'skills', id: repo.id })}>{t('marketDetail')}</button>
192
+ <a className="dpc-link" href={repo.homepage ?? repo.url} target="_blank" rel="noreferrer" onClick={cardStop}>{t('marketHome')}</a>
184
193
  <span className="dpc-spacer" />
185
194
  {repo.installedId !== null ? (
186
- <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => setConfirmUninstall({ kind: 'root', id: repo.installedId as string, name: title(repo) })}>
195
+ <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); setConfirmUninstall({ kind: 'root', id: repo.installedId as string, name: title(repo) }) }}>
187
196
  {t('marketUninstall')}
188
197
  </Button>
189
198
  ) : (
190
- <Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallRepo(repo)}>
199
+ <Button variant="primary" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void doInstallRepo(repo) }}>
191
200
  {busyId === repo.id ? t('marketInstalling') : t('marketInstall')}
192
201
  </Button>
193
202
  )}
@@ -207,7 +216,7 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
207
216
  {servers !== null && servers.length > 0 && (
208
217
  <ul className="dpc-cards">
209
218
  {servers.map((server) => (
210
- <li className="dpc-card" key={server.id}>
219
+ <li className="dpc-card" key={server.id} onClick={() => setDetail({ kind: 'mcp', id: server.id })}>
211
220
  <div className="dpc-cardTop">
212
221
  <strong className="dpc-cardTitle" title={server.id}>{title(server)}</strong>
213
222
  <span className="dpc-tag">{server.transport}</span>
@@ -219,20 +228,21 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
219
228
  <p className="dpc-envHint">{t('marketNeedsEnv')}: {server.envKeys.join(', ')}</p>
220
229
  )}
221
230
  <div className="dpc-cardRow">
222
- <a className="dpc-link" href={server.homepage} target="_blank" rel="noreferrer">{t('marketHome')}</a>
231
+ <button type="button" className="dpc-link" onClick={() => setDetail({ kind: 'mcp', id: server.id })}>{t('marketDetail')}</button>
232
+ <a className="dpc-link" href={server.homepage} target="_blank" rel="noreferrer" onClick={cardStop}>{t('marketHome')}</a>
223
233
  <span className="dpc-spacer" />
224
234
  {server.installed ? (
225
- <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={() => void mcp.list().then(
235
+ <Button variant="ghost" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void mcp.list().then(
226
236
  (body) => {
227
237
  const row = body.servers.find(item => item.serverName === server.id)
228
238
  if (row !== undefined) setConfirmUninstall({ kind: 'mcp', id: row.id, name: server.id })
229
239
  },
230
240
  (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
231
- )}>
241
+ ) }}>
232
242
  {t('marketUninstall')}
233
243
  </Button>
234
244
  ) : (
235
- <Button variant="primary" size="sm" disabled={busyId !== null} onClick={() => void doInstallMcp(server)}>
245
+ <Button variant="primary" size="sm" disabled={busyId !== null} onClick={(event) => { event.stopPropagation(); void doInstallMcp(server) }}>
236
246
  {busyId === server.id ? t('marketInstalling') : t('marketAdd')}
237
247
  </Button>
238
248
  )}
@@ -244,6 +254,94 @@ export function MarketTab(props: { t: Translate; market: MarketInjected; mcp: Mc
244
254
  </>
245
255
  )}
246
256
 
257
+ <Modal
258
+ open={detailRepo !== null || detailServer !== null}
259
+ onClose={() => setDetail(null)}
260
+ title={detailRepo !== null ? title(detailRepo) : detailServer !== null ? title(detailServer) : ''}
261
+ footer={
262
+ <>
263
+ {detailRepo !== null && (
264
+ <a className="dpc-link" href={detailRepo.homepage ?? detailRepo.url} target="_blank" rel="noreferrer">{t('marketHome')}</a>
265
+ )}
266
+ {detailServer !== null && (
267
+ <a className="dpc-link" href={detailServer.homepage} target="_blank" rel="noreferrer">{t('marketHome')}</a>
268
+ )}
269
+ <span className="dpc-spacer" />
270
+ {detailRepo !== null && (detailRepo.installedId !== null ? (
271
+ <Button variant="ghost" disabled={busyId !== null} onClick={() => { setConfirmUninstall({ kind: 'root', id: detailRepo.installedId as string, name: title(detailRepo) }); setDetail(null) }}>
272
+ {t('marketUninstall')}
273
+ </Button>
274
+ ) : (
275
+ <Button variant="primary" disabled={busyId !== null} onClick={() => void doInstallRepo(detailRepo)}>
276
+ {busyId === detailRepo.id ? t('marketInstalling') : t('marketInstall')}
277
+ </Button>
278
+ ))}
279
+ {detailServer !== null && (detailServer.installed ? (
280
+ <Button variant="ghost" disabled={busyId !== null} onClick={() => void mcp.list().then(
281
+ (body) => {
282
+ const row = body.servers.find(item => item.serverName === detailServer.id)
283
+ if (row !== undefined) { setConfirmUninstall({ kind: 'mcp', id: row.id, name: title(detailServer) }); setDetail(null) }
284
+ },
285
+ (error: Error) => setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }),
286
+ )}>
287
+ {t('marketUninstall')}
288
+ </Button>
289
+ ) : (
290
+ <Button variant="primary" disabled={busyId !== null} onClick={() => void doInstallMcp(detailServer)}>
291
+ {busyId === detailServer.id ? t('marketInstalling') : t('marketAdd')}
292
+ </Button>
293
+ ))}
294
+ </>
295
+ }
296
+ >
297
+ {detailRepo !== null && (
298
+ <>
299
+ <p className="dpc-detailDesc">{desc(detailRepo)}</p>
300
+ {detailRepo.skills !== undefined && detailRepo.skills.length > 0 && (
301
+ <div className="dpc-detailSection">
302
+ <div className="dpc-detailLabel">{t('marketSkillListLabel')}</div>
303
+ <div className="dpc-detailTags">
304
+ {detailRepo.skills.map(name => <span className="dpc-tag" key={name}>{name}</span>)}
305
+ </div>
306
+ </div>
307
+ )}
308
+ </>
309
+ )}
310
+ {detailServer !== null && (
311
+ <>
312
+ <p className="dpc-detailDesc">{desc(detailServer)}</p>
313
+ {detailServer.command !== undefined && (
314
+ <div className="dpc-detailSection">
315
+ <div className="dpc-detailLabel">{t('marketCommandLabel')}</div>
316
+ <code className="dpc-code">{[detailServer.command, ...(detailServer.args ?? [])].join(' ')}</code>
317
+ </div>
318
+ )}
319
+ {detailServer.transport === 'streamable-http' && detailServer.url !== undefined && (
320
+ <div className="dpc-detailSection">
321
+ <div className="dpc-detailLabel">{t('marketCommandLabel')}</div>
322
+ <code className="dpc-code">{detailServer.url}</code>
323
+ </div>
324
+ )}
325
+ {detailServer.envKeys !== undefined && detailServer.envKeys.length > 0 && (
326
+ <div className="dpc-detailSection">
327
+ <div className="dpc-detailLabel">{t('marketNeedsEnv')}</div>
328
+ <div className="dpc-detailTags">
329
+ {detailServer.envKeys.map(key => <span className="dpc-tag" key={key}>{key}</span>)}
330
+ </div>
331
+ </div>
332
+ )}
333
+ {detailServer.tools !== undefined && detailServer.tools.length > 0 && (
334
+ <div className="dpc-detailSection">
335
+ <div className="dpc-detailLabel">{t('marketToolsLabel')}</div>
336
+ <div className="dpc-detailTags">
337
+ {detailServer.tools.map(name => <span className="dpc-tag" key={name}>{name}</span>)}
338
+ </div>
339
+ </div>
340
+ )}
341
+ </>
342
+ )}
343
+ </Modal>
344
+
247
345
  <Modal
248
346
  open={confirmUninstall !== null}
249
347
  onClose={() => setConfirmUninstall(null)}
package/src/client/css.ts CHANGED
@@ -26,7 +26,7 @@ export const CSS = `
26
26
  .dpc-refresh:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
27
27
  .dpc-empty{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
28
28
  .dpc-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:10px;margin:0;padding:0;list-style:none}
29
- .dpc-card{display:flex;flex-direction:column;gap:8px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);padding:12px 14px}
29
+ .dpc-card{display:flex;flex-direction:column;gap:8px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);padding:12px 14px;cursor:pointer}
30
30
  .dpc-card:hover{background:var(--dsw-alias-interactive-bg-hover)}
31
31
  .dpc-cardTop{display:flex;align-items:center;gap:8px}
32
32
  .dpc-cardTitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;font-weight:600;font-family:var(--ds-font-family-code)}
@@ -108,5 +108,10 @@ export const CSS = `
108
108
  .dpc-format .dpc-form{margin-top:8px}
109
109
  .dpc-formatHint{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
110
110
  .dpc-code{margin:4px 0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:8px 10px;overflow-x:auto;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code);font-size:11px;line-height:17px;white-space:pre}
111
+ /* Market detail modal. */
112
+ .dpc-detailDesc{margin:0;font-size:13px;line-height:20px}
113
+ .dpc-detailSection{margin-top:12px;display:flex;flex-direction:column;gap:6px}
114
+ .dpc-detailLabel{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
115
+ .dpc-detailTags{display:flex;flex-wrap:wrap;gap:4px}
111
116
  @media(max-width:680px){.dpc-cards{grid-template-columns:minmax(0,1fr)}}
112
117
  `
@@ -141,6 +141,7 @@ export interface MarketRepoView {
141
141
  url: string
142
142
  homepage?: string
143
143
  skillCount?: number
144
+ skills?: string[]
144
145
  }
145
146
 
146
147
  /** One MCP-market server as the browser sees it. */
@@ -158,4 +159,5 @@ export interface MarketServerView {
158
159
  homepage: string
159
160
  category?: string
160
161
  runtime?: string
162
+ tools?: string[]
161
163
  }
@@ -125,6 +125,10 @@ export const zh = {
125
125
  marketRepoInstalled: '仓库已安装,技能稍候出现在「技能」页列表。',
126
126
  marketUninstallConfirm: '确认移除?',
127
127
  marketUninstallRootWarn: '将取消扫描该仓库并删除下载的副本(移除后可重新安装)。',
128
+ marketDetail: '详情',
129
+ marketSkillListLabel: '技能清单',
130
+ marketToolsLabel: '提供工具',
131
+ marketCommandLabel: '启动命令',
128
132
  }
129
133
 
130
134
  export const en = {
@@ -252,4 +256,8 @@ export const en = {
252
256
  marketRepoInstalled: 'Repository installed — its skills appear on the Skills tab shortly.',
253
257
  marketUninstallConfirm: 'Uninstall?',
254
258
  marketUninstallRootWarn: 'Stops scanning the repository and deletes the downloaded copy (you can reinstall later).',
259
+ marketDetail: 'Details',
260
+ marketSkillListLabel: 'Skills included',
261
+ marketToolsLabel: 'Tools provided',
262
+ marketCommandLabel: 'Launch command',
255
263
  }
@@ -8,10 +8,11 @@ const okJson = (body: unknown): typeof fetch =>
8
8
  describe('loadMarketIndex', () => {
9
9
  it('prefers a valid remote index', async () => {
10
10
  const index = await loadMarketIndex('skills', {
11
- fetcher: okJson({ repos: [{ id: 'r1', name: 'Repo', description: 'd', url: 'https://github.com/a/b' }] }),
11
+ fetcher: okJson({ repos: [{ id: 'r1', name: 'Repo', description: 'd', url: 'https://github.com/a/b', skills: ['s1', 's2'] }] }),
12
12
  })
13
13
  expect(index?.source).toBe('remote')
14
14
  expect(index?.skills?.[0]?.id).toBe('r1')
15
+ expect(index?.skills?.[0]?.skills).toEqual(['s1', 's2'])
15
16
  })
16
17
 
17
18
  it('falls back to the bundled snapshot when remote is unreachable or invalid', async () => {
@@ -29,7 +30,7 @@ describe('loadMarketIndex', () => {
29
30
  const index = await loadMarketIndex('mcp', {
30
31
  fetcher: okJson({
31
32
  servers: [
32
- { id: 'ok', name: 'OK', description: 'd', transport: 'stdio', command: 'npx', args: ['-y', 'x'], envKeys: ['K'], homepage: 'https://x' },
33
+ { id: 'ok', name: 'OK', description: 'd', transport: 'stdio', command: 'npx', args: ['-y', 'x'], envKeys: ['K'], homepage: 'https://x', tools: ['t1'] },
33
34
  { id: 'no-command', name: 'Bad', description: 'd', transport: 'stdio', homepage: 'https://x' },
34
35
  { id: 'no-home', name: 'Bad2', description: 'd', transport: 'stdio', command: 'npx' },
35
36
  'not an object',
@@ -38,7 +39,7 @@ describe('loadMarketIndex', () => {
38
39
  })
39
40
  expect(index?.source).toBe('remote')
40
41
  expect(index?.servers).toHaveLength(1)
41
- expect(index?.servers?.[0]).toMatchObject({ id: 'ok', envKeys: ['K'] })
42
+ expect(index?.servers?.[0]).toMatchObject({ id: 'ok', envKeys: ['K'], tools: ['t1'] })
42
43
  })
43
44
 
44
45
  it('ships parseable bundled snapshots with expected top-level shape', () => {
@@ -54,4 +55,12 @@ describe('loadMarketIndex', () => {
54
55
  const index = await loadMarketIndex('mcp', { fetcher: okJson({ servers: [{ id: 'http1', name: 'H', description: 'd', transport: 'streamable-http', url: 'https://e/mcp', homepage: 'https://x' }] }) })
55
56
  expect(index?.servers?.[0]).toMatchObject({ transport: 'streamable-http', url: 'https://e/mcp' })
56
57
  })
58
+
59
+ it('bundles skill and tool inventories for the market detail view', async () => {
60
+ const failing = (async () => { throw new Error('offline') }) as unknown as typeof fetch
61
+ const skills = await loadMarketIndex('skills', { fetcher: failing })
62
+ expect(skills?.skills?.some(repo => (repo.skills?.length ?? 0) > 0)).toBe(true)
63
+ const mcp = await loadMarketIndex('mcp', { fetcher: failing })
64
+ expect(mcp?.servers?.some(server => (server.tools?.length ?? 0) > 0)).toBe(true)
65
+ })
57
66
  })
package/src/market.ts CHANGED
@@ -23,6 +23,8 @@ export interface MarketSkillRepo {
23
23
  url: string
24
24
  homepage?: string
25
25
  skillCount?: number
26
+ /** Skill names inside the repository, for the market detail view. */
27
+ skills?: string[]
26
28
  }
27
29
 
28
30
  /** One installable server in the MCP index. */
@@ -41,6 +43,8 @@ export interface MarketMcpServer {
41
43
  category?: string
42
44
  /** Runtime hint shown next to stdio commands (npx / uvx). */
43
45
  runtime?: string
46
+ /** Tool names the server exposes, for the market detail view. */
47
+ tools?: string[]
44
48
  }
45
49
 
46
50
  export interface MarketIndex {
@@ -80,6 +84,7 @@ function parseSkillsIndex(parsed: unknown): MarketSkillRepo[] | null {
80
84
  url: record.url,
81
85
  ...isString(record.homepage) ? { homepage: record.homepage } : {},
82
86
  ...(typeof record.skillCount === 'number' ? { skillCount: record.skillCount } : {}),
87
+ ...isStringArray(record.skills) ? { skills: record.skills } : {},
83
88
  })
84
89
  }
85
90
  return out.length > 0 ? out : null
@@ -111,6 +116,7 @@ function parseMcpIndex(parsed: unknown): MarketMcpServer[] | null {
111
116
  homepage: record.homepage,
112
117
  ...isString(record.category) ? { category: record.category } : {},
113
118
  ...isString(record.runtime) ? { runtime: record.runtime } : {},
119
+ ...isStringArray(record.tools) ? { tools: record.tools } : {},
114
120
  })
115
121
  }
116
122
  return out.length > 0 ? out : null
package/src/opener.ts CHANGED
@@ -15,9 +15,17 @@ export function openDirectory(dir: string): boolean {
15
15
  } catch {
16
16
  return false
17
17
  }
18
+ if (process.platform === 'win32') {
19
+ // explorer 对正斜杠路径会静默回落到默认文件夹(实测打开成了「文档」),必须喂反斜杠。
20
+ dir = dir.split('/').join('\\')
21
+ }
18
22
  const launcher = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open'
19
23
  try {
20
- const child = spawn(launcher, [dir], { detached: true, stdio: 'ignore', windowsHide: true })
24
+ // GUI 启动器不能带 windowsHide:该 flag SW_HIDE 写进 STARTUPINFO,explorer
25
+ // 首个窗口据此创建为隐藏——实机表现是「打开目录」点了没反应,窗口其实开了。
26
+ const child = spawn(launcher, [dir], { detached: true, stdio: 'ignore' })
27
+ // 启动器解析失败时 'error' 异步到达;没有监听会升级成 uncaughtException 炸掉 sidecar。
28
+ child.once('error', () => {})
21
29
  child.unref()
22
30
  return true
23
31
  } catch {
package/src/routes.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** HTTP routes bridging the Settings UI to the capabilities manager. */
2
2
 
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import { mkdirSync } from 'node:fs'
4
5
  import { scanAllMcp } from './agents.ts'
5
6
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
6
7
  import { loadMarketIndex, type MarketMcpServer } from './market.ts'
@@ -199,8 +200,11 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
199
200
  let dir: string | undefined
200
201
  if (body.target === 'user-skills') {
201
202
  dir = userSkillsDir()
203
+ // 用户还没建过任何技能时该目录不存在;「打开技能目录」应按需创建而非报错。
204
+ mkdirSync(dir, { recursive: true })
202
205
  } else if (body.target === 'plugin-state') {
203
206
  dir = pluginStateDir()
207
+ mkdirSync(dir, { recursive: true })
204
208
  } else if (body.target === 'skill') {
205
209
  if (typeof body.name !== 'string') {
206
210
  sendJson(response, 400, { error: 'name is required' })
package/src/smoke.test.ts CHANGED
@@ -199,10 +199,11 @@ describe.skipIf(process.env.DSH_DESKTOP_PLUGIN_SMOKE !== '1' || !guard || !nodeO
199
199
  expect(after.servers.some(row => row.serverName === 'memory')).toBe(true)
200
200
  }
201
201
 
202
- // 9. Open-folder route resolves server-side and answers ok (no GUI
203
- // assertion beyond the route not failing headless).
202
+ // 9. Open-folder route resolves server-side and answers ok; the skills
203
+ // home is created on demand, so a fresh DSH_HOME opens (not 422s).
204
204
  const open = await post('/dsh-plugin-capabilities/open', { target: 'user-skills' })
205
- expect([200, 422]).toContain(open.status)
205
+ expect(open.status).toBe(200)
206
+ expect(existsSync(join(smokeRoot, 'skills'))).toBe(true)
206
207
  const openBad = await post('/dsh-plugin-capabilities/open', { target: 'root', id: 'does-not-exist' })
207
208
  expect(openBad.status).toBe(404)
208
209
  })