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.
@@ -1,10 +1,11 @@
1
1
  /** dsh-plugin-capabilities client entry: contributes the top-level
2
- * “技能与 MCP” section into Settings (beside 通用设置/模型, with Skills and
3
- * MCP as internal tabs). Calls the host routes with fetch. */
2
+ * “技能与 MCP” section into Settings (beside 通用设置/模型, with Skills, MCP,
3
+ * and 市场 as internal tabs). Calls the host routes with fetch. */
4
4
 
5
5
  import { createElement as h } from 'react'
6
6
  import { CapabilitiesSection } from './CapabilitiesSection.tsx'
7
7
  import type { McpInjected, McpRow } from './McpTab.tsx'
8
+ import type { MarketInjected, RootRowView } from './MarketTab.tsx'
8
9
  import type { SkillsInjected, SkillRowView } from './SkillsTab.tsx'
9
10
  import { zh, en } from './locales.ts'
10
11
 
@@ -63,6 +64,12 @@ export interface ImportedServerView {
63
64
  headers?: Record<string, string>
64
65
  }
65
66
 
67
+ /** Openable targets on the host (server resolves real paths). */
68
+ export type OpenTarget =
69
+ | { target: 'user-skills' }
70
+ | { target: 'skill'; name: string }
71
+ | { target: 'root'; id: string }
72
+
66
73
  export function apply(ctx: CapabilitiesClientContext): void {
67
74
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-plugin-capabilities: dictionaries')
68
75
  const t = ctx.locale.bind(NS)
@@ -72,6 +79,13 @@ export function apply(ctx: CapabilitiesClientContext): void {
72
79
  get: (name: string) => fetchJson<SkillBody>(`/dsh-plugin-capabilities/skill?name=${encodeURIComponent(name)}`),
73
80
  save: (input: unknown) => post('/dsh-plugin-capabilities/skill/save', input) as Promise<{ ok: boolean }>,
74
81
  remove: (name: string) => post('/dsh-plugin-capabilities/skill/delete', { name }) as Promise<{ ok: boolean }>,
82
+ policy: (name: string, enabled: boolean) =>
83
+ post('/dsh-plugin-capabilities/skill/policy', { name, enabled }) as Promise<{ ok: boolean }>,
84
+ open: (target: OpenTarget) => post('/dsh-plugin-capabilities/open', target) as Promise<{ ok: boolean }>,
85
+ roots: () => fetchJson<{ roots: RootRowView[] }>('/dsh-plugin-capabilities/roots'),
86
+ addRoot: (input: { kind: 'local' | 'git'; path?: string; url?: string }) =>
87
+ post('/dsh-plugin-capabilities/roots/add', input) as Promise<{ ok: boolean; root: RootRowView }>,
88
+ removeRoot: (id: string) => post('/dsh-plugin-capabilities/roots/remove', { id }) as Promise<{ ok: boolean }>,
75
89
  }
76
90
 
77
91
  const mcpInjected: McpInjected = {
@@ -93,9 +107,19 @@ export function apply(ctx: CapabilitiesClientContext): void {
93
107
  desktop: window.dshDesktop !== undefined,
94
108
  }
95
109
 
110
+ const marketInjected: MarketInjected = {
111
+ skillsIndex: () => fetchJson<{ source: 'remote' | 'bundled'; repos: Array<MarketRepoView & { installedId: string | null }> }>('/dsh-plugin-capabilities/market/skills'),
112
+ mcpIndex: () => fetchJson<{ source: 'remote' | 'bundled'; servers: Array<MarketServerView & { installed: boolean }> }>('/dsh-plugin-capabilities/market/mcp'),
113
+ installSkillRepo: (url: string) =>
114
+ post('/dsh-plugin-capabilities/market/skills/install', { url }) as Promise<{ ok: boolean; root: RootRowView }>,
115
+ installMcp: (id: string) =>
116
+ post('/dsh-plugin-capabilities/market/mcp/install', { id }) as Promise<{ ok: boolean; id: string }>,
117
+ removeRoot: skillsInjected.removeRoot,
118
+ }
119
+
96
120
  // One top-level nav entry (between 模型 and 插件), not a tab under the
97
- // Plugins section — the section component owns its internal Skills/MCP
98
- // tabs directly, so nothing registers into settings.plugins.tab anymore.
121
+ // Plugins section — the section component owns its internal tabs directly,
122
+ // so nothing registers into settings.plugins.tab anymore.
99
123
  ctx.slots.inject('settings.section', () => {
100
124
  return ctx.slots.register({
101
125
  name: 'settings.section',
@@ -103,6 +127,35 @@ export function apply(ctx: CapabilitiesClientContext): void {
103
127
  order: 12,
104
128
  label: () => t('sectionNav'),
105
129
  locale: NS,
106
- }, () => h(CapabilitiesSection, { t, skills: skillsInjected, mcp: mcpInjected }))
130
+ }, () => h(CapabilitiesSection, { t, skills: skillsInjected, mcp: mcpInjected, market: marketInjected }))
107
131
  })
108
132
  }
133
+
134
+ /** One skills-market repository as the browser sees it. */
135
+ export interface MarketRepoView {
136
+ id: string
137
+ name: string
138
+ nameZh?: string
139
+ description: string
140
+ descriptionZh?: string
141
+ url: string
142
+ homepage?: string
143
+ skillCount?: number
144
+ }
145
+
146
+ /** One MCP-market server as the browser sees it. */
147
+ export interface MarketServerView {
148
+ id: string
149
+ name: string
150
+ nameZh?: string
151
+ description: string
152
+ descriptionZh?: string
153
+ transport: 'stdio' | 'streamable-http'
154
+ command?: string
155
+ args?: string[]
156
+ envKeys?: string[]
157
+ url?: string
158
+ homepage: string
159
+ category?: string
160
+ runtime?: string
161
+ }
@@ -4,8 +4,9 @@ export const zh = {
4
4
  sectionNav: '技能与 MCP',
5
5
  skillsTab: '技能',
6
6
  mcpTab: 'MCP',
7
+ marketTab: '市场',
7
8
  skillsTitle: '技能管理',
8
- skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills)可在此新建、修改、删除,文件被监听,保存即生效。若存在 ~/.claude/skills 或 ~/.codex/skills,会自动纳入扫描(零拷贝、实时同步)。',
9
+ skillsIntro: '查看与编辑 dsh 发现的技能;用户级技能(DSH_HOME/skills)可在此新建、修改、删除,文件被监听,保存即生效。若存在 ~/.claude/skills 或 ~/.codex/skills,会自动纳入扫描(零拷贝、实时同步)。卡片上的开关控制是否加载:停用即从模型与用户目录同时摘除,开启恢复默认。',
9
10
  newSkill: '新建技能',
10
11
  editSkill: '编辑技能',
11
12
  viewSkill: '查看技能',
@@ -35,6 +36,35 @@ export const zh = {
35
36
  confirmDelete: '确认删除技能?',
36
37
  deleteWarn: '将删除 DSH_HOME/skills 下的整个技能目录,此操作不可撤销。',
37
38
  saved: '已保存,技能目录被监听,稍候即可在会话中使用。',
39
+ searchSkills: '搜索技能名称或描述',
40
+ filterAll: '全部',
41
+ noMatch: '没有匹配的技能',
42
+ refresh: '刷新',
43
+ skillDisabled: '已停用',
44
+ skillUserOnly: '仅用户可用',
45
+ skillModelOnly: '仅模型可用',
46
+ skillEnabled: '已启用,稍候在列表中生效。',
47
+ skillDisabledMsg: '已停用:模型与 / 命令都不会再加载它。',
48
+ toggleSkill: '切换技能加载',
49
+ toggleSkillHint: '停用会在该技能的 SKILL.md 里写入 disable-model-invocation 与 user-invocable,同时从模型与用户目录摘除;开启则恢复默认策略。文件其余内容不变。',
50
+ openFolder: '打开目录',
51
+ openUserSkills: '打开技能目录',
52
+ rootsTitle: '技能仓库',
53
+ rootsIntro: '手动添加技能仓库:本地目录直接扫描(零拷贝),GitHub 仓库下载压缩包后解包到 DSH_HOME 内(无需安装 git)。也可以在「市场」页一键安装精选仓库。',
54
+ emptyRoots: '还没有添加自定义技能仓库',
55
+ addRoot: '添加仓库',
56
+ rootLocal: '本地',
57
+ rootLocalPlaceholder: 'D:\\path\\to\\skills',
58
+ rootKind: '仓库类型',
59
+ rootPlaceholder: '仓库地址',
60
+ add: '添加',
61
+ rootAdded: '仓库已添加,目录实时生效。',
62
+ rootRemoved: '仓库已移除。',
63
+ rootStale: '目录缺失',
64
+ rootGitHint: '支持 https://github.com/owner/repo、owner/repo,可用 #分支 或 /tree/分支 指定版本。',
65
+ rootLocalHint: '指向一个技能目录(内含 SKILL.md)或装着多个技能目录的文件夹。',
66
+ confirmRemoveRoot: '确认移除该仓库?',
67
+ removeRootWarn: '将取消扫描该仓库并删除下载到 DSH_HOME 的副本(本地目录本身不受影响)。',
38
68
  mcpTitle: 'MCP 服务器',
39
69
  mcpIntro: '管理 profile 中的 MCP 服务器行(@deepseek-ai/dsh-mcp-client)。新增、修改或删除后需要重启 dsh 才生效。',
40
70
  addServer: '添加服务器',
@@ -70,14 +100,40 @@ export const zh = {
70
100
  restarting: '正在重启,恢复后将自动刷新页面…',
71
101
  restartPortHint: '若重启后页面长时间未恢复,可能是端口已变化:在终端查看新地址后打开。',
72
102
  failed: '操作失败',
103
+ formatTitle: '完整格式与快速填充',
104
+ formatPaste: '粘贴 JSON 配置(mcpServers 包装或单条目均可)',
105
+ formatFill: '解析并填充表单',
106
+ formatYamlHint: '保存后将写入 profile patch 的完整行(实时预览):',
107
+ formatJsonHint: '等价的 mcpServers JSON 写法(可粘贴到其他工具):',
108
+ pasteTransportMismatch: '该行的传输方式已锁定,与粘贴配置的类型不一致。',
109
+ marketTitle: '市场',
110
+ marketIntro: '精选的技能仓库与 MCP 服务器,一键安装。列表来自插件仓库的在线索引,离线时回退到包内快照。',
111
+ marketSkills: '技能市场',
112
+ marketMcp: 'MCP 市场',
113
+ marketSkillsIntro: '安装技能仓库(GitHub 下载解包,实时进入扫描目录);已安装的仓库可在「技能」页管理。',
114
+ marketMcpIntro: '添加 MCP 服务器行(与「MCP」页同一存储,添加后需重启生效);需要密钥的服务在安装后于列表中补填环境变量。',
115
+ marketEmpty: '市场列表为空或不可用',
116
+ marketOffline: '在线索引不可达,当前展示包内快照(可能与最新列表有差异)。',
117
+ marketSkillCount: '{n} 个技能',
118
+ marketInstall: '安装',
119
+ marketAdd: '添加',
120
+ marketInstalling: '进行中…',
121
+ marketInstalled: '已安装',
122
+ marketUninstall: '移除',
123
+ marketHome: '主页',
124
+ marketNeedsEnv: '需要环境变量',
125
+ marketRepoInstalled: '仓库已安装,技能稍候出现在「技能」页列表。',
126
+ marketUninstallConfirm: '确认移除?',
127
+ marketUninstallRootWarn: '将取消扫描该仓库并删除下载的副本(移除后可重新安装)。',
73
128
  }
74
129
 
75
130
  export const en = {
76
131
  sectionNav: 'Skills & MCP',
77
132
  skillsTab: 'Skills',
78
133
  mcpTab: 'MCP',
134
+ marketTab: 'Market',
79
135
  skillsTitle: 'Skills',
80
- skillsIntro: 'View and edit the skills dsh discovers; user-level skills (DSH_HOME/skills) can be created, edited, and deleted here — the directory is watched, saves apply without a restart. ~/.claude/skills and ~/.codex/skills are scanned too when present (zero-copy, live-synced).',
136
+ skillsIntro: 'View and edit the skills dsh discovers; user-level skills (DSH_HOME/skills) can be created, edited, and deleted here — the directory is watched, saves apply without a restart. ~/.claude/skills and ~/.codex/skills are scanned too when present (zero-copy, live-synced). The switch on each card toggles loading: disabling removes the skill from both the model and user catalogs; enabling restores the default policy.',
81
137
  newSkill: 'New skill',
82
138
  editSkill: 'Edit skill',
83
139
  viewSkill: 'View skill',
@@ -107,6 +163,35 @@ export const en = {
107
163
  confirmDelete: 'Delete this skill?',
108
164
  deleteWarn: 'Removes the whole skill directory under DSH_HOME/skills. This cannot be undone.',
109
165
  saved: 'Saved. The skills directory is watched; the skill is usable in sessions shortly.',
166
+ searchSkills: 'Search name or description',
167
+ filterAll: 'All',
168
+ noMatch: 'No skills match',
169
+ refresh: 'Refresh',
170
+ skillDisabled: 'disabled',
171
+ skillUserOnly: 'user-only',
172
+ skillModelOnly: 'model-only',
173
+ skillEnabled: 'Enabled — the list reflects it shortly.',
174
+ skillDisabledMsg: 'Disabled: neither the model nor / commands will load it.',
175
+ toggleSkill: 'Toggle skill loading',
176
+ toggleSkillHint: 'Disabling writes disable-model-invocation and user-invocable into the skill\u2019s SKILL.md, removing it from both catalogs; enabling restores the default policy. The rest of the file is untouched.',
177
+ openFolder: 'Open folder',
178
+ openUserSkills: 'Open skills folder',
179
+ rootsTitle: 'Skill repositories',
180
+ rootsIntro: 'Add skill repositories by hand: local folders are scanned in place (zero-copy), GitHub repos arrive as a downloaded copy inside DSH_HOME (no git needed). The Market tab offers curated repos with one click.',
181
+ emptyRoots: 'No custom skill repositories yet',
182
+ addRoot: 'Add repository',
183
+ rootLocal: 'Local',
184
+ rootLocalPlaceholder: 'D:\\path\\to\\skills',
185
+ rootKind: 'Repository type',
186
+ rootPlaceholder: 'Repository address',
187
+ add: 'Add',
188
+ rootAdded: 'Repository added — the catalog follows live.',
189
+ rootRemoved: 'Repository removed.',
190
+ rootStale: 'missing',
191
+ rootGitHint: 'Accepts https://github.com/owner/repo and owner/repo; pick a version with #branch or /tree/branch.',
192
+ rootLocalHint: 'Point at one skill folder (with SKILL.md) or a folder of skill folders.',
193
+ confirmRemoveRoot: 'Remove this repository?',
194
+ removeRootWarn: 'Stops scanning the repository and deletes the copy downloaded into DSH_HOME (a local folder itself is untouched).',
110
195
  mcpTitle: 'MCP servers',
111
196
  mcpIntro: 'Manage the MCP server rows (@deepseek-ai/dsh-mcp-client) in this profile. Additions, edits, and removals take effect after a dsh restart.',
112
197
  addServer: 'Add server',
@@ -142,4 +227,29 @@ export const en = {
142
227
  restarting: 'Restarting — the page will reload once the host is back…',
143
228
  restartPortHint: 'If the page does not recover, the port may have changed: check the terminal for the new URL.',
144
229
  failed: 'Operation failed',
230
+ formatTitle: 'Full format & quick fill',
231
+ formatPaste: 'Paste a JSON config (mcpServers wrapper or a bare entry)',
232
+ formatFill: 'Parse and fill the form',
233
+ formatYamlHint: 'The complete row as it will land in the profile patch (live preview):',
234
+ formatJsonHint: 'Equivalent mcpServers JSON (paste-ready for other tools):',
235
+ pasteTransportMismatch: 'This row\u2019s transport is locked and differs from the pasted config.',
236
+ marketTitle: 'Market',
237
+ marketIntro: 'Curated skill repositories and MCP servers, installable with one click. The list comes from the plugin repository\u2019s online index, falling back to the bundled snapshot offline.',
238
+ marketSkills: 'Skills market',
239
+ marketMcp: 'MCP market',
240
+ marketSkillsIntro: 'Installs skill repositories (GitHub download, live catalog); manage installed ones on the Skills tab.',
241
+ marketMcpIntro: 'Adds MCP server rows (same storage as the MCP tab; restart to apply). Servers needing keys get their environment filled in on the row after install.',
242
+ marketEmpty: 'Market list is empty or unavailable',
243
+ marketOffline: 'Online index unreachable — showing the bundled snapshot, which may lag behind.',
244
+ marketSkillCount: '{n} skills',
245
+ marketInstall: 'Install',
246
+ marketAdd: 'Add',
247
+ marketInstalling: 'Working…',
248
+ marketInstalled: 'installed',
249
+ marketUninstall: 'Uninstall',
250
+ marketHome: 'Homepage',
251
+ marketNeedsEnv: 'Needs environment variables',
252
+ marketRepoInstalled: 'Repository installed — its skills appear on the Skills tab shortly.',
253
+ marketUninstallConfirm: 'Uninstall?',
254
+ marketUninstallRootWarn: 'Stops scanning the repository and deletes the downloaded copy (you can reinstall later).',
145
255
  }
package/src/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /** dsh-plugin-capabilities host entry: mount the manager's HTTP routes once
2
2
  * the profile composes both the web server and the skill registry, and mount
3
3
  * a host-plane filesystem skill provider so the Settings page sees a live
4
- * catalog (the web composition deliberately leaves the host row to presets). */
4
+ * catalog (the web composition deliberately leaves the host row to presets).
5
+ * The provider's custom roots also take the user-registered skill
6
+ * repositories from the plugin state file, and remount whenever those
7
+ * change — the catalog follows without a dsh restart. */
5
8
 
6
9
  import type { Context } from '@deepseek-ai/cordis'
7
10
  import { existsSync } from 'node:fs'
@@ -10,6 +13,7 @@ import { fileURLToPath } from 'node:url'
10
13
  import { agentSkillRoots } from './agents.ts'
11
14
  import { argvProfile, profileDir } from './profile.ts'
12
15
  import { mountCapabilitiesRoutes } from './routes.ts'
16
+ import { loadState } from './state.ts'
13
17
  import type { CapabilitiesHost } from './types.ts'
14
18
 
15
19
  export const name = 'dsh-plugin-capabilities'
@@ -38,6 +42,11 @@ interface FilesystemSkillPlugin {
38
42
  apply(context: Context, config?: unknown): void
39
43
  }
40
44
 
45
+ /** The disposable fiber `ctx.plugin()` returns, as far as we use it. */
46
+ interface PluginFiber {
47
+ dispose(): Promise<void>
48
+ }
49
+
41
50
  export function apply(ctx: Context, config?: Config): void {
42
51
  const profile = config?.profile ?? argvProfile() ?? 'web'
43
52
  ctx.inject(['webServer', 'skills'], (hostCtx: Context) => {
@@ -48,25 +57,56 @@ export function apply(ctx: Context, config?: Config): void {
48
57
  // providers are exactly what that layer is for — agents read the merged
49
58
  // catalog), and preset layers keep their semantics (nearest layer still
50
59
  // wins duplicate names). Custom roots, in scan order: the package's own
51
- // vendored skills (read-only, update with the plugin), then other agents'
52
- // skill roots (~/.claude/skills, ~/.codex/skills) zero-copy, live-synced
53
- // both ways. A failed load only means an empty catalog — the routes keep
54
- // serving.
55
- void (async () => {
56
- try {
57
- const mod = (await import('@deepseek-ai/dsh-skill-filesystem')) as unknown as
58
- (FilesystemSkillPlugin & { default?: FilesystemSkillPlugin })
59
- const plugin = mod.default ?? mod
60
- const roots = [packagedSkillsDir(), ...agentSkillRoots()].filter(dir => existsSync(dir))
61
- hostCtx.plugin(plugin, roots.length > 0 ? { customSkillDirs: roots } : {})
62
- } catch {
63
- // Unresolvable provider: skills list stays empty; MCP tab unaffected.
60
+ // vendored skills (read-only, update with the plugin), the user's
61
+ // registered repositories (local paths and GitHub checkouts, managed
62
+ // from the Settings page), then other agents' skill roots
63
+ // (~/.claude/skills, ~/.codex/skills) — zero-copy, live-synced both
64
+ // ways. Registering or removing a repository remounts the provider with
65
+ // the new root set; a failed load only means an empty catalog — the
66
+ // routes keep serving.
67
+ let providerFiber: PluginFiber | undefined
68
+ let disposed = false
69
+ ctx.effect(() => {
70
+ const disposer = (): void => { disposed = true }
71
+ let chain: Promise<void> = Promise.resolve()
72
+ const remountProvider = (): Promise<void> => {
73
+ // Serialize remounts; concurrent add/remove requests must not
74
+ // interleave dispose and re-plugin on the same provider.
75
+ chain = chain.then(async () => {
76
+ if (disposed) return
77
+ const mod = (await import('@deepseek-ai/dsh-skill-filesystem')) as unknown as
78
+ (FilesystemSkillPlugin & { default?: FilesystemSkillPlugin })
79
+ const plugin = mod.default ?? mod
80
+ if (providerFiber !== undefined) {
81
+ const old = providerFiber
82
+ providerFiber = undefined
83
+ try { await old.dispose() } catch { /* unloading raced us */ }
84
+ }
85
+ if (disposed) return
86
+ const roots = [
87
+ packagedSkillsDir(),
88
+ ...loadState().skillRoots.flatMap(entry => entry.roots),
89
+ ...agentSkillRoots(),
90
+ ].filter(dir => existsSync(dir))
91
+ try {
92
+ providerFiber = hostCtx.plugin(plugin, roots.length > 0 ? { customSkillDirs: roots } : {}) as PluginFiber
93
+ } catch {
94
+ // Context already disposed: nothing left to mount for.
95
+ }
96
+ })
97
+ chain = chain.catch(() => undefined)
98
+ return chain
64
99
  }
65
- })()
100
+ void remountProvider()
66
101
 
67
- ctx.effect(
68
- () => mountCapabilitiesRoutes(hostCtx as unknown as CapabilitiesHost, { profileDirPath: profileDir(profile) }),
69
- 'dsh-plugin-capabilities: http routes',
70
- )
102
+ ctx.effect(
103
+ () => mountCapabilitiesRoutes(hostCtx as unknown as CapabilitiesHost, {
104
+ profileDirPath: profileDir(profile),
105
+ remountProvider,
106
+ }),
107
+ 'dsh-plugin-capabilities: http routes',
108
+ )
109
+ return disposer
110
+ }, 'dsh-plugin-capabilities: skill provider')
71
111
  })
72
112
  }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { bundledMarketPath, loadMarketIndex, marketIndexUrl } from './market.ts'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const okJson = (body: unknown): typeof fetch =>
6
+ (async () => ({ ok: true, json: async () => body }) as unknown as Response) as unknown as typeof fetch
7
+
8
+ describe('loadMarketIndex', () => {
9
+ it('prefers a valid remote index', async () => {
10
+ const index = await loadMarketIndex('skills', {
11
+ fetcher: okJson({ repos: [{ id: 'r1', name: 'Repo', description: 'd', url: 'https://github.com/a/b' }] }),
12
+ })
13
+ expect(index?.source).toBe('remote')
14
+ expect(index?.skills?.[0]?.id).toBe('r1')
15
+ })
16
+
17
+ it('falls back to the bundled snapshot when remote is unreachable or invalid', async () => {
18
+ const failing = (async () => { throw new Error('offline') }) as unknown as typeof fetch
19
+ const index = await loadMarketIndex('skills', { fetcher: failing })
20
+ expect(index?.source).toBe('bundled')
21
+ expect(index?.skills?.length).toBeGreaterThan(0)
22
+ expect(index?.skills?.every(repo => typeof repo.url === 'string')).toBe(true)
23
+
24
+ const garbage = await loadMarketIndex('skills', { fetcher: okJson({ repos: [] }) })
25
+ expect(garbage?.source).toBe('bundled')
26
+ })
27
+
28
+ it('validates entries and drops malformed ones', async () => {
29
+ const index = await loadMarketIndex('mcp', {
30
+ fetcher: okJson({
31
+ servers: [
32
+ { id: 'ok', name: 'OK', description: 'd', transport: 'stdio', command: 'npx', args: ['-y', 'x'], envKeys: ['K'], homepage: 'https://x' },
33
+ { id: 'no-command', name: 'Bad', description: 'd', transport: 'stdio', homepage: 'https://x' },
34
+ { id: 'no-home', name: 'Bad2', description: 'd', transport: 'stdio', command: 'npx' },
35
+ 'not an object',
36
+ ],
37
+ }),
38
+ })
39
+ expect(index?.source).toBe('remote')
40
+ expect(index?.servers).toHaveLength(1)
41
+ expect(index?.servers?.[0]).toMatchObject({ id: 'ok', envKeys: ['K'] })
42
+ })
43
+
44
+ it('ships parseable bundled snapshots with expected top-level shape', () => {
45
+ for (const kind of ['skills', 'mcp'] as const) {
46
+ const parsed = JSON.parse(readFileSync(bundledMarketPath(kind), 'utf8')) as Record<string, unknown>
47
+ expect(parsed.version).toBe(1)
48
+ expect(Array.isArray(kind === 'skills' ? parsed.repos : parsed.servers)).toBe(true)
49
+ expect(marketIndexUrl(kind)).toContain(`https://raw.githubusercontent.com/qinyre/dsh-plugin-capabilities/main/market/${kind}.json`)
50
+ }
51
+ })
52
+
53
+ it('marks nothing installed and returns null entries-safe data offline for mcp too', async () => {
54
+ 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
+ expect(index?.servers?.[0]).toMatchObject({ transport: 'streamable-http', url: 'https://e/mcp' })
56
+ })
57
+ })
package/src/market.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Market indexes for the Settings “市场” tab: a remote JSON list per kind
3
+ * (skills repositories, MCP servers) fetched from the plugin's GitHub repo,
4
+ * with the copy bundled inside the package as an offline fallback. Entries
5
+ * are validated before they reach the browser; a bad remote index degrades
6
+ * to the bundled one instead of erroring the page.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ /** Which catalog a request wants. */
14
+ export type MarketKind = 'skills' | 'mcp'
15
+
16
+ /** One installable skill repository in the skills index. */
17
+ export interface MarketSkillRepo {
18
+ id: string
19
+ name: string
20
+ nameZh?: string
21
+ description: string
22
+ descriptionZh?: string
23
+ url: string
24
+ homepage?: string
25
+ skillCount?: number
26
+ }
27
+
28
+ /** One installable server in the MCP index. */
29
+ export interface MarketMcpServer {
30
+ id: string
31
+ name: string
32
+ nameZh?: string
33
+ description: string
34
+ descriptionZh?: string
35
+ transport: 'stdio' | 'streamable-http'
36
+ command?: string
37
+ args?: string[]
38
+ envKeys?: string[]
39
+ url?: string
40
+ homepage: string
41
+ category?: string
42
+ /** Runtime hint shown next to stdio commands (npx / uvx). */
43
+ runtime?: string
44
+ }
45
+
46
+ export interface MarketIndex {
47
+ source: 'remote' | 'bundled'
48
+ skills?: MarketSkillRepo[]
49
+ servers?: MarketMcpServer[]
50
+ }
51
+
52
+ /** Remote source of truth, one file per kind under the plugin repo. */
53
+ export function marketIndexUrl(kind: MarketKind): string {
54
+ return `https://raw.githubusercontent.com/qinyre/dsh-plugin-capabilities/main/market/${kind}.json`
55
+ }
56
+
57
+ /** The snapshot shipped inside the package (src/../market in tests, lib/../market installed). */
58
+ export function bundledMarketPath(kind: MarketKind): string {
59
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'market', `${kind}.json`)
60
+ }
61
+
62
+ const isString = (value: unknown): value is string => typeof value === 'string' && value !== ''
63
+ const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString)
64
+
65
+ function parseSkillsIndex(parsed: unknown): MarketSkillRepo[] | null {
66
+ if (typeof parsed !== 'object' || parsed === null) return null
67
+ const repos = (parsed as { repos?: unknown }).repos
68
+ if (!Array.isArray(repos)) return null
69
+ const out: MarketSkillRepo[] = []
70
+ for (const entry of repos) {
71
+ if (typeof entry !== 'object' || entry === null) continue
72
+ const record = entry as Record<string, unknown>
73
+ if (!isString(record.id) || !isString(record.name) || !isString(record.description) || !isString(record.url)) continue
74
+ out.push({
75
+ id: record.id,
76
+ name: record.name,
77
+ ...isString(record.nameZh) ? { nameZh: record.nameZh } : {},
78
+ description: record.description,
79
+ ...isString(record.descriptionZh) ? { descriptionZh: record.descriptionZh } : {},
80
+ url: record.url,
81
+ ...isString(record.homepage) ? { homepage: record.homepage } : {},
82
+ ...(typeof record.skillCount === 'number' ? { skillCount: record.skillCount } : {}),
83
+ })
84
+ }
85
+ return out.length > 0 ? out : null
86
+ }
87
+
88
+ function parseMcpIndex(parsed: unknown): MarketMcpServer[] | null {
89
+ if (typeof parsed !== 'object' || parsed === null) return null
90
+ const servers = (parsed as { servers?: unknown }).servers
91
+ if (!Array.isArray(servers)) return null
92
+ const out: MarketMcpServer[] = []
93
+ for (const entry of servers) {
94
+ if (typeof entry !== 'object' || entry === null) continue
95
+ const record = entry as Record<string, unknown>
96
+ if (!isString(record.id) || !isString(record.name) || !isString(record.description) || !isString(record.homepage)) continue
97
+ const transport = record.transport === 'streamable-http' ? 'streamable-http' : 'stdio'
98
+ if (transport === 'stdio' && !isString(record.command)) continue
99
+ if (transport === 'streamable-http' && !isString(record.url)) continue
100
+ out.push({
101
+ id: record.id,
102
+ name: record.name,
103
+ ...isString(record.nameZh) ? { nameZh: record.nameZh } : {},
104
+ description: record.description,
105
+ ...isString(record.descriptionZh) ? { descriptionZh: record.descriptionZh } : {},
106
+ transport,
107
+ ...(isString(record.command) ? { command: record.command } : {}),
108
+ ...(isStringArray(record.args) ? { args: record.args } : {}),
109
+ ...(isStringArray(record.envKeys) ? { envKeys: record.envKeys } : {}),
110
+ ...(isString(record.url) ? { url: record.url } : {}),
111
+ homepage: record.homepage,
112
+ ...isString(record.category) ? { category: record.category } : {},
113
+ ...isString(record.runtime) ? { runtime: record.runtime } : {},
114
+ })
115
+ }
116
+ return out.length > 0 ? out : null
117
+ }
118
+
119
+ export interface LoadMarketOptions {
120
+ fetcher?: typeof fetch
121
+ /** Remote fetch timeout; the bundled fallback has none. */
122
+ timeoutMs?: number
123
+ dshHome?: string
124
+ }
125
+
126
+ /** Load one market index: remote first, bundled snapshot as fallback. */
127
+ export async function loadMarketIndex(kind: MarketKind, options: LoadMarketOptions = {}): Promise<MarketIndex | null> {
128
+ const doFetch = options.fetcher ?? fetch
129
+ if (kind === 'skills') {
130
+ const remote = await fetchIndex(doFetch, 'skills', parseSkillsIndex, options)
131
+ if (remote !== null) return { source: 'remote', skills: remote }
132
+ } else {
133
+ const remote = await fetchIndex(doFetch, 'mcp', parseMcpIndex, options)
134
+ if (remote !== null) return { source: 'remote', servers: remote }
135
+ }
136
+ const bundled = bundledMarketPath(kind)
137
+ if (!existsSync(bundled)) return null
138
+ try {
139
+ const parsed: unknown = JSON.parse(readFileSync(bundled, 'utf8'))
140
+ if (kind === 'skills') {
141
+ const skills = parseSkillsIndex(parsed)
142
+ return skills === null ? null : { source: 'bundled', skills }
143
+ }
144
+ const servers = parseMcpIndex(parsed)
145
+ return servers === null ? null : { source: 'bundled', servers }
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ /** Fetch and validate one remote index; null on any failure. */
152
+ async function fetchIndex<T>(
153
+ doFetch: typeof fetch,
154
+ kind: MarketKind,
155
+ parse: (parsed: unknown) => T[] | null,
156
+ options: LoadMarketOptions,
157
+ ): Promise<T[] | null> {
158
+ try {
159
+ const response = await doFetch(marketIndexUrl(kind), { signal: AbortSignal.timeout(options.timeoutMs ?? 8000) })
160
+ if (!response.ok) return null
161
+ return parse(await response.json())
162
+ } catch {
163
+ // Network unavailable or slow: the bundled snapshot takes over.
164
+ return null
165
+ }
166
+ }
package/src/opener.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Open a directory in the OS file manager, from the dsh sidecar process.
3
+ * Spawned detached with all stdio ignored — a GUI file manager needs no
4
+ * pipes, and explorer.exe reports failure through exit codes we must not
5
+ * await (it exits non-zero even on success in several Windows versions).
6
+ */
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import { statSync } from 'node:fs'
10
+
11
+ /** Open one directory; resolves true when a launcher was started. */
12
+ export function openDirectory(dir: string): boolean {
13
+ try {
14
+ if (!statSync(dir).isDirectory()) return false
15
+ } catch {
16
+ return false
17
+ }
18
+ const launcher = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open'
19
+ try {
20
+ const child = spawn(launcher, [dir], { detached: true, stdio: 'ignore', windowsHide: true })
21
+ child.unref()
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }