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.
- package/README.md +14 -2
- package/lib/client.js +936 -220
- package/lib/client.js.map +4 -4
- package/lib/index.js +807 -41
- package/lib/index.js.map +4 -4
- package/market/mcp.json +164 -0
- package/market/skills.json +36 -0
- package/package.json +2 -1
- 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 +59 -19
- package/src/market.test.ts +57 -0
- package/src/market.ts +166 -0
- package/src/opener.ts +26 -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 +53 -1
- 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
package/src/client/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** dsh-plugin-capabilities client entry: contributes the top-level
|
|
2
|
-
* “技能与 MCP” section into Settings (beside 通用设置/模型, with Skills
|
|
3
|
-
*
|
|
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
|
|
98
|
-
//
|
|
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
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -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),
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
}
|