@omdp/dsh-connector 0.1.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 ADDED
@@ -0,0 +1,106 @@
1
+ # @omdp/dsh-connector
2
+
3
+ 一个 DeepSeek Harness (`dsh`) 插件,把 **MCP 服务器** 和 **用户 Skills** 的管理合并到 Web UI 的同一个设置页里(设置页标签:**Connector**)。
4
+
5
+ - **MCP**:读取/编辑 `profiles/web/cordis.patch.yml` 中的 `mcp-*` 块(结构化表单)。保存后**重启 `dsh` 生效**。
6
+ - **Skills**:列出/查看/编辑/删除 `~/.dsh/skills` 下的 `SKILL.md`。保存**即时生效**(filesystem provider 自动重新发现)。
7
+
8
+ 设计上复用官方两款参考插件的方式:
9
+ - 设置页槽位注册方式参照 [`dsh-mcp-manager`](https://github.com/hyqhyq3/dsh-mcp-manager)(`settings.section` + Package 私有 HTTP API)。
10
+ - Skills 的 frontmatter 解析/序列化参照 [`dsh-skill-manager`](https://github.com/bitterSmilezzz/dsh-skill-manager)。
11
+
12
+ ### SSE(MCP over SSE) 如何处理
13
+
14
+ 本插件**不**内置 SSE 桥接。需要连接走 legacy SSE 协议的 MCP 服务器(如知乎搜索 / 全网搜索)时,仍在 `cordis.patch.yml` 里用 [`mcp-remote`](https://github.com/geelen/mcp-remote) 把 SSE 转成 stdio,本插件只是把它作为一条普通 mcp-remote 配置来可视化编辑。这样避免重造进程管理逻辑——连接本身交给成熟的 mcp-remote。
15
+
16
+ ## 安装
17
+
18
+ **推荐:本地 `link:` 安装**(避免从 GitHub 直接拉取的网络/TLS 问题)。在
19
+ `profiles/web/package.json` 的 `dependencies` 里加入(或直接编辑):
20
+
21
+ ```json
22
+ "@omdp/dsh-connector": "link:D:/WorkSpace/omdp/dsh-connector"
23
+ ```
24
+
25
+ 然后在该 profile 下重建 lockfile 并建立 junction(`dsh plugin add` 底层就是 pnpm,
26
+ 等价于):
27
+
28
+ ```sh
29
+ cd ~/.dsh/profiles/web
30
+ pnpm install --lockfile-only --offline # 按 link 依赖重写 lockfile
31
+ ```
32
+
33
+ > `pnpm install` 会为 `link:` 依赖建立 `node_modules/@omdp/dsh-connector` junction
34
+ > 指向 `D:/WorkSpace/omdp/dsh-connector`,插件源码即仓库源码,**改仓库 → 重启 dsh 即生效**。
35
+
36
+ 确保 `dsh.profile.bundles` 里包含 `"@omdp/dsh-connector"`(包内声明了
37
+ `dsh.bundle.patch`,激活行自动生效,无需手动改 `cordis.patch.yml`)。
38
+
39
+ > 安装前请先**备份** `profiles/web/cordis.patch.yml`。本插件会改写其中的 MCP 块。
40
+
41
+ ### 备选:从 GitHub 远程安装
42
+
43
+ 不想本地 checkout 时,可直接从仓库装(`#path:` 指向子目录):
44
+
45
+ ```sh
46
+ dsh plugin --profile web add github:XJungit/omdp#path:dsh-connector
47
+ ```
48
+
49
+ pnpm ≥10 默认拒绝运行 git 依赖的构建脚本,首次 `add` 会失败,需在
50
+ `profiles/web/pnpm-workspace.yaml` 加白名单后重试:
51
+
52
+ ```yaml
53
+ allowBuilds:
54
+ '@omdp/dsh-connector': true
55
+ ```
56
+
57
+ (本插件是纯 JS 零构建,白名单是唯一门槛,无需 `prepare` 脚本。详见官方
58
+ [publish.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md)。)
59
+
60
+ ## 更新
61
+
62
+ 本地 link 模式下**没有"拉取"这一步**:直接 `git pull` 或编辑 `D:/WorkSpace/omdp`,
63
+ 然后**重启 `dsh --profile web`** 加载新代码(运行中的进程仍用旧代码)。
64
+
65
+ ## 使用
66
+
67
+ 打开 Web UI 的 **设置 → Connector**:
68
+
69
+ 1. **MCP 服务器** 区:
70
+ - 列出当前 `cordis.patch.yml` 里的 `mcp-*` 服务器
71
+ - 「编辑」改名称/传输/URL/命令/参数/Header;「删除」移除;「+ 添加」新建
72
+ - 保存后提示**重启 dsh** 才会真正加载新的 MCP server
73
+ 2. **Skills** 区:
74
+ - 列出 `~/.dsh/skills` 下的用户技能
75
+ - 「编辑」改 frontmatter 与正文;「删除」移除目录;「+ 新建」创建
76
+
77
+ ## 工作原理
78
+
79
+ | 组成 | 机制 |
80
+ |---|---|
81
+ | 设置页 | client half 注册 `settings.section` 槽位("Connector" 页签) |
82
+ | 跨边界调用 | client 用 `fetch('/connector/api/...')`,host 用 `ctx.webServer.register` 接收(安装包走 HTTP) |
83
+ | MCP 持久化 | 文本块级提取并替换 `cordis.patch.yml` 中含 `mcp-` 的 insert 块,**保留 `!!js` 表达式与 env 块原样**(preserve 桶) |
84
+ | Skill 持久化 | 直接读写 `~/.dsh/skills/<name>/SKILL.md` |
85
+
86
+ ## 已知限制
87
+
88
+ - MCP 改动需**重启 dsh** 才生效(因为 `dsh-mcp-client` 实例是静态加载的)。若想要保存即时生效,需用 `dsh-mcp-manager`(它自行实现 MCP client)。
89
+ - 保存时按 `dsh-mcp-client` 的契约**校验**:`transport` 只能是 `stdio`/`streamable-http`;`serverName` 必须匹配 `[A-Za-z0-9_-]{1,32}`;stdio 的 `command` 必须是单个词且能在 PATH 中找到(或为绝对路径);streamable-http 的 `url` 必须是合法 http(s)(`!!js` 表达式除外);命令/URL/参数中不允许控制字符。任何一项不合法,保存会被拒绝(HTTP 400)并提示原因,**不会写入** `cordis.patch.yml`——坏配置永远到不了下次启动。
90
+ - MCP 块解析为结构化提取,复杂嵌套 YAML(如多 env 变量)在表单里以单字段呈现;极复杂配置请直接在 `cordis.patch.yml` 编辑。
91
+ - 不桥接 MCP 的 resources/prompts,只管理 server 配置。
92
+
93
+ ## 安全实践
94
+
95
+ - **不要在 `cordis.patch.yml` 里写明文 token**。MCP server 需要密钥时,用环境变量引用(`!!js process.env.XXX`),例如:
96
+ ```yaml
97
+ env:
98
+ AUTH_HEADER: !!js ('Bearer ' + process.env.ZHIHU_TOKEN)
99
+ ```
100
+ token 明文只存在于 `.env` / 系统环境变量,不落进配置文件(同 `dsh-mcp-manager` 的 `tokenEnv` 理念)。
101
+ - 本插件的 API(`/connector/api/*`)与 DSH GUI 同源,无额外鉴权——仅限本机使用,不要暴露到公网。
102
+ - Skills 内容与 MCP 配置都属于本地敏感数据,改动会直接写入磁盘。
103
+
104
+ ## License
105
+
106
+ MIT
package/client.js ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * @omdp/dsh-connector — client half.
3
+ *
4
+ * Installed-package client bundle: the harness serves this file at
5
+ * /plugins/<id>/client.js and expects a single window.__ModuleLoader__.load
6
+ * handoff whose factory receives `require`. We register one unified settings
7
+ * tab ("Connector") that manages two things in a single page:
8
+ * - MCP servers: read/edit the mcp-* block of cordis.patch.yml
9
+ * - user skills: list/view/edit/remove SKILL.md files under ~/.dsh/skills
10
+ *
11
+ * It talks to the host half over the Package-private HTTP API the host mounts
12
+ * on the DSH GUI webserver (/connector/api/*), exactly like
13
+ * dsh-mcp-manager's client does with fetch — not the dynamic-only host.call.
14
+ *
15
+ * @module @omdp/dsh-connector/client
16
+ */
17
+
18
+ window.__ModuleLoader__.load({
19
+ id: '@omdp/dsh-connector',
20
+ factory: (require) => {
21
+ var module = { exports: {} }
22
+ var exports = module.exports
23
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
24
+ var react = require('react')
25
+ var createElement = react.createElement
26
+
27
+ var css =
28
+ '.pm_root{display:flex;flex-direction:column;gap:20px;padding:0 24px 24px}' +
29
+ '.pm_tabs{display:flex;gap:8px;padding:0 24px}' +
30
+ '.pm_section{display:flex;flex-direction:column;gap:12px}' +
31
+ '.pm_sectionHead{font-weight:600;font-size:15px}' +
32
+ '.pm_row{display:flex;flex-direction:column;gap:8px;border:1px solid rgba(128,128,128,.3);border-radius:12px;padding:12px 14px}' +
33
+ '.pm_rowHead{display:flex;align-items:center;gap:10px}' +
34
+ '.pm_name{font-weight:600;font-size:14px}' +
35
+ '.pm_kind{font-size:11px;padding:2px 8px;border-radius:8px;border:1px solid rgba(128,128,128,.4);color:var(--dsw-alias-label-secondary,#888)}' +
36
+ '.pm_meta{font-size:12px;color:var(--dsw-alias-label-secondary,#888);word-break:break-all}' +
37
+ '.pm_actions{margin-left:auto;display:flex;gap:8px}' +
38
+ '.pm_btn{cursor:pointer;font-size:12px;padding:5px 12px;border-radius:8px;border:1px solid rgba(128,128,128,.5);background:transparent;color:inherit}' +
39
+ '.pm_btn:hover{background:rgba(128,128,128,.12)}' +
40
+ '.pm_btn:disabled{opacity:.5;cursor:default}' +
41
+ '.pm_btn.primary{border-color:transparent;background:var(--dsw-alias-interactive-bg-active,#2563eb);color:#fff}' +
42
+ '.pm_btn.danger{border-color:#c62828;color:#c62828}' +
43
+ '.pm_err{color:#c62828;font-size:12px}' +
44
+ '.pm_form{display:grid;grid-template-columns:1fr 1fr;gap:10px}' +
45
+ '.pm_form label{display:flex;flex-direction:column;gap:4px;font-size:12px}' +
46
+ '.pm_form input,.pm_form textarea,.pm_form select{font:inherit;font-size:13px;padding:6px 8px;border-radius:8px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit}' +
47
+ '.pm_form .wide{grid-column:1 / -1}' +
48
+ '.pm_add{border-style:dashed}' +
49
+ '.pm_hint{font-size:12px;color:var(--dsw-alias-label-secondary,#888)}' +
50
+ '.pm_textarea{font-family:ui-monospace,Menlo,Consolas,monospace;min-height:140px}'
51
+
52
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="@omdp/dsh-connector/section"]') === null) {
53
+ var tag = document.createElement('style')
54
+ tag.dataset.plugin = '@omdp/dsh-connector'
55
+ tag.dataset.pluginCss = '@omdp/dsh-connector/section'
56
+ tag.textContent = css
57
+ document.head.appendChild(tag)
58
+ }
59
+
60
+ function api(path, options) {
61
+ return fetch('/connector/api' + path, {
62
+ headers: { 'Content-Type': 'application/json' },
63
+ ...options,
64
+ }).then(function (resp) {
65
+ return resp.json().catch(function () { return {} })
66
+ })
67
+ }
68
+
69
+ // Server ids must stay kebab-case under the fixed `mcp-` prefix: the host
70
+ // parser and dsh-mcp-client both key on it, so the prefix is not optional.
71
+ var MCP_ID_RE = /^mcp-[a-z0-9]+(?:-[a-z0-9]+)*$/
72
+
73
+ function deriveServerId(serverName) {
74
+ var base = (serverName || 'server').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
75
+ return 'mcp-' + (base || 'server')
76
+ }
77
+
78
+ function field(label, value, onChange) {
79
+ return createElement('label', { className: 'wide' },
80
+ label,
81
+ createElement('input', { value: value || '', onChange: function (e) { onChange(e.target.value) } }),
82
+ )
83
+ }
84
+
85
+ function selectField(label, value, opts, onChange) {
86
+ return createElement('label', null,
87
+ label,
88
+ createElement('select', { value: value || opts[0], onChange: function (e) { onChange(e.target.value) } },
89
+ opts.map(function (o) { return createElement('option', { value: o, key: o }, o) }),
90
+ ),
91
+ )
92
+ }
93
+
94
+ function ManageTab() {
95
+ var _a = react.useState('mcp')
96
+ var tab = _a[0]
97
+ var setTab = _a[1]
98
+ var _b = react.useState({ servers: [], exists: true })
99
+ var mcp = _b[0]
100
+ var setMcp = _b[1]
101
+ var _c = react.useState([])
102
+ var skills = _c[0]
103
+ var setSkills = _c[1]
104
+ var _d = react.useState('')
105
+ var error = _d[0]
106
+ var setError = _d[1]
107
+ var _e = react.useState(false)
108
+ var busy = _e[0]
109
+ var setBusy = _e[1]
110
+
111
+ function refresh() {
112
+ setBusy(true)
113
+ setError('')
114
+ Promise.all([api('/mcp', { method: 'GET' }), api('/skills', { method: 'GET' })]).then(function (r) {
115
+ setMcp(r[0])
116
+ setSkills(r[1].skills || [])
117
+ }).catch(function (e) {
118
+ setError(String(e))
119
+ }).finally(function () { setBusy(false) })
120
+ }
121
+ react.useEffect(function () { refresh() }, [])
122
+
123
+ return createElement('div', { className: 'pm_root' },
124
+ createElement('div', { className: 'pm_tabs' },
125
+ createElement('button', { className: 'pm_btn' + (tab === 'mcp' ? ' primary' : ''), onClick: function () { setTab('mcp') } }, 'MCP 服务器 MCP Servers'),
126
+ createElement('button', { className: 'pm_btn' + (tab === 'skills' ? ' primary' : ''), onClick: function () { setTab('skills') } }, 'Skills 技能'),
127
+ ),
128
+ error ? createElement('div', { className: 'pm_err' }, error) : null,
129
+ tab === 'mcp'
130
+ ? createElement(McpPane, { mcp: mcp, busy: busy, onChanged: refresh })
131
+ : createElement(SkillPane, { skills: skills, busy: busy, onChanged: refresh }),
132
+ )
133
+ }
134
+
135
+ function McpPane(props) {
136
+ var _a = react.useState(null)
137
+ var editing = _a[0]
138
+ var setEditing = _a[1]
139
+ var _b = react.useState('')
140
+ var err = _b[0]
141
+ var setErr = _b[1]
142
+
143
+ function save(server) {
144
+ api('/mcp', {
145
+ method: 'POST',
146
+ body: JSON.stringify({ servers: props.mcp.servers.map(function (s) { return s.id === server.id ? server : s }).concat(props.mcp.servers.some(function (s) { return s.id === server.id }) ? [] : [server]) }),
147
+ }).then(function (r) {
148
+ if (r && r.error) { setErr(r.error); return }
149
+ setErr('')
150
+ setEditing(null)
151
+ props.onChanged()
152
+ })
153
+ }
154
+ function remove(id) {
155
+ api('/mcp', {
156
+ method: 'POST',
157
+ body: JSON.stringify({ servers: props.mcp.servers.filter(function (s) { return s.id !== id }) }),
158
+ }).then(function (r) {
159
+ if (r && r.error) { setErr(r.error); return }
160
+ setErr('')
161
+ props.onChanged()
162
+ })
163
+ }
164
+
165
+ var rows = props.mcp.servers.map(function (s) {
166
+ return createElement('div', { className: 'pm_row', key: s.id },
167
+ createElement('div', { className: 'pm_rowHead' },
168
+ createElement('span', { className: 'pm_name' }, s.name || s.id),
169
+ createElement('span', { className: 'pm_kind' }, s.transport || 'stdio'),
170
+ ),
171
+ createElement('div', { className: 'pm_meta' }, s.url ? 'url: ' + s.url : (s.command ? 'cmd: ' + s.command : '')),
172
+ createElement('div', { className: 'pm_actions' },
173
+ createElement('button', { className: 'pm_btn', onClick: function () { setEditing(s) } }, '编辑 Edit'),
174
+ createElement('button', { className: 'pm_btn danger', onClick: function () { remove(s.id) } }, '删除 Delete'),
175
+ ),
176
+ )
177
+ })
178
+
179
+ return createElement('div', { className: 'pm_section' },
180
+ createElement('div', { className: 'pm_sectionHead' }, 'MCP 服务器 MCP Servers'),
181
+ err ? createElement('div', { className: 'pm_err' }, err) : null,
182
+ createElement('div', { className: 'pm_hint' },
183
+ props.mcp.exists ? '编辑 profiles/web/cordis.patch.yml 中的 mcp-* 块。保存后重启 dsh 生效。Edit the mcp-* block in cordis.patch.yml; restart dsh to apply.' : '未找到 cordis.patch.yml。cordis.patch.yml not found.'),
184
+ rows,
185
+ editing === null
186
+ ? createElement('button', { className: 'pm_btn pm_add', onClick: function () { setEditing({ id: 'mcp-new', customId: '', name: '', transport: 'stdio', serverName: '', url: '', command: '', args: '' }) } }, '+ 添加 MCP 服务器 Add MCP Server')
187
+ : createElement(ServerForm, { value: editing, onSave: save, onCancel: function () { setEditing(null) } }),
188
+ )
189
+ }
190
+
191
+ function ServerForm(props) {
192
+ // Normalize a block-sequence `args` (parsed into argsLines[]) into the
193
+ // space-separated string the form edits, and drop argsLines so a save
194
+ // always goes through the `args` field (renderServer re-emits it as an
195
+ // inline array). Without this, editing a server whose args were written
196
+ // as a YAML block sequence shows an empty Args box, and clearing the
197
+ // field can't remove the old argsLines.
198
+ function initForm(value) {
199
+ var a = value.args || ''
200
+ if (!a && value.argsLines && value.argsLines.length) {
201
+ a = value.argsLines.join(' ')
202
+ }
203
+ var next = Object.assign({}, value, { args: a })
204
+ delete next.argsLines
205
+ return next
206
+ }
207
+ var _a = react.useState(initForm(props.value))
208
+ var v = _a[0]
209
+ var setV = _a[1]
210
+ var _b = react.useState('')
211
+ var err = _b[0]
212
+ var setErr = _b[1]
213
+ function set(key) { return function (val) { var next = Object.assign({}, v); next[key] = val; setV(next) } }
214
+
215
+ var isNew = v.id === 'mcp-new'
216
+ var derivedId = deriveServerId(v.serverName || v.name)
217
+ var idValue = isNew ? (v.customId !== undefined && v.customId !== '' ? v.customId : derivedId) : v.id
218
+
219
+ // dsh-mcp-client's own serverName contract; the harness rejects anything
220
+ // else at boot, so it must be rejected here.
221
+ var SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/
222
+ var CONTROL_CHARS = /[\x00-\x1f\x7f]/
223
+ var TRANSPORTS = { stdio: true, 'streamable-http': true }
224
+
225
+ function looksLikeExpression(value) {
226
+ var text = (value || '').trim()
227
+ return text.indexOf('!!js') === 0 || text.indexOf('process.env') !== -1 || text.indexOf('&&') !== -1 || text.indexOf('(') === 0
228
+ }
229
+
230
+ function submit() {
231
+ var problems = []
232
+ var t = v.transport || 'stdio'
233
+ var name = (v.serverName || '').trim()
234
+ if (!name) problems.push('serverName 必填 Name is required')
235
+ else if (!SERVER_NAME_RE.test(name)) problems.push('serverName 必须为 1-32 位 [A-Za-z0-9_-] Name must match [A-Za-z0-9_-]{1,32}')
236
+ if (!TRANSPORTS[t]) problems.push('未知传输类型 Unknown transport')
237
+ if (t === 'stdio') {
238
+ var command = (v.command || '').trim()
239
+ if (!command) problems.push('stdio 传输必须填 Command Command is required for stdio')
240
+ else if (/\s|['"]/.test(command)) problems.push('Command 必须是单个词,不能有空格或引号 Command must be a single token (no spaces/quotes)')
241
+ else if (CONTROL_CHARS.test(command)) problems.push('Command 不能含控制字符 Command must not contain control characters')
242
+ var argsText = (v.args || '').trim()
243
+ if (argsText && CONTROL_CHARS.test(argsText)) problems.push('Args 不能含控制字符 Args must not contain control characters')
244
+ }
245
+ if (t === 'streamable-http') {
246
+ var url = (v.url || '').trim()
247
+ if (!url) problems.push('streamable-http 传输必须填 URL URL is required for streamable-http')
248
+ else if (!looksLikeExpression(url)) {
249
+ try {
250
+ var parsed = new URL(url)
251
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') problems.push('URL 必须是 http(s) URL must be http(s)')
252
+ } catch (e) {
253
+ problems.push('URL 无效 Invalid URL')
254
+ }
255
+ }
256
+ }
257
+ if (isNew) {
258
+ var custom = (v.customId || '').trim()
259
+ if (custom && !MCP_ID_RE.test(custom)) problems.push('ID 必须以 mcp- 开头且为小写 kebab-case,如 mcp-github ID must be mcp-<kebab-case>')
260
+ }
261
+ if (problems.length) { setErr(problems.join(';')); return }
262
+ setErr('')
263
+ // Never send a stale argsLines back: renderServer prefers argsLines over
264
+ // args, so a leftover array would clobber the edited/cleared args field.
265
+ var next = Object.assign({}, v)
266
+ delete next.argsLines
267
+ if (isNew) {
268
+ var id = MCP_ID_RE.test(custom) ? custom : derivedId
269
+ props.onSave(Object.assign({}, next, { id: id, name: v.serverName || v.name }))
270
+ } else {
271
+ props.onSave(Object.assign({}, next, { name: v.serverName || v.name }))
272
+ }
273
+ }
274
+
275
+ return createElement('div', { className: 'pm_row' },
276
+ err ? createElement('div', { className: 'pm_err' }, err) : null,
277
+ createElement('div', { className: 'pm_form' },
278
+ field('名称 (serverName) Name', v.serverName, set('serverName')),
279
+ selectField('传输 Transport', v.transport, ['stdio', 'streamable-http'], set('transport')),
280
+ field('URL (http)', v.url, set('url')),
281
+ field('命令 (stdio) Command', v.command, set('command')),
282
+ field('参数 (空格分隔) Args', v.args, set('args')),
283
+ field('Header', v.header, set('header')),
284
+ isNew
285
+ ? createElement('label', { className: 'wide' },
286
+ 'ID (默认自动生成,前缀固定 mcp- 如 mcp-github) ID (auto, fixed mcp- prefix)',
287
+ createElement('input', {
288
+ value: idValue,
289
+ onChange: function (e) { set('customId')(e.target.value) },
290
+ }),
291
+ )
292
+ : createElement('div', { className: 'pm_meta wide' }, 'id: ' + v.id),
293
+ ),
294
+ createElement('div', { className: 'pm_actions' },
295
+ createElement('button', { className: 'pm_btn primary', onClick: submit }, '保存 Save'),
296
+ createElement('button', { className: 'pm_btn', onClick: props.onCancel }, '取消 Cancel'),
297
+ ),
298
+ )
299
+ }
300
+
301
+ function SkillPane(props) {
302
+ var _a = react.useState(null)
303
+ var editing = _a[0]
304
+ var setEditing = _a[1]
305
+ var _b = react.useState('')
306
+ var err = _b[0]
307
+ var setErr = _b[1]
308
+
309
+ function open(name) {
310
+ api('/skills/' + encodeURIComponent(name), { method: 'GET' }).then(function (skill) {
311
+ if (skill && skill.error) { setErr(skill.error); return }
312
+ setErr('')
313
+ setEditing(skill)
314
+ })
315
+ }
316
+ function save(skill) {
317
+ api('/skills', { method: 'PUT', body: JSON.stringify(skill) }).then(function (r) {
318
+ if (r && r.error) { setErr(r.error); return }
319
+ setErr('')
320
+ setEditing(null)
321
+ props.onChanged()
322
+ })
323
+ }
324
+ function remove(name) {
325
+ api('/skills/' + encodeURIComponent(name), { method: 'DELETE' }).then(function (r) {
326
+ if (r && r.error) { setErr(r.error); return }
327
+ setErr('')
328
+ props.onChanged()
329
+ })
330
+ }
331
+
332
+ var rows = props.skills.map(function (s) {
333
+ return createElement('div', { className: 'pm_row', key: s.name },
334
+ createElement('div', { className: 'pm_rowHead' },
335
+ createElement('span', { className: 'pm_name' }, s.name),
336
+ ),
337
+ createElement('div', { className: 'pm_meta' }, s.description || ''),
338
+ createElement('div', { className: 'pm_actions' },
339
+ createElement('button', { className: 'pm_btn', onClick: function () { open(s.name) } }, '编辑 Edit'),
340
+ createElement('button', { className: 'pm_btn danger', onClick: function () { remove(s.name) } }, '删除 Delete'),
341
+ ),
342
+ )
343
+ })
344
+
345
+ return createElement('div', { className: 'pm_section' },
346
+ createElement('div', { className: 'pm_sectionHead' }, '用户 Skills (~/.dsh/skills) User Skills'),
347
+ err ? createElement('div', { className: 'pm_err' }, err) : null,
348
+ createElement('div', { className: 'pm_hint' }, '读写用户技能目录,保存即生效(skill catalog 自动刷新)。Read/write user skills; saved changes take effect immediately.'),
349
+ rows,
350
+ editing === null
351
+ ? createElement('button', { className: 'pm_btn pm_add', onClick: function () { setEditing({ name: '', description: '', content: '' }) } }, '+ 新建 Skill New Skill')
352
+ : createElement(SkillForm, { value: editing, onSave: save, onCancel: function () { setEditing(null) } }),
353
+ )
354
+ }
355
+
356
+ function SkillForm(props) {
357
+ var _a = react.useState(props.value)
358
+ var v = _a[0]
359
+ var setV = _a[1]
360
+ var _b = react.useState('')
361
+ var err = _b[0]
362
+ var setErr = _b[1]
363
+ function set(key) { return function (val) { var next = Object.assign({}, v); next[key] = val; setV(next) } }
364
+
365
+ function submit() {
366
+ var name = (v.name || '').trim()
367
+ if (!name) { setErr('名称必填 Name is required'); return }
368
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { setErr('名称必须是小写 kebab-case,如 my-skill Name must be kebab-case'); return }
369
+ setErr('')
370
+ props.onSave(Object.assign({}, v, { name: name }))
371
+ }
372
+
373
+ return createElement('div', { className: 'pm_row' },
374
+ err ? createElement('div', { className: 'pm_err' }, err) : null,
375
+ createElement('div', { className: 'pm_form' },
376
+ field('名称 (kebab-case) Name', v.name, set('name')),
377
+ field('描述 Description', v.description, set('description')),
378
+ createElement('label', { className: 'wide' },
379
+ 'SKILL.md 内容 (Markdown) Content',
380
+ createElement('textarea', { className: 'pm_textarea wide', value: v.content || '', onChange: function (e) { var next = Object.assign({}, v); next.content = e.target.value; setV(next) } }),
381
+ ),
382
+ ),
383
+ createElement('div', { className: 'pm_actions' },
384
+ createElement('button', { className: 'pm_btn primary', onClick: submit }, '保存 Save'),
385
+ createElement('button', { className: 'pm_btn', onClick: props.onCancel }, '取消 Cancel'),
386
+ ),
387
+ )
388
+ }
389
+
390
+ function apply(ctx) {
391
+ var slots = ctx.get('slots')
392
+ if (slots === undefined) return
393
+ slots.inject('settings.section', function () {
394
+ return slots.register(
395
+ { name: 'settings.section', id: 'connector', order: 55, label: 'Connector 连接器' },
396
+ ManageTab,
397
+ )
398
+ })
399
+ }
400
+
401
+ exports.apply = apply
402
+ return module.exports
403
+ },
404
+ })
@@ -0,0 +1,6 @@
1
+ # @omdp/dsh-connector bundle patch: activates the plugin row in any profile
2
+ # that installs this package (via `dsh plugin --profile <name> add ...`).
3
+ # The user's own cordis.patch.yml may still override or disable this row by id.
4
+ - insert:
5
+ - id: connector
6
+ name: '@omdp/dsh-connector'
package/index.js ADDED
@@ -0,0 +1,654 @@
1
+ /**
2
+ * dsh-connector — host half (installed-package plugin).
3
+ *
4
+ * Manages two deployment assets from one unified Web UI (see client.js):
5
+ *
6
+ * 1. MCP servers — the `mcp-*` entries inside the profile's cordis.patch.yml
7
+ * 2. User skills — the `<dshHome>/skills` directory (~/.dsh/skills)
8
+ *
9
+ * The client half calls this host half over a Package-private HTTP API mounted
10
+ * on the DSH GUI webserver at /connector/api/*. (For an installed package
11
+ * the Client→Host boundary is HTTP, not the dynamic-only host.call.)
12
+ *
13
+ * MCP rewrite safety: cordis.patch.yml contains `!!js` expressions, env blocks
14
+ * and hand-written comments. A strict YAML parser would reject or mangle them.
15
+ * So we locate the INSERT BLOCK that contains `mcp-` ids and replace ONLY the
16
+ * ` - id: mcp-*` server entries inside it, preserving every other line
17
+ * (header comments, the trailing connector row, and each server's env /
18
+ * header / arbitrary nested keys) verbatim through a `preserve` bucket.
19
+ *
20
+ * @module dsh-connector
21
+ */
22
+
23
+ import { readFile, writeFile, mkdir, readdir, rm, rename } from 'node:fs/promises'
24
+ import { existsSync } from 'node:fs'
25
+ import { spawnSync } from 'node:child_process'
26
+ import { homedir } from 'node:os'
27
+ import { join, dirname } from 'node:path'
28
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
29
+
30
+ const API_PREFIX = '/connector/api'
31
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
32
+ // Server ids are keys for the mcp-* block (and dsh-mcp-client instances):
33
+ // kebab-case under a fixed `mcp-` prefix, which parseMcpServers also requires.
34
+ const MCP_ID_RE = /^mcp-[a-z0-9]+(?:-[a-z0-9]+)*$/
35
+ // dsh-mcp-client's own serverName contract (lib/types/index.js SERVER_NAME_PATTERN).
36
+ const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/
37
+ const TRANSPORTS = new Set(['stdio', 'streamable-http'])
38
+ // Anything that would break YAML/JSON transport, a cmd command line, or the
39
+ // patch file itself (NUL, control chars). Tab/newline are already rejected by
40
+ // the single-token rules; this catches the rest defensively.
41
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/
42
+ export const inject = ['webServer']
43
+ export { validateServer, commandResolvable }
44
+
45
+ function resolveHome() {
46
+ const fromEnv = process.env.DSH_HOME
47
+ return fromEnv !== undefined && fromEnv.trim().length > 0
48
+ ? fromEnv.trim()
49
+ : join(homedir(), '.dsh')
50
+ }
51
+
52
+ function patchPath() {
53
+ return join(resolveHome(), 'profiles', 'web', 'cordis.patch.yml')
54
+ }
55
+
56
+ function skillsRoot() {
57
+ return join(resolveHome(), 'skills')
58
+ }
59
+
60
+ /* ───────────────────────── MCP block handling ────────────────────────────
61
+ * The MCP servers live inside one top-level `- insert:` list. We find the
62
+ * insert block that actually contains `mcp-` ids (the first such one), split
63
+ * the file into [head, mcpBlock, tail], and only rewrite server entries inside
64
+ * mcpBlock. head keeps the header comments; tail keeps every later insert
65
+ * (including our own connector row).
66
+ * ────────────────────────────────────────────────────────────────────────── */
67
+
68
+ function findMcpBlock(text) {
69
+ const re = /\n- insert:/g
70
+ let m
71
+ const starts = []
72
+ while ((m = re.exec(text)) !== null) starts.push(m.index)
73
+ for (const start of starts) {
74
+ const blockStart = start + 1 // after the leading \n
75
+ // locate the next insert (or EOF) as block end
76
+ let end = text.length
77
+ re.lastIndex = blockStart
78
+ const next = re.exec(text)
79
+ if (next) end = next.index + 1
80
+ re.lastIndex = 0
81
+ const blockText = text.slice(blockStart, end)
82
+ if (/\n\s*- id:\s*["']?mcp-/.test(blockText)) {
83
+ return { head: text.slice(0, blockStart), blockText, tail: text.slice(end) }
84
+ }
85
+ }
86
+ return null
87
+ }
88
+
89
+ function stripQuotes(v) {
90
+ if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
91
+ return v.slice(1, -1)
92
+ }
93
+ return v
94
+ }
95
+
96
+ // Extract the bare scalar from a YAML block-sequence item line like
97
+ // ` - '--transport'` -> `--transport`. Handles quoted and unquoted values.
98
+ function stripListScalar(line) {
99
+ const m = line.match(/^\s*-\s*(.*)$/)
100
+ if (!m) return line.trim()
101
+ return stripQuotes(m[1].trim())
102
+ }
103
+
104
+ const KNOWN_KEYS = new Set(['transport', 'serverName', 'command', 'header'])
105
+ // `args` is modeled as a string: it appears both as a single-line array
106
+ // (`args: ['/c', ...]`) and as a block sequence. The inline form is stored in
107
+ // `cur.args` and re-emitted as a REAL YAML flow array on render — writing it
108
+ // back as a quoted string would violate dsh-mcp-client's schema (args:
109
+ // string[]) and crash the next boot. The block form is kept verbatim
110
+ // (argsLines) and re-emitted unchanged.
111
+ // `url` is preserved verbatim: it may be a `!!js` expression
112
+ // (`url: !!js (process.env.X || '') && ('https://...')`), and re-emitting it
113
+ // through a quoted scalar would turn the `!!js` tag into literal text, breaking
114
+ // the server on the next boot. Keeping it raw preserves the expression.
115
+ const PRESERVE_KEYS = new Set(['args', 'url'])
116
+ // Keys that `renderServer` emits itself (fixed `name`, the `config:` container
117
+ // opener). They must be dropped on parse so the rewrite doesn't duplicate them.
118
+ const SKIP_KEYS = new Set(['name', 'config'])
119
+
120
+ /**
121
+ * Parse server entries (4-space-indented `- id: mcp-*`) inside the MCP block.
122
+ * Each server keeps a `preserve` string of the config lines we don't model
123
+ * (env blocks, `!!js` lines, any other keys) so a rewrite never drops them.
124
+ */
125
+ function parseMcpServers(blockText) {
126
+ const lines = blockText.split('\n')
127
+ const servers = []
128
+ let cur = null
129
+ let curIndent = 0
130
+ // When we hit `args:` with no inline value, the following deeper-indented
131
+ // `- ` list items are a block sequence we must keep verbatim and re-emit
132
+ // under `args:`. `argsSeqIndent` is that sequence's indent (-1 = not collecting).
133
+ let argsSeqIndent = -1
134
+ for (const line of lines) {
135
+ const idm = line.match(/^(\s*)- id:\s*["']?(mcp-[^\s"']+)["']?\s*$/)
136
+ if (idm) {
137
+ if (cur) servers.push(cur)
138
+ cur = {
139
+ id: idm[2],
140
+ name: idm[2].replace(/^mcp-/, ''),
141
+ transport: '',
142
+ serverName: '',
143
+ url: '',
144
+ command: '',
145
+ args: '',
146
+ argsLines: [],
147
+ header: '',
148
+ preserve: '',
149
+ }
150
+ curIndent = idm[1].length
151
+ argsSeqIndent = -1
152
+ continue
153
+ }
154
+ if (!cur) continue
155
+ const indentMatch = line.match(/^(\s*)/)
156
+ const indent = indentMatch ? indentMatch[1].length : 0
157
+ if (line.trim().length === 0) continue
158
+ // A sibling server at the same indent ends this entry.
159
+ if (indent === curIndent && /^- id:/.test(line)) continue
160
+ if (indent <= curIndent) {
161
+ // Left the config entirely (or a sibling): stop collecting args.
162
+ argsSeqIndent = -1
163
+ if (cur) { servers.push(cur); cur = null }
164
+ continue
165
+ }
166
+ // While collecting an args block sequence, every deeper list item is kept.
167
+ // Store the BARE value (strip the leading "- " and any quotes) so the
168
+ // frontend can join it into a space-separated args string and renderServer
169
+ // can re-emit it with safeScalar — keeping "- " prefixes out of the value.
170
+ if (argsSeqIndent >= 0 && indent >= argsSeqIndent && /^\s*- /.test(line)) {
171
+ cur.argsLines.push(stripListScalar(line))
172
+ continue
173
+ }
174
+ const kv = line.match(/^\s+(\w+):\s*(.*)$/)
175
+ if (kv) {
176
+ const key = kv[1]
177
+ const val = stripQuotes(kv[2].trim())
178
+ if (SKIP_KEYS.has(key)) continue
179
+ if (key === 'args') {
180
+ // Inline array: `args: [...]` -> string. Block sequence: start collecting.
181
+ if (val.length) cur.args = val
182
+ else argsSeqIndent = indent + 2
183
+ continue
184
+ }
185
+ if (key === 'url') {
186
+ cur.url = val
187
+ // Plain http(s) urls are re-emitted from `cur.url` on render, so they
188
+ // must NOT also land in `preserve` (that would duplicate the line and,
189
+ // worse, make a `url` edit get clobbered by the stale preserve line).
190
+ // A `!!js`/process.env expression cannot be re-emitted through
191
+ // safeScalar (the tag would become literal text), so it falls through
192
+ // to `preserve` and is kept verbatim.
193
+ if (!looksLikeExpression(val)) continue
194
+ }
195
+ if (KNOWN_KEYS.has(key)) {
196
+ if (key === 'serverName') cur.serverName = val
197
+ else if (key === 'transport') cur.transport = val
198
+ else if (key === 'command') cur.command = val
199
+ else if (key === 'header') cur.header = val
200
+ continue
201
+ }
202
+ }
203
+ // Anything else (env:, !!js, nested keys) is preserved verbatim.
204
+ cur.preserve += (cur.preserve ? '\n' : '') + line
205
+ }
206
+ if (cur) servers.push(cur)
207
+ return servers
208
+ }
209
+
210
+ // Emit a YAML scalar safely for arbitrary user input. We always use double
211
+ // quotes with JSON-style escaping: it is a single line, so it can never
212
+ // disturb the surrounding block indentation (a block scalar like `|-` would
213
+ // swallow the following lines). Double quotes in YAML accept the same
214
+ // \n \t \" \\ escapes as JSON.
215
+ function safeScalar(value) {
216
+ const v = value === undefined || value === null ? '' : String(value)
217
+ if (v === '') return "''"
218
+ const escaped = v
219
+ .replace(/\\/g, '\\\\')
220
+ .replace(/"/g, '\\"')
221
+ .replace(/\n/g, '\\n')
222
+ .replace(/\t/g, '\\t')
223
+ .replace(/\r/g, '\\r')
224
+ return '"' + escaped + '"'
225
+ }
226
+
227
+ // Turn a connector args field into a real argument list: a JS/JSON array
228
+ // literal string ("['/c', 'npx', ...]") is parsed, anything else is split on
229
+ // whitespace. Keeps dsh-mcp-client's schema (args: string[]) satisfied.
230
+ function parseArgsValue(raw) {
231
+ const text = String(raw ?? '').trim()
232
+ if (!text) return ['']
233
+ if (/^\[.*\]$/s.test(text)) {
234
+ try {
235
+ const arr = JSON.parse(text.replace(/'/g, '"'))
236
+ if (Array.isArray(arr)) return arr.map((x) => String(x))
237
+ } catch {}
238
+ }
239
+ // Quote-aware tokenizer (learned from dsh-mcp-manager's parseArgs): arguments
240
+ // wrapped in "..." or '...' stay intact even when they contain spaces, so
241
+ // e.g. `--header "Authorization: Bearer x"` becomes two argv tokens instead
242
+ // of being split on every whitespace.
243
+ const out = []
244
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g
245
+ let m
246
+ while ((m = re.exec(text))) out.push(m[1] ?? m[2] ?? m[3])
247
+ return out
248
+ }
249
+
250
+ // dsh-mcp-client only accepts `headers` (an object) on streamable-http. A
251
+ // bare `header: "k: v"` line on stdio violates the schema and crashes the next
252
+ // boot, so we only render it for http, and only in the map form.
253
+ function renderHeader(s) {
254
+ if (!s.header || (s.transport || 'stdio') !== 'streamable-http') return []
255
+ const hm = /^([^:]+):\s*(.*)$/.exec(s.header)
256
+ if (!hm) return []
257
+ const key = hm[1].trim().replace(/"/g, '\\"')
258
+ return [` headers: { "${key}": ${safeScalar(hm[2].trim())} }`]
259
+ }
260
+
261
+ function renderServer(s) {
262
+ const out = []
263
+ out.push(` - id: ${safeScalar(s.id)}`)
264
+ out.push(` name: '@deepseek-ai/dsh-mcp-client'`)
265
+ out.push(` config:`)
266
+ out.push(` transport: ${safeScalar(s.transport || 'stdio')}`)
267
+ if (s.serverName) out.push(` serverName: ${safeScalar(s.serverName)}`)
268
+ // Plain http(s) urls are emitted here; `!!js`/process.env expressions stay
269
+ // in `preserve` and come back verbatim (safeScalar would break the tag).
270
+ if (s.url && !looksLikeExpression(s.url)) out.push(` url: ${safeScalar(s.url)}`)
271
+ if (s.command) out.push(` command: ${safeScalar(s.command)}`)
272
+ out.push(...renderHeader(s))
273
+ // args: either a real flow array, or a verbatim block sequence.
274
+ if (s.argsLines && s.argsLines.length) {
275
+ out.push(` args:`)
276
+ for (const item of s.argsLines) out.push(` - ${safeScalar(item)}`)
277
+ } else if (s.args) {
278
+ out.push(` args: [${parseArgsValue(s.args).map(safeScalar).join(', ')}]`)
279
+ }
280
+ if (s.preserve) {
281
+ const trimmed = s.preserve.trimEnd()
282
+ if (trimmed.length) out.push(trimmed)
283
+ }
284
+ return out.join('\n')
285
+ }
286
+
287
+ /**
288
+ * Rebuild the full patch text. `servers` is the desired server list (each may
289
+ * carry a `preserve` string from the original file; callers pass it through so
290
+ * env / !!js lines survive). When no MCP servers remain, the block is dropped.
291
+ */
292
+ function buildPatch(text, servers) {
293
+ const found = findMcpBlock(text)
294
+ if (!found) {
295
+ if (!servers.length) return text
296
+ const block =
297
+ `\n# MCP servers managed by @omdp/dsh-connector\n` +
298
+ `- insert:\n` +
299
+ servers.map(renderServer).join('\n') +
300
+ '\n'
301
+ return text.replace(/\n*$/, '\n') + block
302
+ }
303
+ // When every MCP server is removed, drop the whole insert block instead of
304
+ // leaving a bare `- insert:` (that would render as `insert: null` and could
305
+ // break the loader on the next boot).
306
+ if (!servers.length) return found.head + found.tail
307
+ const body = servers.map(renderServer).join('\n') + '\n'
308
+ return found.head + `- insert:\n` + body + found.tail
309
+ }
310
+
311
+ /* ───────────────────────────── Skills ──────────────────────────────────── */
312
+
313
+ function isAbsent(error) {
314
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT'
315
+ }
316
+
317
+ function parseFrontmatter(raw) {
318
+ const firstLineEnd = raw.indexOf('\n')
319
+ if (firstLineEnd < 0) return undefined
320
+ const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
321
+ if (firstLine !== '---') return undefined
322
+ const start = firstLineEnd + 1
323
+ let lineStart = start
324
+ while (lineStart <= raw.length) {
325
+ const nextNewline = raw.indexOf('\n', lineStart)
326
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline
327
+ const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
328
+ if (line === '---') {
329
+ const yamlText = raw.slice(start, lineStart)
330
+ let data
331
+ try {
332
+ data = parseYaml(yamlText)
333
+ } catch {
334
+ return undefined
335
+ }
336
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
337
+ const body = raw.slice(lineEnd + 1).replace(/^\r?\n/, '')
338
+ return { data, body }
339
+ }
340
+ lineStart = nextNewline < 0 ? raw.length + 1 : nextNewline + 1
341
+ }
342
+ return undefined
343
+ }
344
+
345
+ function serializeSkill(skill) {
346
+ const fm = { name: skill.name }
347
+ if (skill.description !== undefined) fm.description = skill.description
348
+ if (skill.whenToUse !== undefined) fm.whenToUse = skill.whenToUse
349
+ if (skill.modelInvocable !== undefined) fm.modelInvocable = skill.modelInvocable
350
+ if (skill.userInvocable !== undefined) fm.userInvocable = skill.userInvocable
351
+ const head = '---\n' + stringifyYaml(fm) + '---\n'
352
+ return head + (skill.content || '')
353
+ }
354
+
355
+ async function readUserSkill(home, name) {
356
+ const path = join(skillsRoot(), name, 'SKILL.md')
357
+ let raw
358
+ try {
359
+ raw = await readFile(path, 'utf8')
360
+ } catch (error) {
361
+ if (isAbsent(error)) return undefined
362
+ throw error
363
+ }
364
+ const fm = parseFrontmatter(raw)
365
+ if (!fm) return { name, description: '', content: raw }
366
+ return {
367
+ name: fm.data.name ?? name,
368
+ description: fm.data.description ?? '',
369
+ whenToUse: fm.data.whenToUse,
370
+ modelInvocable: fm.data.modelInvocable,
371
+ userInvocable: fm.data.userInvocable,
372
+ content: fm.body,
373
+ }
374
+ }
375
+
376
+ async function listUserSkills() {
377
+ const root = skillsRoot()
378
+ let entries
379
+ try {
380
+ entries = await readdir(root, { withFileTypes: true })
381
+ } catch (error) {
382
+ if (isAbsent(error)) return []
383
+ throw error
384
+ }
385
+ const out = []
386
+ for (const entry of entries) {
387
+ if (!entry.isDirectory()) continue
388
+ const skill = await readUserSkill(resolveHome(), entry.name)
389
+ if (skill) out.push({ name: skill.name, description: skill.description || '' })
390
+ }
391
+ return out
392
+ }
393
+
394
+ async function saveUserSkill(home, skill) {
395
+ if (!SKILL_NAME.test(skill.name)) {
396
+ throw new Error(`invalid skill name "${skill.name}" (kebab-case only)`)
397
+ }
398
+ // A bare `---` line inside the body would be mistaken for the frontmatter
399
+ // terminator on read and truncate the skill. Reject it up front.
400
+ if (skill.content && /(^|\n)\s*---\s*(\n|$)/.test(skill.content)) {
401
+ throw new Error('skill content must not contain a standalone "---" line')
402
+ }
403
+ const dir = join(skillsRoot(), skill.name)
404
+ const file = join(dir, 'SKILL.md')
405
+ const serialized = serializeSkill(skill)
406
+ // Round-trip check: the file we are about to write must parse back into a
407
+ // valid skill (name + at least one closing delimiter). If not, refuse.
408
+ if (!parseFrontmatter(serialized)) {
409
+ throw new Error('generated SKILL.md is not valid (frontmatter missing or unterminated)')
410
+ }
411
+ await mkdir(dir, { recursive: true })
412
+ await writeFile(file, serialized, 'utf8')
413
+ return { path: file }
414
+ }
415
+
416
+ async function removeUserSkill(home, name) {
417
+ if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`)
418
+ await rm(join(skillsRoot(), name), { recursive: true, force: true })
419
+ }
420
+
421
+ /* ─────────────────────── MCP server validation ─────────────────────────
422
+ * The authoritative gate between the UI and the patch file: every field the
423
+ * user can type is checked against the exact contract dsh-mcp-client enforces
424
+ * at boot (transport enum, serverName pattern) plus the OS-level constraints
425
+ * that make a stdio spawn actually work (command must resolve to something
426
+ * executable; no spaces/quotes that would break the cmd command line) and the
427
+ * streamable-http contract (url must parse as an http(s) URL). A value that
428
+ * fails here would either crash the next boot (schema reject) or spam cmd
429
+ * errors ('a' is not recognized...) — both are rejected before writing.
430
+ * ────────────────────────────────────────────────────────────────────────── */
431
+
432
+ // A `!!js` url expression (e.g. `!!js (process.env.X || '') && ('https://...')`)
433
+ // is evaluated by the harness at activation, so its raw text cannot be URL
434
+ // checked. Anything that looks like an expression is exempted.
435
+ function looksLikeExpression(value) {
436
+ const text = String(value ?? '').trim()
437
+ return text.startsWith('!!js') || text.includes('process.env') || text.includes('&&') || text.startsWith('(')
438
+ }
439
+
440
+ // Windows has no `which`; PATH lookup mirrors cmd's own resolution. A
441
+ // path-like command (contains a separator or a known script/exec extension)
442
+ // must exist on disk instead — `where` would not resolve a bare `.js`.
443
+ function commandResolvable(command) {
444
+ if (/[\\/]/.test(command) || /\.(js|mjs|cjs|cmd|bat|exe|ps1|py)$/i.test(command)) {
445
+ return existsSync(command)
446
+ }
447
+ if (process.platform === 'win32') {
448
+ const r = spawnSync('where', [command], { stdio: 'ignore' })
449
+ return r.status === 0
450
+ }
451
+ const r = spawnSync('which', [command], { stdio: 'ignore' })
452
+ return r.status === 0
453
+ }
454
+
455
+ // One string field shared by every server entry; returns the first problem.
456
+ function checkScalar(label, value) {
457
+ if (typeof value !== 'string') return `${label} must be a string`
458
+ if (CONTROL_CHARS.test(value)) return `${label} must not contain control characters`
459
+ return null
460
+ }
461
+
462
+ // Full validation for one server object (raw from the request body). Returns
463
+ // an array of human-readable problems; empty means it is safe to write.
464
+ function validateServer(s) {
465
+ const problems = []
466
+ const id = String(s.id ?? '')
467
+ if (!MCP_ID_RE.test(id)) {
468
+ problems.push(`invalid server id ${JSON.stringify(id)} (must be mcp-<kebab-case>, e.g. mcp-github)`)
469
+ }
470
+ const transport = String(s.transport ?? 'stdio')
471
+ if (!TRANSPORTS.has(transport)) {
472
+ problems.push(`server ${JSON.stringify(id)}: transport ${JSON.stringify(transport)} is not supported (use stdio or streamable-http)`)
473
+ }
474
+ const name = String(s.serverName ?? '')
475
+ if (!SERVER_NAME_RE.test(name)) {
476
+ problems.push(`server ${JSON.stringify(id)}: serverName ${JSON.stringify(name)} must match ${String(SERVER_NAME_RE)} (harness contract)`)
477
+ }
478
+ for (const [label, key] of [['command', 'command'], ['url', 'url'], ['header', 'header'], ['args', 'args']]) {
479
+ if (typeof s[key] === 'string') {
480
+ const problem = checkScalar(`${JSON.stringify(id)}.${label}`, s[key])
481
+ if (problem) problems.push(problem)
482
+ }
483
+ }
484
+ if (transport === 'stdio') {
485
+ const command = String(s.command ?? '')
486
+ if (!command.trim()) {
487
+ problems.push(`server ${JSON.stringify(id)}: command is required for stdio transport`)
488
+ } else if (/\s|['"]/.test(command)) {
489
+ problems.push(`server ${JSON.stringify(id)}: command must be a single token (no spaces or quotes), e.g. node or npx`)
490
+ } else if (!commandResolvable(command)) {
491
+ problems.push(`server ${JSON.stringify(id)}: command ${JSON.stringify(command)} not found — check the name or the full path`)
492
+ }
493
+ }
494
+ if (transport === 'streamable-http') {
495
+ const url = String(s.url ?? '')
496
+ if (!url.trim()) {
497
+ problems.push(`server ${JSON.stringify(id)}: url is required for streamable-http transport`)
498
+ } else if (!looksLikeExpression(url)) {
499
+ try {
500
+ const parsed = new URL(url)
501
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
502
+ problems.push(`server ${JSON.stringify(id)}: url must be http(s)`)
503
+ }
504
+ } catch {
505
+ problems.push(`server ${JSON.stringify(id)}: url ${JSON.stringify(url)} is not a valid URL`)
506
+ }
507
+ }
508
+ }
509
+ return problems
510
+ }
511
+
512
+ /* ─────────────────────────── HTTP API ──────────────────────────────────── */
513
+
514
+ function json(res, code, value) {
515
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
516
+ res.end(JSON.stringify(value))
517
+ }
518
+
519
+ async function readBody(req) {
520
+ const chunks = []
521
+ for await (const chunk of req) chunks.push(chunk)
522
+ try {
523
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
524
+ } catch {
525
+ return {}
526
+ }
527
+ }
528
+
529
+ export function apply(ctx) {
530
+ async function route(req, res) {
531
+ const url = new URL(req.url, 'http://localhost')
532
+ const path = url.pathname
533
+ try {
534
+ if (!path.startsWith(API_PREFIX)) { res.writeHead(404); res.end(); return }
535
+
536
+ // GET /api/mcp — list server entries (with preserve buckets)
537
+ if (req.method === 'GET' && path === API_PREFIX + '/mcp') {
538
+ let text
539
+ try {
540
+ text = await readFile(patchPath(), 'utf8')
541
+ } catch (error) {
542
+ if (isAbsent(error)) return json(res, 200, { servers: [], exists: false })
543
+ throw error
544
+ }
545
+ const found = findMcpBlock(text)
546
+ const servers = found ? parseMcpServers(found.blockText) : []
547
+ return json(res, 200, { servers, exists: true })
548
+ }
549
+
550
+ // POST /api/mcp — replace server entries. Preserve env/!!js from the
551
+ // existing entry with the same id so edits never drop credentials.
552
+ if (req.method === 'POST' && path === API_PREFIX + '/mcp') {
553
+ const body = await readBody(req)
554
+ if (!Array.isArray(body.servers)) {
555
+ return json(res, 400, { error: 'malformed request body: servers must be an array' })
556
+ }
557
+ // Authoritative validation gate: any problem (bad transport, invalid
558
+ // serverName, unresolvable command, invalid url, control characters)
559
+ // rejects the whole save with a specific message BEFORE it reaches the
560
+ // patch — a bad entry would crash the next boot, so it must never be
561
+ // written.
562
+ for (const s of body.servers) {
563
+ const problems = validateServer(s)
564
+ if (problems.length) {
565
+ return json(res, 400, { error: problems.join('; ') })
566
+ }
567
+ }
568
+ let text
569
+ try {
570
+ text = await readFile(patchPath(), 'utf8')
571
+ } catch (error) {
572
+ if (isAbsent(error)) text = '# Managed by @omdp/dsh-connector\n[]\n'
573
+ else throw error
574
+ }
575
+ const existing = new Map()
576
+ const found = findMcpBlock(text)
577
+ if (found) for (const s of parseMcpServers(found.blockText)) existing.set(s.id, s)
578
+ const servers = body.servers.map((s) => {
579
+ const prev = existing.get(s.id)
580
+ return prev ? { ...s, preserve: s.preserve || prev.preserve } : s
581
+ })
582
+ const next = buildPatch(text, servers)
583
+ // Validate the entire resulting patch parses as YAML before writing.
584
+ // cordis.patch.yml uses `!!js` tags; the parser tolerates them via
585
+ // silent log level (values stay raw, nothing printed), so a successful
586
+ // parse here means DSH can load it too. If it does NOT parse, refuse
587
+ // to write — better to reject the edit than crash dsh on next boot.
588
+ try {
589
+ parseYaml(next, { logLevel: 'silent' })
590
+ } catch (err) {
591
+ return json(res, 422, { ok: false, error: 'generated patch is not valid YAML: ' + (err && err.message ? err.message : String(err)) })
592
+ }
593
+ await mkdir(dirname(patchPath()), { recursive: true })
594
+ // Atomic replace: write to a temp file, then rename over the original
595
+ // so a crash mid-write cannot leave a half-written patch behind.
596
+ const target = patchPath()
597
+ const tmp = target + '.tmp'
598
+ await writeFile(tmp, next, 'utf8')
599
+ await rename(tmp, target)
600
+ return json(res, 200, { ok: true })
601
+ }
602
+
603
+ // GET /api/skills — list
604
+ if (req.method === 'GET' && path === API_PREFIX + '/skills') {
605
+ const skills = await listUserSkills()
606
+ return json(res, 200, { skills })
607
+ }
608
+
609
+ // PUT /api/skills — save
610
+ if (req.method === 'PUT' && path === API_PREFIX + '/skills') {
611
+ const body = await readBody(req)
612
+ // Guard against malformed bodies (readBody falls back to {} on bad
613
+ // JSON): without this, `name` could be undefined and, matching the
614
+ // kebab-case regex as the string "undefined", create a junk skill.
615
+ if (typeof body !== 'object' || body === null || Array.isArray(body) || typeof body.name !== 'string') {
616
+ return json(res, 400, { error: 'malformed request body: skill name (string) is required' })
617
+ }
618
+ const written = await saveUserSkill(resolveHome(), body)
619
+ return json(res, 200, { ok: true, path: written.path })
620
+ }
621
+
622
+ const skMatch = path.match(new RegExp('^' + API_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/skills/([^/]+)$'))
623
+
624
+ // GET /api/skills/:name — get one
625
+ if (req.method === 'GET' && skMatch) {
626
+ const name = decodeURIComponent(skMatch[1])
627
+ // Validate before touching the filesystem: a crafted `..%2F..%2F`
628
+ // name could otherwise escape ~/.dsh/skills and read arbitrary files.
629
+ if (!SKILL_NAME.test(name)) return json(res, 400, { error: 'invalid skill name' })
630
+ const skill = await readUserSkill(resolveHome(), name)
631
+ if (!skill) return json(res, 404, { error: 'skill not found' })
632
+ return json(res, 200, skill)
633
+ }
634
+
635
+ // DELETE /api/skills/:name — remove
636
+ if (req.method === 'DELETE' && skMatch) {
637
+ const name = decodeURIComponent(skMatch[1])
638
+ if (!SKILL_NAME.test(name)) return json(res, 400, { error: 'invalid skill name' })
639
+ await removeUserSkill(resolveHome(), name)
640
+ return json(res, 200, { ok: true })
641
+ }
642
+
643
+ res.writeHead(404)
644
+ res.end()
645
+ } catch (error) {
646
+ const logger = ctx.get('logger')
647
+ if (logger && typeof logger.error === 'function') logger.error(`connector api: ${error?.stack ?? error}`)
648
+ json(res, 500, { error: String(error?.message ?? error).slice(0, 300) })
649
+ }
650
+ }
651
+
652
+ const handle = ctx.webServer.register({ kind: 'prefix', path: '/connector', handler: route })
653
+ ctx.effect(() => handle)
654
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@omdp/dsh-connector",
3
+ "version": "0.1.0",
4
+ "description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml) and user skills (~/.dsh/skills) from one Web UI settings page.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "client.js",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "dsh": {
20
+ "client": {
21
+ "platform": "web",
22
+ "inject": [
23
+ "slots"
24
+ ]
25
+ },
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ }
29
+ },
30
+ "keywords": [
31
+ "dsh",
32
+ "dsh-plugin",
33
+ "deepseek-harness",
34
+ "mcp",
35
+ "skill",
36
+ "cordis"
37
+ ],
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/XJungit/omdp.git",
42
+ "directory": "dsh-connector"
43
+ },
44
+ "dependencies": {
45
+ "yaml": "^2.9.0"
46
+ }
47
+ }