@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 +106 -0
- package/client.js +404 -0
- package/cordis.patch.yml +6 -0
- package/index.js +654 -0
- package/package.json +47 -0
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
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|