@omdp/dsh-connector 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +26 -6
  2. package/client.js +411 -38
  3. package/index.js +299 -2
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -2,12 +2,18 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- 把 **MCP 服务器** 和 **用户 Skills** 的管理合并到 DSH Web UI 的同一个设置页
6
- (设置页标签:**Connector**)。适合需要在 DSH 里频繁增删改 MCP server / skills、
7
- 又不想手改 `cordis.patch.yml` 的用户。
5
+ 把 **MCP 服务器**、**用户 Skills** 的管理和 **魔搭(ModelScope)市场浏览**
6
+ 合并到 DSH Web UI 的同一个设置页(设置页标签:**Connector**)。适合需要在
7
+ DSH 里频繁增删改 MCP server / skills、又不想手改 `cordis.patch.yml` 的用户。
8
8
 
9
9
  - **MCP**:读取/编辑 `profiles/web/cordis.patch.yml` 中的 `mcp-*` 块(结构化表单)。保存后**重启 `dsh` 生效**。
10
10
  - **Skills**:列出/查看/编辑/删除 `~/.dsh/skills` 下的 `SKILL.md`。保存**即时生效**(filesystem provider 自动重新发现)。
11
+ - **市场探索(0.2.0 新增)**:只读浏览魔搭社区 [Skills 中心](https://modelscope.cn/skills) 与 [MCP 广场](https://modelscope.cn/mcp)(匿名 OpenAPI,无需密钥)。
12
+ - 列表/详情:名称、作者、分类、下载/浏览数、认证标识(Hosted 官方托管 / 已认证)
13
+ - **一键复制** skill 安装命令(`npx / curl / modelscope` 三种)与 MCP 配置片段(`server_config` 的 `mcpServers` JSON)
14
+ - **Skill 更新提示**:把本地 skill 关联市场条目(写入 frontmatter 的 `source`/`sourceUpdated`)后,「检查更新」比对市场 `file_last_modified` 标出"有更新/最新"
15
+ - **零落盘**:市场数据只存在 DSH 进程内存(30 分钟 TTL 缓存),重启即清,从不写文件
16
+ - MCP 为部署模式、无版本概念,不提供更新提示(仅浏览与复制配置)
11
17
 
12
18
  设计上复用官方两款参考插件的方式:
13
19
  - 设置页槽位注册方式参照 [`dsh-mcp-manager`](https://github.com/hyqhyq3/dsh-mcp-manager)(`settings.section` + Package 私有 HTTP API)。
@@ -133,6 +139,13 @@ pnpm remove @omdp/dsh-connector
133
139
  2. **Skills** 区:
134
140
  - 列出 `~/.dsh/skills` 下的用户技能
135
141
  - 「编辑」改 frontmatter 与正文;「删除」移除目录;「+ 新建」创建
142
+ - 「检查更新」:对已关联市场来源的 skill 比对魔搭更新时间,显示"有更新/最新"徽标
143
+ 3. **市场探索** 区:
144
+ - MCP 市场:输入关键词搜索魔搭 MCP 广场;条目显示作者/浏览数与"已配置"徽标;展开详情看
145
+ Hosted / 认证 / 环境变量 / 配置变体,**「复制配置」** 一键复制 `mcpServers` JSON 片段
146
+ - Skills 市场:搜索魔搭技能中心;展开详情看三条安装命令(逐个**复制**,自行执行),
147
+ 「记录来源」把本地技能关联到该市场条目(写 frontmatter `source`/`sourceUpdated`)
148
+ - 全部数据经 host 30 分钟内存缓存代理,仅本机内存,不写盘
136
149
 
137
150
  ## 工作原理
138
151
 
@@ -149,6 +162,9 @@ pnpm remove @omdp/dsh-connector
149
162
  - 保存时按 `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`——坏配置永远到不了下次启动。
150
163
  - MCP 块解析为结构化提取,复杂嵌套 YAML(如多 env 变量)在表单里以单字段呈现;极复杂配置请直接在 `cordis.patch.yml` 编辑。
151
164
  - 不桥接 MCP 的 resources/prompts,只管理 server 配置。
165
+ - **市场只读**:不做 MCP 部署、不做 skill 安装(MCP 部署/部分 skill 安装不是简单改配置)。安装命令/配置片段请复制后自行执行。
166
+ - **版本提示仅限 skill**:魔搭 MCP 是部署模式、无版本概念,不提供更新提示;skill 的"有更新"以市场 `file_last_modified` 对比本地 `sourceUpdated` 判定(需先「记录来源」)。
167
+ - 市场依赖 `modelscope.cn` 可达性;不可达时接口返回 502 提示,不影响 MCP/skill 本地管理。
152
168
 
153
169
  ## Troubleshooting
154
170
 
@@ -202,9 +218,10 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
202
218
  | 数据 | 访问方式 | 说明 |
203
219
  |---|---|---|
204
220
  | `profiles/web/cordis.patch.yml` | **读写** | MCP 块的结构化编辑(保留 `!!js`/env 原样) |
205
- | `~/.dsh/skills/**/SKILL.md` | **读写** | 用户技能文件的查看/编辑/删除/新建 |
221
+ | `~/.dsh/skills/**/SKILL.md` | **读写** | 用户技能文件的查看/编辑/删除/新建;「记录来源」会写 `source`/`sourceUpdated` frontmatter |
206
222
  | `~/.dsh/settings.yaml` 等 | 只读 | 不主动读写 |
207
- | HTTP `/connector/api/*` | 本机监听 | 与 DSH GUI 同源,无外部网络请求(不调用外部 API) |
223
+ | HTTP `/connector/api/*` | 本机监听 | 与 DSH GUI 同源,无额外鉴权 |
224
+ | 魔搭 `modelscope.cn/openapi/v1` | **只读外部** | 市场浏览代理(匿名);结果仅存进程内存(30 分钟 TTL),**不写文件、不落盘** |
208
225
  | 环境变量 | 只读引用 | 只读 `process.env.*`,不持久化 |
209
226
 
210
227
  **不收集**:无遥测、无外部上报、无用户数据离开本机。
@@ -222,5 +239,8 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
222
239
  | DSH 小更新/补丁 | ✅ 不会崩 |
223
240
  | DSH 大版本(`webServer` API 变化) | ✅ DSH 不崩;connector 需适配更新 |
224
241
  | yaml 版本 | ✅ 独立 npm 包,不受 DSH 更新影响 |
242
+ | 魔搭市场不可达 | ✅ 市场接口报 502,本地 MCP/skill 管理不受影响 |
225
243
 
226
- **最后验证**:DSH `0.1.0-rc.8`(2026-08-20,已在本机运行实例活体验证 `/connector/api/skills` 返回 200)。
244
+ **最后验证**:DSH `0.1.0-rc.8`(2026-08-20);0.2.0 市场功能以 `node --check` +
245
+ 真实 HTTP 集成测试通过(11 项:skills/mcp 列表与详情、证书/Hosted 标识、安装命令、
246
+ 记录来源回写、更新判定),未改动 DSH 实例。
package/client.js CHANGED
@@ -4,14 +4,21 @@
4
4
  * Installed-package client bundle: the harness serves this file at
5
5
  * /plugins/<id>/client.js and expects a single window.__ModuleLoader__.load
6
6
  * handoff whose factory receives `require`. We register one unified settings
7
- * tab ("Connector") that manages two things in a single page:
7
+ * tab ("Connector") with three panes:
8
8
  * - MCP servers: read/edit the mcp-* block of cordis.patch.yml
9
9
  * - user skills: list/view/edit/remove SKILL.md files under ~/.dsh/skills
10
+ * - marketplace: read-only ModelScope Skills Center + MCP Plaza browser
11
+ * (30-min in-process cache on the host, nothing on disk)
10
12
  *
11
13
  * It talks to the host half over the Package-private HTTP API the host mounts
12
14
  * on the DSH GUI webserver (/connector/api/*), exactly like
13
15
  * dsh-mcp-manager's client does with fetch — not the dynamic-only host.call.
14
16
  *
17
+ * Visual language follows the DSH theme tokens (--dsw-alias-*): surfaces use
18
+ * layer-1/2 backgrounds, borders use border-l1/l2, accents use brand-primary,
19
+ * and badges reuse the state success/warn colors so the panel blends into the
20
+ * GUI in both light and dark themes.
21
+ *
15
22
  * @module @omdp/dsh-connector/client
16
23
  */
17
24
 
@@ -25,29 +32,59 @@ window.__ModuleLoader__.load({
25
32
  var createElement = react.createElement
26
33
 
27
34
  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}' +
35
+ /* layout */
36
+ '.pm_root{display:flex;flex-direction:column;gap:18px;padding:0 24px 28px}' +
37
+ '.pm_tabs{display:flex;gap:4px;padding:2px 24px 0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));margin:0 -24px 4px}' +
38
+ '.pm_tab{cursor:pointer;font:inherit;font-size:13px;padding:8px 14px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary,#9aa);border-bottom:2px solid transparent;margin-bottom:-1px}' +
39
+ '.pm_tab:hover{color:var(--dsw-alias-label-primary,#ddd)}' +
40
+ '.pm_tab.active{color:var(--dsw-alias-label-primary,#f4f4f4);border-bottom-color:var(--dsw-alias-brand-primary,#5b8cff);font-weight:600}' +
30
41
  '.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}' +
42
+ '.pm_sectionHead{font-size:13px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-primary,#eee);display:flex;align-items:center;gap:8px}' +
43
+ '.pm_sectionHead .pm_hint{margin-left:0}' +
44
+ /* cards */
45
+ '.pm_card{background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;transition:background .15s,border-color .15s}' +
46
+ '.pm_card:hover{background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.06));border-color:var(--dsw-alias-border-l2,rgba(128,128,128,.4))}' +
47
+ '.pm_cardHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}' +
48
+ '.pm_name{font-weight:600;font-size:14px;color:var(--dsw-alias-label-primary,#eee)}' +
49
+ '.pm_meta{font-size:12px;color:var(--dsw-alias-label-secondary,#9aa);word-break:break-all;line-height:1.5}' +
50
+ '.pm_desc{font-size:12.5px;color:var(--dsw-alias-label-secondary,#aab);line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}' +
51
+ /* badges */
52
+ '.pm_badge{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid currentColor;line-height:1.6;white-space:nowrap}' +
53
+ '.pm_badge.muted{color:var(--dsw-alias-label-secondary,#9aa)}' +
54
+ '.pm_badge.ok{color:var(--dsw-alias-state-success-primary,#4ade80)}' +
55
+ '.pm_badge.warn{color:var(--dsw-alias-state-warn-primary,#fbbf24)}' +
56
+ '.pm_badge.brand{color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:var(--dsw-alias-brand-primary,#7aa2ff)}' +
57
+ '.pm_badge.err{color:var(--dsw-alias-state-error-primary,#f87171)}' +
58
+ /* buttons */
59
+ '.pm_actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
60
+ '.pm_btn{cursor:pointer;font:inherit;font-size:12px;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:inherit;transition:background .12s}' +
61
+ '.pm_btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14))}' +
62
+ '.pm_btn:disabled{opacity:.45;cursor:default}' +
63
+ '.pm_btn.primary{border-color:transparent;background:var(--dsw-alias-brand-primary,#5b8cff);color:#fff}' +
64
+ '.pm_btn.primary:hover:not(:disabled){filter:brightness(1.08)}' +
65
+ '.pm_btn.danger{color:var(--dsw-alias-state-error-primary,#f87171);border-color:currentColor}' +
66
+ '.pm_btn.ghost{border-style:dashed}' +
67
+ /* form */
44
68
  '.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}' +
69
+ '.pm_form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}' +
70
+ '.pm_form input,.pm_form textarea,.pm_form select{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:inherit}' +
71
+ '.pm_form input:focus,.pm_form textarea:focus,.pm_form select:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));outline-offset:0;border-color:transparent}' +
47
72
  '.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}'
73
+ '.pm_textarea{font-family:ui-monospace,Menlo,Consolas,monospace;min-height:140px}' +
74
+ /* market */
75
+ '.pm_search{display:flex;gap:8px}' +
76
+ '.pm_search input{flex:1;font:inherit;font-size:13px;padding:7px 11px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:inherit}' +
77
+ '.pm_search input:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));border-color:transparent}' +
78
+ '.pm_groupHead{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa);text-transform:uppercase;letter-spacing:.08em;margin:8px 0 2px}' +
79
+ '.pm_code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.55;background:var(--dsw-alias-bg-base,rgba(0,0,0,.25));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));border-radius:8px;padding:8px 10px;overflow:auto;max-height:220px;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-secondary,#9aa)}' +
80
+ '.pm_envRow{display:flex;gap:8px;align-items:baseline;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}' +
81
+ '.pm_envRow code{color:var(--dsw-alias-label-primary,#ddd)}' +
82
+ '.pm_spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-top-color:var(--dsw-alias-brand-primary,#5b8cff);border-radius:50%;animation:pm_spin .7s linear infinite;vertical-align:-2px}' +
83
+ '@keyframes pm_spin{to{transform:rotate(360deg)}}' +
84
+ '.pm_err{color:var(--dsw-alias-state-error-primary,#f87171);font-size:12px}' +
85
+ '.pm_ok{color:var(--dsw-alias-state-success-primary,#4ade80);font-size:12px}' +
86
+ '.pm_empty{font-size:12px;color:var(--dsw-alias-label-secondary,#888);padding:10px 2px}' +
87
+ '.pm_btnrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}'
51
88
 
52
89
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="@omdp/dsh-connector/section"]') === null) {
53
90
  var tag = document.createElement('style')
@@ -66,6 +103,45 @@ window.__ModuleLoader__.load({
66
103
  })
67
104
  }
68
105
 
106
+ // Clipboard with a fallback: 127.0.0.1 is a secure context so
107
+ // navigator.clipboard normally works; execCommand covers edge cases.
108
+ function copyText(text, done) {
109
+ function fallback() {
110
+ try {
111
+ var ta = document.createElement('textarea')
112
+ ta.value = text
113
+ ta.style.position = 'fixed'
114
+ ta.style.opacity = '0'
115
+ document.body.appendChild(ta)
116
+ ta.focus()
117
+ ta.select()
118
+ var ok = document.execCommand('copy')
119
+ document.body.removeChild(ta)
120
+ done(ok === true)
121
+ } catch (e) {
122
+ done(false)
123
+ }
124
+ }
125
+ if (navigator.clipboard && navigator.clipboard.writeText) {
126
+ navigator.clipboard.writeText(text).then(function () { done(true) }, fallback)
127
+ } else {
128
+ fallback()
129
+ }
130
+ }
131
+
132
+ function useCopied() {
133
+ var _a = react.useState('')
134
+ var copied = _a[0]
135
+ var setCopied = _a[1]
136
+ function copy(text) {
137
+ copyText(text, function (ok) {
138
+ setCopied(ok ? '已复制 ✓' : '复制失败')
139
+ setTimeout(function () { setCopied('') }, 1600)
140
+ })
141
+ }
142
+ return { copied: copied, copy: copy }
143
+ }
144
+
69
145
  // Server ids must stay kebab-case under the fixed `mcp-` prefix: the host
70
146
  // parser and dsh-mcp-client both key on it, so the prefix is not optional.
71
147
  var MCP_ID_RE = /^mcp-[a-z0-9]+(?:-[a-z0-9]+)*$/
@@ -91,6 +167,10 @@ window.__ModuleLoader__.load({
91
167
  )
92
168
  }
93
169
 
170
+ function badge(text, kind) {
171
+ return createElement('span', { className: 'pm_badge ' + kind }, text)
172
+ }
173
+
94
174
  function ManageTab() {
95
175
  var _a = react.useState('mcp')
96
176
  var tab = _a[0]
@@ -122,16 +202,21 @@ window.__ModuleLoader__.load({
122
202
 
123
203
  return createElement('div', { className: 'pm_root' },
124
204
  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 技能'),
205
+ createElement('button', { className: 'pm_tab' + (tab === 'mcp' ? ' active' : ''), onClick: function () { setTab('mcp') } }, 'MCP 服务器'),
206
+ createElement('button', { className: 'pm_tab' + (tab === 'skills' ? ' active' : ''), onClick: function () { setTab('skills') } }, 'Skills 技能'),
207
+ createElement('button', { className: 'pm_tab' + (tab === 'market' ? ' active' : ''), onClick: function () { setTab('market') } }, '市场探索'),
127
208
  ),
128
209
  error ? createElement('div', { className: 'pm_err' }, error) : null,
129
210
  tab === 'mcp'
130
211
  ? createElement(McpPane, { mcp: mcp, busy: busy, onChanged: refresh })
131
- : createElement(SkillPane, { skills: skills, busy: busy, onChanged: refresh }),
212
+ : tab === 'skills'
213
+ ? createElement(SkillPane, { skills: skills, busy: busy, onChanged: refresh })
214
+ : createElement(MarketPane, { skills: skills, onChanged: refresh }),
132
215
  )
133
216
  }
134
217
 
218
+ /* ─────────────────────────── MCP servers ─────────────────────────── */
219
+
135
220
  function McpPane(props) {
136
221
  var _a = react.useState(null)
137
222
  var editing = _a[0]
@@ -163,10 +248,10 @@ window.__ModuleLoader__.load({
163
248
  }
164
249
 
165
250
  var rows = props.mcp.servers.map(function (s) {
166
- return createElement('div', { className: 'pm_row', key: s.id },
167
- createElement('div', { className: 'pm_rowHead' },
251
+ return createElement('div', { className: 'pm_card', key: s.id },
252
+ createElement('div', { className: 'pm_cardHead' },
168
253
  createElement('span', { className: 'pm_name' }, s.name || s.id),
169
- createElement('span', { className: 'pm_kind' }, s.transport || 'stdio'),
254
+ badge(s.transport || 'stdio', 'muted'),
170
255
  ),
171
256
  createElement('div', { className: 'pm_meta' }, s.url ? 'url: ' + s.url : (s.command ? 'cmd: ' + s.command : '')),
172
257
  createElement('div', { className: 'pm_actions' },
@@ -177,13 +262,16 @@ window.__ModuleLoader__.load({
177
262
  })
178
263
 
179
264
  return createElement('div', { className: 'pm_section' },
180
- createElement('div', { className: 'pm_sectionHead' }, 'MCP 服务器 MCP Servers'),
265
+ createElement('div', { className: 'pm_sectionHead' },
266
+ 'MCP 服务器 MCP Servers',
267
+ props.busy ? createElement('span', { className: 'pm_spinner' }) : null,
268
+ ),
181
269
  err ? createElement('div', { className: 'pm_err' }, err) : null,
182
- createElement('div', { className: 'pm_hint' },
270
+ createElement('div', { className: 'pm_meta' },
183
271
  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
272
  rows,
185
273
  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')
274
+ ? createElement('button', { className: 'pm_btn pm_btn ghost', onClick: function () { setEditing({ id: 'mcp-new', customId: '', name: '', transport: 'stdio', serverName: '', url: '', command: '', args: '' }) } }, '+ 添加 MCP 服务器 Add MCP Server')
187
275
  : createElement(ServerForm, { value: editing, onSave: save, onCancel: function () { setEditing(null) } }),
188
276
  )
189
277
  }
@@ -272,7 +360,7 @@ window.__ModuleLoader__.load({
272
360
  }
273
361
  }
274
362
 
275
- return createElement('div', { className: 'pm_row' },
363
+ return createElement('div', { className: 'pm_card' },
276
364
  err ? createElement('div', { className: 'pm_err' }, err) : null,
277
365
  createElement('div', { className: 'pm_form' },
278
366
  field('名称 (serverName) Name', v.serverName, set('serverName')),
@@ -298,6 +386,8 @@ window.__ModuleLoader__.load({
298
386
  )
299
387
  }
300
388
 
389
+ /* ───────────────────────────── Skills ────────────────────────────── */
390
+
301
391
  function SkillPane(props) {
302
392
  var _a = react.useState(null)
303
393
  var editing = _a[0]
@@ -305,6 +395,12 @@ window.__ModuleLoader__.load({
305
395
  var _b = react.useState('')
306
396
  var err = _b[0]
307
397
  var setErr = _b[1]
398
+ var _c = react.useState(null)
399
+ var updates = _c[0]
400
+ var setUpdates = _c[1]
401
+ var _d = react.useState(false)
402
+ var checking = _d[0]
403
+ var setChecking = _d[1]
308
404
 
309
405
  function open(name) {
310
406
  api('/skills/' + encodeURIComponent(name), { method: 'GET' }).then(function (skill) {
@@ -328,11 +424,22 @@ window.__ModuleLoader__.load({
328
424
  props.onChanged()
329
425
  })
330
426
  }
427
+ function checkUpdates() {
428
+ setChecking(true)
429
+ setErr('')
430
+ api('/skills/check-updates', { method: 'GET' }).then(function (r) {
431
+ if (r && r.error) { setErr(r.error); setUpdates(null); return }
432
+ setUpdates(r.items || [])
433
+ }).finally(function () { setChecking(false) })
434
+ }
331
435
 
332
436
  var rows = props.skills.map(function (s) {
333
- return createElement('div', { className: 'pm_row', key: s.name },
334
- createElement('div', { className: 'pm_rowHead' },
437
+ var update = updates ? updates.find(function (u) { return u.name === s.name }) : null
438
+ return createElement('div', { className: 'pm_card', key: s.name },
439
+ createElement('div', { className: 'pm_cardHead' },
335
440
  createElement('span', { className: 'pm_name' }, s.name),
441
+ s.source ? badge('来源 ' + s.source, 'muted') : null,
442
+ update ? (update.updateAvailable ? badge('有更新', 'warn') : badge('最新', 'ok')) : null,
336
443
  ),
337
444
  createElement('div', { className: 'pm_meta' }, s.description || ''),
338
445
  createElement('div', { className: 'pm_actions' },
@@ -343,12 +450,18 @@ window.__ModuleLoader__.load({
343
450
  })
344
451
 
345
452
  return createElement('div', { className: 'pm_section' },
346
- createElement('div', { className: 'pm_sectionHead' }, '用户 Skills (~/.dsh/skills) User Skills'),
453
+ createElement('div', { className: 'pm_sectionHead' },
454
+ '用户 Skills (~/.dsh/skills) User Skills',
455
+ props.busy ? createElement('span', { className: 'pm_spinner' }) : null,
456
+ createElement('span', { className: 'pm_actions', style: { marginLeft: 'auto' } },
457
+ createElement('button', { className: 'pm_btn', disabled: checking, onClick: checkUpdates }, checking ? '检查中…' : '检查更新'),
458
+ ),
459
+ ),
347
460
  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.'),
461
+ createElement('div', { className: 'pm_meta' }, '读写用户技能目录,保存即生效(skill catalog 自动刷新)。带来源的 skill 可与魔搭市场比对更新时间。Read/write user skills; saved changes take effect immediately.'),
349
462
  rows,
350
463
  editing === null
351
- ? createElement('button', { className: 'pm_btn pm_add', onClick: function () { setEditing({ name: '', description: '', content: '' }) } }, '+ 新建 Skill New Skill')
464
+ ? createElement('button', { className: 'pm_btn pm_btn ghost', onClick: function () { setEditing({ name: '', description: '', content: '' }) } }, '+ 新建 Skill New Skill')
352
465
  : createElement(SkillForm, { value: editing, onSave: save, onCancel: function () { setEditing(null) } }),
353
466
  )
354
467
  }
@@ -370,7 +483,7 @@ window.__ModuleLoader__.load({
370
483
  props.onSave(Object.assign({}, v, { name: name }))
371
484
  }
372
485
 
373
- return createElement('div', { className: 'pm_row' },
486
+ return createElement('div', { className: 'pm_card' },
374
487
  err ? createElement('div', { className: 'pm_err' }, err) : null,
375
488
  createElement('div', { className: 'pm_form' },
376
489
  field('名称 (kebab-case) Name', v.name, set('name')),
@@ -387,6 +500,266 @@ window.__ModuleLoader__.load({
387
500
  )
388
501
  }
389
502
 
503
+ /* ─────────────────────────── marketplace ─────────────────────────── */
504
+
505
+ function MarketPane(props) {
506
+ var _a = react.useState('')
507
+ var mcpSearch = _a[0]
508
+ var setMcpSearch = _a[1]
509
+ var _b = react.useState(null)
510
+ var mcpResult = _b[0]
511
+ var setMcpResult = _b[1]
512
+ var _c = react.useState(false)
513
+ var mcpBusy = _c[0]
514
+ var setMcpBusy = _c[1]
515
+ var _d = react.useState({})
516
+ var mcpDetail = _d[0]
517
+ var setMcpDetail = _d[1]
518
+ var _e = react.useState('')
519
+ var skillSearch = _e[0]
520
+ var setSkillSearch = _e[1]
521
+ var _f = react.useState(null)
522
+ var skillResult = _f[0]
523
+ var setSkillResult = _f[1]
524
+ var _g = react.useState(false)
525
+ var skillBusy = _g[0]
526
+ var setSkillBusy = _g[1]
527
+ var _h = react.useState({})
528
+ var skillDetail = _h[0]
529
+ var setSkillDetail = _h[1]
530
+ var _i = react.useState('')
531
+ var err = _i[0]
532
+ var setErr = _i[1]
533
+
534
+ function findMcp() {
535
+ setMcpBusy(true)
536
+ setErr('')
537
+ api('/market/mcp?search=' + encodeURIComponent(mcpSearch.trim()), { method: 'GET' }).then(function (r) {
538
+ if (r && r.error) { setErr(r.error); setMcpResult(null); return }
539
+ setMcpResult(r)
540
+ }).finally(function () { setMcpBusy(false) })
541
+ }
542
+ function findSkills() {
543
+ setSkillBusy(true)
544
+ setErr('')
545
+ api('/market/skills?search=' + encodeURIComponent(skillSearch.trim()), { method: 'GET' }).then(function (r) {
546
+ if (r && r.error) { setErr(r.error); setSkillResult(null); return }
547
+ setSkillResult(r)
548
+ }).finally(function () { setSkillBusy(false) })
549
+ }
550
+ function openMcp(id) {
551
+ api('/market/mcp/' + encodeURIComponent(id), { method: 'GET' }).then(function (r) {
552
+ if (r && r.error) { setErr(r.error); return }
553
+ var next = Object.assign({}, mcpDetail)
554
+ next[id] = r.item
555
+ setMcpDetail(next)
556
+ })
557
+ }
558
+ function openSkill(id) {
559
+ api('/market/skills/' + encodeURIComponent(id), { method: 'GET' }).then(function (r) {
560
+ if (r && r.error) { setErr(r.error); return }
561
+ var next = Object.assign({}, skillDetail)
562
+ next[id] = r.item
563
+ setSkillDetail(next)
564
+ })
565
+ }
566
+
567
+ var mcpRows = mcpResult && mcpResult.items
568
+ ? mcpResult.items.map(function (s) {
569
+ var d = mcpDetail[s.id]
570
+ return createElement(McpMarketItem, {
571
+ key: s.id, item: s, detail: d,
572
+ onOpen: function () { if (!d) openMcp(s.id) },
573
+ })
574
+ })
575
+ : []
576
+
577
+ var skillRows = skillResult && skillResult.items
578
+ ? skillResult.items.map(function (s) {
579
+ var d = skillDetail[s.id]
580
+ return createElement(SkillMarketItem, {
581
+ key: s.id, item: s, detail: d, skills: props.skills,
582
+ onOpen: function () { if (!d) openSkill(s.id) },
583
+ onChanged: props.onChanged,
584
+ })
585
+ })
586
+ : []
587
+
588
+ return createElement('div', { className: 'pm_section' },
589
+ createElement('div', { className: 'pm_sectionHead' }, '市场探索 Marketplace'),
590
+ createElement('div', { className: 'pm_meta' },
591
+ '浏览魔搭社区(ModelScope)技能中心与 MCP 广场。列表数据缓存在 DSH 进程内存(30 分钟),不写任何文件;安装/部署请复制命令自行执行。Browse ModelScope Skills Center & MCP Plaza; cached in-process only, nothing saved to disk.'),
592
+ err ? createElement('div', { className: 'pm_err' }, err) : null,
593
+
594
+ createElement('div', { className: 'pm_groupHead' }, 'MCP 市场 MCP Plaza'),
595
+ createElement('div', { className: 'pm_search' },
596
+ createElement('input', {
597
+ placeholder: '搜索 MCP 服务,如 map / github / fetch Search…',
598
+ value: mcpSearch,
599
+ onChange: function (e) { setMcpSearch(e.target.value) },
600
+ onKeyDown: function (e) { if (e.key === 'Enter') findMcp() },
601
+ }),
602
+ createElement('button', { className: 'pm_btn primary', disabled: mcpBusy, onClick: findMcp }, mcpBusy ? '搜索中…' : '搜索'),
603
+ ),
604
+ mcpRows.length ? mcpRows : (mcpResult ? createElement('div', { className: 'pm_empty' }, '没有匹配的 MCP 服务。No matching MCP servers.') : null),
605
+
606
+ createElement('div', { className: 'pm_groupHead', style: { marginTop: '18px' } }, 'Skills 市场 Skills Center'),
607
+ createElement('div', { className: 'pm_search' },
608
+ createElement('input', {
609
+ placeholder: '搜索技能,如 code-review / deploy Search…',
610
+ value: skillSearch,
611
+ onChange: function (e) { setSkillSearch(e.target.value) },
612
+ onKeyDown: function (e) { if (e.key === 'Enter') findSkills() },
613
+ }),
614
+ createElement('button', { className: 'pm_btn primary', disabled: skillBusy, onClick: findSkills }, skillBusy ? '搜索中…' : '搜索'),
615
+ ),
616
+ skillRows.length ? skillRows : (skillResult ? createElement('div', { className: 'pm_empty' }, '没有匹配的技能。No matching skills.') : null),
617
+ )
618
+ }
619
+
620
+ // One MCP market entry with a lazy detail panel.
621
+ function McpMarketItem(props) {
622
+ var item = props.item
623
+ var d = props.detail
624
+ var _a = react.useState(null)
625
+ var variant = _a[0]
626
+ var setVariant = _a[1]
627
+ var copied = useCopied()
628
+ var configs = d && Array.isArray(d.serverConfig) ? d.serverConfig : []
629
+ var cfgText = ''
630
+ if (configs.length) {
631
+ var sel = variant !== null && variant < configs.length ? configs[variant] : configs[0]
632
+ cfgText = JSON.stringify(sel, null, 2)
633
+ }
634
+
635
+ return createElement('div', { className: 'pm_card', key: item.id },
636
+ createElement('div', { className: 'pm_cardHead' },
637
+ createElement('span', { className: 'pm_name' }, item.name || item.id),
638
+ item.configured ? badge('已配置', 'ok') : null,
639
+ badge(item.id, 'muted'),
640
+ ),
641
+ createElement('div', { className: 'pm_desc' }, item.description || ''),
642
+ createElement('div', { className: 'pm_meta' },
643
+ 'by ' + (item.publisher || '—'),
644
+ item.views !== undefined ? ' · ' + item.views + ' 浏览' : '',
645
+ ),
646
+ createElement('div', { className: 'pm_actions' },
647
+ createElement('button', { className: 'pm_btn', onClick: props.onOpen }, d ? '收起' : '详情'),
648
+ d && configs.length
649
+ ? createElement('button', { className: 'pm_btn primary', onClick: function () { copied.copy(cfgText) } },
650
+ copied.copied || '复制配置 Copy config')
651
+ : null,
652
+ ),
653
+ d ? createElement('div', { className: 'pm_section', style: { marginTop: '4px' } },
654
+ createElement('div', { className: 'pm_actions' },
655
+ d.hosted ? badge('Hosted 官方托管', 'brand') : null,
656
+ d.verified ? badge('已认证 Verified', 'brand') : null,
657
+ d.stars !== undefined && d.stars !== null ? badge('★ ' + d.stars, 'muted') : null,
658
+ (d.categories && d.categories.length) ? badge(d.categories.slice(0, 3).join(' / '), 'muted') : null,
659
+ ),
660
+ (d.envSchema && d.envSchema.length)
661
+ ? createElement('div', { className: 'pm_meta' },
662
+ '环境变量 Env vars: ',
663
+ d.envSchema.map(function (ev) {
664
+ return createElement('span', { key: ev.key, className: 'pm_envRow' },
665
+ createElement('code', null, ev.key),
666
+ ev.required ? badge('必填', 'warn') : null,
667
+ )
668
+ }),
669
+ )
670
+ : null,
671
+ configs.length
672
+ ? createElement('div', { className: 'pm_section' },
673
+ configs.length > 1
674
+ ? selectField('配置变体 Config variant', variant === null ? 0 : variant, configs.map(function (_, i) { return 'variant ' + (i + 1) }), function (i) { setVariant(Number(i)) })
675
+ : null,
676
+ createElement('div', { className: 'pm_code' }, cfgText),
677
+ )
678
+ : null,
679
+ d.readme ? createElement('div', { className: 'pm_meta' }, d.readme.slice(0, 240) + (d.readme.length > 240 ? ' …' : '')) : null,
680
+ d.source ? createElement('div', { className: 'pm_meta' }, '来源: ' + d.source) : null,
681
+ ) : null,
682
+ )
683
+ }
684
+
685
+ // One market skill entry with install commands and local "record source".
686
+ function SkillMarketItem(props) {
687
+ var item = props.item
688
+ var d = props.detail
689
+ var copied = useCopied()
690
+ var _a = react.useState('')
691
+ var linkErr = _a[0]
692
+ var setLinkErr = _a[1]
693
+ var _b = react.useState('')
694
+ var linkOk = _b[0]
695
+ var setLinkOk = _b[1]
696
+ var _c = react.useState(null)
697
+ var target = _c[0]
698
+ var setTarget = _c[1]
699
+
700
+ var alreadyLinked = props.skills.some(function (s) { return s.source === item.id })
701
+ var installs = d && Array.isArray(d.installCommand) ? d.installCommand : []
702
+
703
+ function recordSource() {
704
+ setLinkErr('')
705
+ setLinkOk('')
706
+ if (!target) { setLinkErr('请先选择一个本地技能 Please pick a local skill'); return }
707
+ api('/skills/' + encodeURIComponent(target) + '/source', {
708
+ method: 'POST',
709
+ body: JSON.stringify({ marketId: item.id, modified: d ? d.fileModified : undefined }),
710
+ }).then(function (r) {
711
+ if (r && r.error) { setLinkErr(r.error); return }
712
+ setLinkOk('已关联 ' + target + ' ✓')
713
+ props.onChanged()
714
+ })
715
+ }
716
+
717
+ return createElement('div', { className: 'pm_card', key: item.id },
718
+ createElement('div', { className: 'pm_cardHead' },
719
+ createElement('span', { className: 'pm_name' }, item.name || item.id),
720
+ item.installed || alreadyLinked ? badge('已安装', 'ok') : null,
721
+ item.developer ? badge('by ' + item.developer, 'muted') : null,
722
+ item.category ? badge(item.category, 'muted') : null,
723
+ ),
724
+ createElement('div', { className: 'pm_desc' }, item.description || ''),
725
+ createElement('div', { className: 'pm_meta' },
726
+ item.downloads !== undefined ? item.downloads + ' 下载' : '',
727
+ item.views !== undefined ? ' · ' + item.views + ' 浏览' : '',
728
+ item.license ? ' · ' + item.license : '',
729
+ ),
730
+ createElement('div', { className: 'pm_actions' },
731
+ createElement('button', { className: 'pm_btn', onClick: props.onOpen }, d ? '收起' : '详情'),
732
+ d && d.updateAvailable ? badge('有更新', 'warn') : null,
733
+ ),
734
+ d ? createElement('div', { className: 'pm_section', style: { marginTop: '4px' } },
735
+ createElement('div', { className: 'pm_meta' },
736
+ '市场更新时间 Market modified: ' + (d.fileModified || '—'),
737
+ d.localSkill ? ' · 本地记录: ' + d.localSkill.sourceUpdated : '',
738
+ ),
739
+ installs.length
740
+ ? createElement('div', { className: 'pm_section' },
741
+ createElement('div', { className: 'pm_meta' }, '安装命令 Install command(复制后自行执行):'),
742
+ installs.map(function (cmd, i) {
743
+ return createElement('div', { key: i, className: 'pm_btnrow' },
744
+ createElement('div', { className: 'pm_code', style: { flex: 1 } }, cmd),
745
+ createElement('button', { className: 'pm_btn', onClick: function () { copied.copy(cmd) } }, copied.copied || '复制 Copy'),
746
+ )
747
+ }),
748
+ )
749
+ : null,
750
+ alreadyLinked
751
+ ? createElement('div', { className: 'pm_ok' }, '已关联到本地技能 ✓ Linked to a local skill.')
752
+ : createElement('div', { className: 'pm_btnrow' },
753
+ selectField('关联本地技能 Link to local skill', target || '', props.skills.map(function (s) { return s.name }), setTarget),
754
+ createElement('button', { className: 'pm_btn', onClick: recordSource }, '记录来源 Record source'),
755
+ ),
756
+ linkErr ? createElement('div', { className: 'pm_err' }, linkErr) : null,
757
+ linkOk ? createElement('div', { className: 'pm_ok' }, linkOk) : null,
758
+ d.source ? createElement('div', { className: 'pm_meta' }, '来源: ' + d.source) : null,
759
+ ) : null,
760
+ )
761
+ }
762
+
390
763
  function apply(ctx) {
391
764
  var slots = ctx.get('slots')
392
765
  if (slots === undefined) return
@@ -401,4 +774,4 @@ window.__ModuleLoader__.load({
401
774
  exports.apply = apply
402
775
  return module.exports
403
776
  },
404
- })
777
+ })
package/index.js CHANGED
@@ -348,6 +348,10 @@ function serializeSkill(skill) {
348
348
  if (skill.whenToUse !== undefined) fm.whenToUse = skill.whenToUse
349
349
  if (skill.modelInvocable !== undefined) fm.modelInvocable = skill.modelInvocable
350
350
  if (skill.userInvocable !== undefined) fm.userInvocable = skill.userInvocable
351
+ // Marketplace provenance: set by "record source" in the UI, consumed by the
352
+ // update check (compares market file_last_modified against sourceUpdated).
353
+ if (skill.source !== undefined) fm.source = skill.source
354
+ if (skill.sourceUpdated !== undefined) fm.sourceUpdated = skill.sourceUpdated
351
355
  const head = '---\n' + stringifyYaml(fm) + '---\n'
352
356
  return head + (skill.content || '')
353
357
  }
@@ -369,6 +373,9 @@ async function readUserSkill(home, name) {
369
373
  whenToUse: fm.data.whenToUse,
370
374
  modelInvocable: fm.data.modelInvocable,
371
375
  userInvocable: fm.data.userInvocable,
376
+ // Marketplace provenance survives read so edits keep it unless replaced.
377
+ source: fm.data.source,
378
+ sourceUpdated: fm.data.sourceUpdated,
372
379
  content: fm.body,
373
380
  }
374
381
  }
@@ -386,7 +393,7 @@ async function listUserSkills() {
386
393
  for (const entry of entries) {
387
394
  if (!entry.isDirectory()) continue
388
395
  const skill = await readUserSkill(resolveHome(), entry.name)
389
- if (skill) out.push({ name: skill.name, description: skill.description || '' })
396
+ if (skill) out.push({ name: skill.name, description: skill.description || '', source: skill.source, sourceUpdated: skill.sourceUpdated })
390
397
  }
391
398
  return out
392
399
  }
@@ -400,9 +407,18 @@ async function saveUserSkill(home, skill) {
400
407
  if (skill.content && /(^|\n)\s*---\s*(\n|$)/.test(skill.content)) {
401
408
  throw new Error('skill content must not contain a standalone "---" line')
402
409
  }
410
+ // Keep marketplace provenance when an edit does not carry it: the editor
411
+ // round-trips content only, so dropping source here would silently sever
412
+ // the update-check link on every save.
413
+ const existing = await readUserSkill(home, skill.name).catch(() => undefined)
414
+ const merged = {
415
+ ...skill,
416
+ source: skill.source !== undefined ? skill.source : existing?.source,
417
+ sourceUpdated: skill.sourceUpdated !== undefined ? skill.sourceUpdated : existing?.sourceUpdated,
418
+ }
403
419
  const dir = join(skillsRoot(), skill.name)
404
420
  const file = join(dir, 'SKILL.md')
405
- const serialized = serializeSkill(skill)
421
+ const serialized = serializeSkill(merged)
406
422
  // Round-trip check: the file we are about to write must parse back into a
407
423
  // valid skill (name + at least one closing delimiter). If not, refuse.
408
424
  if (!parseFrontmatter(serialized)) {
@@ -418,6 +434,247 @@ async function removeUserSkill(home, name) {
418
434
  await rm(join(skillsRoot(), name), { recursive: true, force: true })
419
435
  }
420
436
 
437
+ /* ──────────────────── ModelScope marketplace proxy ───────────────────────
438
+ * Read-only browsing of the ModelScope Skills Center and MCP Plaza. All
439
+ * market data lives in a 30-minute in-process cache — nothing is written to
440
+ * disk, no files ever accumulate, and a restart clears it. Install / deploy
441
+ * are NOT performed: the UI only lists entries and copies install commands /
442
+ * server config for the user to run themselves. Browsing uses anonymous
443
+ * endpoints; no MODELSCOPE_API_KEY is required.
444
+ * ───────────────────────────────────────────────────────────────────────── */
445
+
446
+ const MARKET_BASE = process.env.DSH_CONNECTOR_MARKET ?? 'https://modelscope.cn/openapi/v1'
447
+ const MARKET_TTL = 30 * 60 * 1000 // 30 minutes in-process cache
448
+ const marketCache = new Map()
449
+
450
+ async function marketFetch(key, path, init) {
451
+ const hit = marketCache.get(key)
452
+ if (hit !== undefined && Date.now() - hit.at < MARKET_TTL) return hit.value
453
+ const res = await fetch(MARKET_BASE + path, {
454
+ ...init,
455
+ headers: { accept: 'application/json', ...(init?.headers ?? {}) },
456
+ signal: AbortSignal.timeout(15000),
457
+ })
458
+ if (!res.ok) throw new Error(`market returned HTTP ${res.status}`)
459
+ const data = await res.json()
460
+ marketCache.set(key, { at: Date.now(), value: data })
461
+ return data
462
+ }
463
+
464
+ function marketError(res, error) {
465
+ return json(res, 502, { error: `market unavailable: ${String(error?.message ?? error)}` })
466
+ }
467
+
468
+ // Skill ids come back as `@author/name` and appear in URLs verbatim. Only
469
+ // accept the safe alphabet so a crafted id can never escape the path.
470
+ const MARKET_ID = /^@?[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/
471
+
472
+ // GET /api/market/skills?search=&category=&page=
473
+ async function marketSkillsList(req, res, params) {
474
+ const search = params.get('search')?.trim()
475
+ const category = params.get('category')?.trim()
476
+ const page = Math.max(1, Number(params.get('page')) || 1)
477
+ const qs = new URLSearchParams({ page_number: String(page), page_size: '20' })
478
+ if (search) qs.set('search', search)
479
+ if (category) qs.set('filter.category', category)
480
+ let data
481
+ try {
482
+ data = await marketFetch(`skills?${qs}`, `/skills?${qs}`)
483
+ } catch (error) {
484
+ return marketError(res, error)
485
+ }
486
+ const local = await listUserSkills().catch(() => [])
487
+ const items = (data?.data?.skills ?? []).map((s) => ({
488
+ id: s.id,
489
+ name: s.display_name,
490
+ description: s.description,
491
+ developer: s.developer,
492
+ category: s.category,
493
+ license: s.license,
494
+ views: s.view_count,
495
+ downloads: s.downloads,
496
+ source: s.source_url,
497
+ logo: s.logo_url,
498
+ installed: local.some((l) => l.source === s.id),
499
+ }))
500
+ return json(res, 200, { ok: true, items })
501
+ }
502
+
503
+ // GET /api/market/skills/:id (id is `@author/name`, may contain a slash)
504
+ async function marketSkillDetail(res, id) {
505
+ let data
506
+ try {
507
+ data = await marketFetch(`skills/${id}`, `/skills/${id}`)
508
+ } catch (error) {
509
+ return marketError(res, error)
510
+ }
511
+ const d = data?.data ?? {}
512
+ const local = (await listUserSkills().catch(() => [])).find((l) => l.source === id)
513
+ let updateAvailable = false
514
+ if (local && d.file_last_modified && local.sourceUpdated) {
515
+ updateAvailable = new Date(d.file_last_modified).getTime() > new Date(local.sourceUpdated).getTime()
516
+ }
517
+ return json(res, 200, {
518
+ ok: true,
519
+ item: {
520
+ id: d.id,
521
+ name: d.display_name,
522
+ description: d.description,
523
+ owner: d.owner || d.developer,
524
+ developer: d.developer,
525
+ category: d.category,
526
+ license: d.license,
527
+ tags: Array.isArray(d.tags) ? d.tags.filter((t) => !t.startsWith('category:') && !t.startsWith('developer:') && !t.startsWith('custom_tag:')) : [],
528
+ views: d.view_count,
529
+ downloads: d.downloads,
530
+ source: d.source_url,
531
+ logo: d.logo_url,
532
+ private: d.private === true,
533
+ installCommand: Array.isArray(d.install_command) ? d.install_command : [],
534
+ fileModified: d.file_last_modified ?? null,
535
+ lastModified: d.last_modified ?? null,
536
+ updateAvailable,
537
+ localSkill: local ? { name: local.name, sourceUpdated: local.sourceUpdated ?? null } : null,
538
+ },
539
+ })
540
+ }
541
+
542
+ // GET /api/market/mcp?search=&page=
543
+ async function marketMcpList(req, res, params) {
544
+ const search = params.get('search')?.trim()
545
+ const page = Math.max(1, Number(params.get('page')) || 1)
546
+ const body = { page_number: page, page_size: 20 }
547
+ if (search) body.search = search
548
+ const key = `mcp?${JSON.stringify(body)}`
549
+ let data
550
+ try {
551
+ data = await marketFetch(key, '/mcp/servers', {
552
+ method: 'PUT',
553
+ headers: { 'content-type': 'application/json' },
554
+ body: JSON.stringify(body),
555
+ })
556
+ } catch (error) {
557
+ return marketError(res, error)
558
+ }
559
+ // "configured" = already present in the local patch's mcp-* block (matched
560
+ // by the server's own id/args/url text appearing in a local entry).
561
+ let localMatches = []
562
+ try {
563
+ const text = await readFile(patchPath(), 'utf8')
564
+ const found = findMcpBlock(text)
565
+ if (found) {
566
+ const configured = parseMcpServers(found.blockText).map((s) => (s.command + ' ' + (Array.isArray(s.args) ? s.args.join(' ') : s.args || '') + ' ' + (s.url || '')).toLowerCase())
567
+ localMatches = configured.filter(Boolean)
568
+ }
569
+ } catch {
570
+ // patch missing/unreadable: no configured markers, market still works
571
+ }
572
+ const items = (data?.data?.mcp_server_list ?? []).map((s) => ({
573
+ id: s.id,
574
+ name: s.chinese_name || s.name,
575
+ description: s.description,
576
+ publisher: s.publisher,
577
+ tags: s.tags,
578
+ logo: s.logo_url,
579
+ views: s.view_count,
580
+ categories: s.categories,
581
+ configured: localMatches.some((local) => local.includes(s.id.toLowerCase())),
582
+ }))
583
+ return json(res, 200, { ok: true, items })
584
+ }
585
+
586
+ // Reduce a JSONSchema env block to its variable list (never ship the raw
587
+ // schema wholesale — it can be large and contains no browsing value).
588
+ function envSchemaSummary(schema) {
589
+ if (!schema || typeof schema !== 'object' || !schema.properties || typeof schema.properties !== 'object') return undefined
590
+ const required = Array.isArray(schema.required) ? schema.required : []
591
+ return Object.entries(schema.properties).map(([key, v]) => ({
592
+ key,
593
+ required: required.includes(key),
594
+ hint: typeof v === 'object' && v ? (v.title || v.description || '') : '',
595
+ }))
596
+ }
597
+
598
+ // GET /api/market/mcp/:id
599
+ async function marketMcpDetail(res, id) {
600
+ let data
601
+ try {
602
+ data = await marketFetch(`mcp/${id}`, `/mcp/servers/${id}`)
603
+ } catch (error) {
604
+ return marketError(res, error)
605
+ }
606
+ const d = data?.data ?? {}
607
+ return json(res, 200, {
608
+ ok: true,
609
+ item: {
610
+ id: d.id,
611
+ name: d.chinese_name || d.name,
612
+ description: d.description,
613
+ author: d.author,
614
+ publisher: d.publisher,
615
+ owner: d.owner,
616
+ hosted: d.is_hosted === true, // "Hosted" tick on the plaza page
617
+ verified: d.is_verified === true, // certification tick
618
+ stars: d.github_stars,
619
+ source: d.source_url,
620
+ logo: d.logo_url,
621
+ categories: d.categories,
622
+ tags: d.tags,
623
+ envSchema: envSchemaSummary(d.env_schema),
624
+ // server_config holds ready-to-paste { mcpServers: {...} } variants.
625
+ serverConfig: Array.isArray(d.server_config) ? d.server_config : undefined,
626
+ readme: typeof d.readme === 'string' ? d.readme.slice(0, 2000) : '',
627
+ },
628
+ })
629
+ }
630
+
631
+ // GET /api/skills/check-updates
632
+ // For every installed skill that carries a `source`, pull the market detail
633
+ // (cache-backed) and compare its file_last_modified against the locally
634
+ // recorded sourceUpdated. No files are touched.
635
+ async function checkSkillUpdates(res) {
636
+ const local = (await listUserSkills().catch(() => [])).filter((s) => s.source)
637
+ const out = []
638
+ for (const skill of local) {
639
+ const id = skill.source
640
+ let marketModified = null
641
+ let updateAvailable = false
642
+ try {
643
+ const data = await marketFetch(`skills/${id}`, `/skills/${id}`)
644
+ marketModified = data?.data?.file_last_modified ?? null
645
+ if (marketModified && skill.sourceUpdated) {
646
+ updateAvailable = new Date(marketModified).getTime() > new Date(skill.sourceUpdated).getTime()
647
+ }
648
+ } catch {
649
+ // market unreachable: report the entry without an update verdict
650
+ }
651
+ out.push({
652
+ name: skill.name,
653
+ source: id,
654
+ sourceUpdated: skill.sourceUpdated ?? null,
655
+ marketModified,
656
+ updateAvailable,
657
+ })
658
+ }
659
+ return json(res, 200, { ok: true, items: out })
660
+ }
661
+
662
+ // POST /api/skills/:name/source { marketId, modified }
663
+ // Links an installed skill to its marketplace entry by writing `source` and
664
+ // `sourceUpdated` into that skill's own frontmatter — the only disk write in
665
+ // the whole market feature, and it happens only on an explicit user action.
666
+ async function recordSkillSource(res, name, body) {
667
+ if (!SKILL_NAME.test(name)) return json(res, 400, { error: 'invalid skill name' })
668
+ const marketId = typeof body?.marketId === 'string' && MARKET_ID.test(body.marketId) ? body.marketId : null
669
+ if (!marketId) return json(res, 400, { error: 'invalid marketId (expect @author/name)' })
670
+ const skill = await readUserSkill(resolveHome(), name)
671
+ if (!skill) return json(res, 404, { error: 'skill not found' })
672
+ skill.source = marketId
673
+ skill.sourceUpdated = typeof body.modified === 'string' && body.modified ? body.modified : new Date().toISOString().slice(0, 19) + 'Z'
674
+ await saveUserSkill(resolveHome(), skill)
675
+ return json(res, 200, { ok: true, source: marketId })
676
+ }
677
+
421
678
  /* ─────────────────────── MCP server validation ─────────────────────────
422
679
  * The authoritative gate between the UI and the patch file: every field the
423
680
  * user can type is checked against the exact contract dsh-mcp-client enforces
@@ -619,6 +876,46 @@ export function apply(ctx) {
619
876
  return json(res, 200, { ok: true, path: written.path })
620
877
  }
621
878
 
879
+ // GET /api/skills/check-updates — update hints for sourced skills
880
+ if (req.method === 'GET' && path === API_PREFIX + '/skills/check-updates') {
881
+ return checkSkillUpdates(res)
882
+ }
883
+
884
+ // POST /api/skills/:name/source — link an installed skill to a market id
885
+ const srcMatch = path.match(new RegExp('^' + API_PREFIX + '/skills/([^/]+)/source$'))
886
+ if (req.method === 'POST' && srcMatch) {
887
+ const body = await readBody(req)
888
+ return recordSkillSource(res, decodeURIComponent(srcMatch[1]), body)
889
+ }
890
+
891
+ /* ── marketplace (read-only ModelScope proxy) ── */
892
+
893
+ // GET /api/market/skills?search=&category=&page=
894
+ if (req.method === 'GET' && path === API_PREFIX + '/market/skills') {
895
+ return marketSkillsList(req, res, new URL(req.url, 'http://localhost').searchParams)
896
+ }
897
+
898
+ // GET /api/market/skills/:id (id `@author/name` contains a slash)
899
+ const mktSkill = path.match(new RegExp('^' + API_PREFIX + '/market/skills/(.+)$'))
900
+ if (req.method === 'GET' && mktSkill) {
901
+ const id = decodeURIComponent(mktSkill[1])
902
+ if (!MARKET_ID.test(id)) return json(res, 400, { error: 'invalid market id' })
903
+ return marketSkillDetail(res, id)
904
+ }
905
+
906
+ // GET /api/market/mcp?search=&page=
907
+ if (req.method === 'GET' && path === API_PREFIX + '/market/mcp') {
908
+ return marketMcpList(req, res, new URL(req.url, 'http://localhost').searchParams)
909
+ }
910
+
911
+ // GET /api/market/mcp/:id
912
+ const mktMcp = path.match(new RegExp('^' + API_PREFIX + '/market/mcp/(.+)$'))
913
+ if (req.method === 'GET' && mktMcp) {
914
+ const id = decodeURIComponent(mktMcp[1])
915
+ if (!MARKET_ID.test(id)) return json(res, 400, { error: 'invalid market id' })
916
+ return marketMcpDetail(res, id)
917
+ }
918
+
622
919
  const skMatch = path.match(new RegExp('^' + API_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/skills/([^/]+)$'))
623
920
 
624
921
  // GET /api/skills/:name — get one
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@omdp/dsh-connector",
3
- "version": "0.1.2",
4
- "description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml) and user skills (~/.dsh/skills) from one Web UI settings page.",
3
+ "version": "0.2.0",
4
+ "description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml), user skills (~/.dsh/skills) and browse the ModelScope MCP/Skills marketplaces from one Web UI settings page.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {