dsh-skill-mcp-market 0.3.0 → 0.3.2
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 +163 -10
- package/client.js +9 -4
- package/index.js +4 -4
- package/package.json +2 -2
- package/src/extra-sources.js +8 -6
- package/src/home.js +2 -2
- package/src/http.js +89 -0
- package/src/provider.js +2 -2
- package/src/sources.js +55 -4
package/README.md
CHANGED
|
@@ -1,10 +1,144 @@
|
|
|
1
1
|
# dsh-skill-mcp-market
|
|
2
2
|
|
|
3
|
-
DSH 的**技能市场**与 **MCP
|
|
3
|
+
DSH 的**技能市场**与 **MCP 连接器市场**:浏览实时技能源、装 SKILL.md 技能、装 MCP 连接器,装完立刻能用,安装路径由你自己定,全程不用重启 DSH。
|
|
4
4
|
|
|
5
5
|
- 提供的工具:12 个 `market_* / skill_market_* / mcp_market_*`(见下表)
|
|
6
|
-
-
|
|
6
|
+
- 设置文件:`~/.dsh/market/settings.json`(可用 `market_set_paths` 改)
|
|
7
7
|
- 默认技能目录:`~/.dsh/market/skills`;默认 MCP 目录:`~/.dsh/market/mcp`
|
|
8
|
+
- 左侧面板:**「SKILL技能&MCP连接器」**
|
|
9
|
+
|
|
10
|
+
## 截图
|
|
11
|
+
|
|
12
|
+
真机截图(DSH 桌面版,左侧菜单「SKILL技能&MCP连接器」)。
|
|
13
|
+
|
|
14
|
+
### 技能市场
|
|
15
|
+
|
|
16
|
+
顶部按来源切换(`skills.sh` / `clawhub` / `skillsmp` / `trusttools` / `browse.sh` / `lobehub-agent` / `skillhub`),卡片上直接「预览 / 安装」:
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
> 「MCP 连接器」「已安装」「路径」三个页签的截图待补。
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
已发布到 npm:**`dsh-skill-mcp-market`**(MIT,公共包,无需登录)。
|
|
25
|
+
|
|
26
|
+
### 前置条件
|
|
27
|
+
|
|
28
|
+
- 桌面版 DSH:本插件复用的是 `app.asar` 里官方技能/MCP 插件,纯 Node 环境跑不了。
|
|
29
|
+
- profile 目录:`~/.dsh/profiles/<profile>`,桌面版一般是 `~/.dsh/profiles/desktop`;
|
|
30
|
+
Windows 下是 `C:\Users\<你>\.dsh\profiles\desktop`。
|
|
31
|
+
|
|
32
|
+
### 方式一:`dsh` CLI 安装(推荐)
|
|
33
|
+
|
|
34
|
+
DSH 桌面版自带 CLI,profile 的依赖由它**内置的 pnpm** 管理(profile 目录里有 `pnpm-lock.yaml`、`pnpm-workspace.yaml`,`nodeLinker: hoisted`):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dsh plugin --profile desktop add dsh-skill-mcp-market
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
它等价于在 `~/.dsh/profiles/desktop` 里跑 `pnpm add`,多做了三件事:用安装目录里那份 pnpm(不依赖你本机的 pnpm)、对 profile 的 `package.json` 加文件锁(避免与正在跑的 DSH 抢写)、检查插件与当前 DSH 版本是否兼容。
|
|
41
|
+
|
|
42
|
+
`dsh` 不在 PATH 时用绝对路径:
|
|
43
|
+
|
|
44
|
+
```bat
|
|
45
|
+
"D:\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add dsh-skill-mcp-market
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
装完依赖还差一步——**让 DSH 加载它**:把包名加进该 profile `package.json` 的 `dsh.profile.bundles`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"dsh-skill-mcp-market": "^0.3.0"
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"profile": {
|
|
57
|
+
"bundles": [
|
|
58
|
+
"@deepseek-ai/dsh-base",
|
|
59
|
+
"@deepseek-ai/dsh-web-app",
|
|
60
|
+
"dsh-skill-mcp-market"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
包自带 `dsh.bundle.patch`(指向 `cordis.patch.yml`),进了 `bundles` 就会自动插入插件行,**不用手写 `cordis.patch.yml`**。
|
|
68
|
+
|
|
69
|
+
改完 `bundles` 需要**重启一次 DSH** 让插件被加载;此后装技能、装 MCP 都不再需要重启。
|
|
70
|
+
|
|
71
|
+
不想动 `bundles` 的话,也可以在 profile 的 `cordis.patch.yml` 末尾手写一行:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- insert:
|
|
75
|
+
- id: dsh-skill-mcp-market
|
|
76
|
+
name: dsh-skill-mcp-market
|
|
77
|
+
config:
|
|
78
|
+
skillDirs: []
|
|
79
|
+
mcpDirs: []
|
|
80
|
+
shippedRoot: ""
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
清掉再装:`dsh plugin --profile desktop remove dsh-skill-mcp-market`(同时从 `bundles` 里删掉包名)。
|
|
84
|
+
|
|
85
|
+
### 方式二:应用内安装(plugin_manager / 插件面板)
|
|
86
|
+
|
|
87
|
+
- `plugin_manager`:`action: install_bundle`,`target` 填 `dsh-skill-mcp-market` 会「装依赖 + 加进 bundles」;`action: set_bundle` 只切换已安装包的启用状态。
|
|
88
|
+
- 设置 → 插件 里也能看到并启用它。
|
|
89
|
+
- 这条路径会执行包内代码,需要 Full access 或审批。
|
|
90
|
+
|
|
91
|
+
### 方式三:npm(不推荐)
|
|
92
|
+
|
|
93
|
+
profile 的 `node_modules` 是 pnpm 管的:`npm i` 会额外写 `package-lock.json`、打乱 `node_modules` 布局,下一次 `pnpm install` 可能把它清掉。只有在没有 `dsh` CLI 的机器上才这样装:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd ~/.dsh/profiles/desktop
|
|
97
|
+
npm i dsh-skill-mcp-market # 之后仍需把包名加入 dsh.profile.bundles
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 方式四:固定版本 / 多 profile / 本地开发
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
dsh plugin --profile desktop add dsh-skill-mcp-market@0.3.0 # 固定版本
|
|
104
|
+
dsh plugin --profile desktop why dsh-skill-mcp-market # 看装的哪个版本、谁引入的
|
|
105
|
+
dsh plugin --profile desktop add link:/path/to/dsh-market # 本地开发(换成你的仓库路径)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`link:` 情况下 `bundles` 里仍写包名;改完源码用 `plugin_manager set_plugin` 把 `include:dsh-skill-mcp-market` 关一次再开一次即可重挂(见「开发」)。同一个包可以装进多个 profile,各自独立配置。
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
### 安装后确认
|
|
112
|
+
|
|
113
|
+
1. 左侧菜单出现 **「SKILL技能&MCP连接器」**;
|
|
114
|
+
2. 「技能市场」「MCP 连接器」两个页签都能出卡片,顶部来源筛选可用:`skills.sh` / `clawhub` / `skillsmp` / `trusttools` / `browse.sh` / `lobehub-agent` / `skillhub`;
|
|
115
|
+
3. 让模型调 `market_get_settings`,应返回两个目录、provider 挂载状态、官方包解析结果、可用来源;
|
|
116
|
+
4. 12 个 `market_*` / `skill_market_*` / `mcp_market_*` 工具都在。
|
|
117
|
+
|
|
118
|
+
### 首次使用:决定装到哪里
|
|
119
|
+
|
|
120
|
+
默认装在 `~/.dsh/market/` 下。想换位置(例如并入 DSH 自带技能目录,让别的 profile 也能看到):
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
market_set_paths({ skillDirs: ["C:/Users/你/.dsh/skills"], mcpDirs: ["C:/Users/你/.dsh/market/mcp"] })
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
支持 `~`、`$DSH_HOME`、`%ENV%` 和相对 profile 的路径;改完 provider 与 MCP 立即重挂。
|
|
127
|
+
|
|
128
|
+
需要密钥的 MCP 用 `${ENV_VAR}` 占位,值放环境变量:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{ "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 升级与卸载
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
dsh plugin --profile desktop add dsh-skill-mcp-market@latest # 升级,重启 DSH 生效
|
|
138
|
+
dsh plugin --profile desktop remove dsh-skill-mcp-market # 卸载;再把这个包名从 dsh.profile.bundles 里删掉
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
卸载插件不会删掉已装好的技能与 MCP 定义(它们在 `~/.dsh/market/` 下,是独立数据);要清理请用 `skill_market_uninstall` / `mcp_market_uninstall`。
|
|
8
142
|
|
|
9
143
|
## 为什么能"不重启就生效"
|
|
10
144
|
|
|
@@ -34,25 +168,39 @@ DSH 本来就有技能运行时和 MCP 运行时,缺的只是市场这一层
|
|
|
34
168
|
| `mcp_market_probe` | 等 MCP 发布工具并回报 |
|
|
35
169
|
| `mcp_market_uninstall` | 卸载(定义移入 `.trash/`) |
|
|
36
170
|
|
|
171
|
+
## 数据源
|
|
172
|
+
|
|
173
|
+
| 源 | 说明 |
|
|
174
|
+
|---|---|
|
|
175
|
+
| **MCP 官方注册表** | `registry.modelcontextprotocol.io`,免费、实时的 MCP 目录,自带真实启动方式(npm / streamable-http) |
|
|
176
|
+
| `skills.sh` | 技能索引,支持页面 URL 与裸 `owner/repo`;搜索接口要求 ≥2 字符,空搜索回退到首页推荐 |
|
|
177
|
+
| `skillsmp` | 带反爬(Cloudflare),走 python→curl→fetch 三级取数 |
|
|
178
|
+
| `browse.sh` | 启动预热 + 内存缓存,冷启动超时不缓存空结果 |
|
|
179
|
+
| `trusttools` | `ai.trusttools.cn/api/skills/search`(POST);该站会丢弃本进程的 TLS 握手,走 python → curl → fetch 三级通道 |
|
|
180
|
+
| `clawhub` / `lobehub-agent` / `skillhub` | 其余实时技能源 |
|
|
181
|
+
| `github` | 直接给仓库或 `@ref`;jsDelivr 列树失败时回退 GitHub contents API 与 codeload tarball |
|
|
182
|
+
|
|
37
183
|
## 目录结构
|
|
38
184
|
|
|
39
185
|
```
|
|
40
186
|
dsh-skill-mcp-market/
|
|
41
187
|
├─ package.json # dsh.bundle.patch 指向 cordis.patch.yml
|
|
42
|
-
├─ cordis.patch.yml # 插入一行 dsh-market 插件
|
|
188
|
+
├─ cordis.patch.yml # 插入一行 dsh-skill-mcp-market 插件
|
|
43
189
|
├─ index.js # 插件入口:挂载 provider + 注册 12 个工具
|
|
190
|
+
├─ client.js # Web 面板「SKILL技能&MCP连接器」
|
|
44
191
|
├─ src/
|
|
45
192
|
│ ├─ home.js # 路径解析与 settings.json 读写
|
|
46
193
|
│ ├─ shipped.js # 从 app.asar 解析并 import 官方插件包
|
|
194
|
+
│ ├─ http.js # python→curl→fetch 三级取数(绕 TLS 指纹 / 系统代理)
|
|
47
195
|
│ ├─ frontmatter.js # SKILL.md frontmatter 解析(无依赖)
|
|
48
|
-
│ ├─ github.js # jsDelivr 列树 + raw/CDN
|
|
196
|
+
│ ├─ github.js # jsDelivr 列树 + raw/CDN 取文件,失败回退 contents API / tarball
|
|
197
|
+
│ ├─ extra-sources.js # skillsmp 等额外源
|
|
198
|
+
│ ├─ mcp-registry.js # MCP 官方注册表适配 + 内置兜底条目
|
|
49
199
|
│ ├─ skillhub.js # SkillHub 公共 API 适配
|
|
50
200
|
│ ├─ skills.js # 搜索聚合 / 安装 / 列出 / 卸载
|
|
201
|
+
│ ├─ sources.js # 各源适配与缓存
|
|
51
202
|
│ ├─ mcp.js # MCP 定义扫描、动态挂载、启停、探活
|
|
52
203
|
│ └─ ...
|
|
53
|
-
├─ catalog/
|
|
54
|
-
│ ├─ skills.json # 17 条精选技能(从 anthropics/skills 生成)
|
|
55
|
-
│ └─ mcp.json # 8 条标准 MCP 定义
|
|
56
204
|
├─ locale/{en,zh}.json # 插件管理器里的标题与描述
|
|
57
205
|
└─ icon.svg
|
|
58
206
|
```
|
|
@@ -61,11 +209,16 @@ dsh-skill-mcp-market/
|
|
|
61
209
|
|
|
62
210
|
- DSH 的 `dsh-mcp-client` 只有 `stdio` 与 `streamable-http`,**没有 SSE**;上游只给 SSE 地址的 MCP 装不上。
|
|
63
211
|
- 技能发现是**一层深**:只认 `<root>/<name>/SKILL.md` 和 `<root>/<name>.md`。
|
|
64
|
-
-
|
|
212
|
+
- MCP 定义是标准写法,能否真跑由安装时的探活结果决定,不预先保证;注册表里标记为必填的环境变量会在安装时提示。
|
|
65
213
|
- 市场不伪造下载量/热度:上游没给就显示 0。
|
|
214
|
+
- LobeHub 页面会间歇返回 `402 payment_required`,所以它只作为次要源;MCP 目录不可达时按「上次缓存 → 内置条目」降级,并在面板上提示可直接按名称安装。
|
|
66
215
|
|
|
67
216
|
## 开发
|
|
68
217
|
|
|
69
|
-
改完代码后,`plugin_manager` 无法原地替换模块。用 `plugin_manager set_plugin` 把 `include:dsh-skill-mcp-market`
|
|
218
|
+
改完代码后,`plugin_manager` 无法原地替换模块。用 `plugin_manager set_plugin` 把 `include:dsh-skill-mcp-market` 关一次再开一次,新代码即生效(已在本机验证)。
|
|
219
|
+
|
|
220
|
+
冒烟脚本在 `../tools/`:`smoke-market.mjs`(技能安装/卸载、MCP 配置映射)、`smoke-market-fixes.mjs`(注册表搜索、裸 `owner/repo`、skillsmp)、`smoke-sources.mjs`(各源 provider 行为)、`smoke-demo-mcp.mjs`(MCP 协议自检)。
|
|
221
|
+
|
|
222
|
+
## 许可
|
|
70
223
|
|
|
71
|
-
|
|
224
|
+
MIT
|
package/client.js
CHANGED
|
@@ -172,14 +172,19 @@ window.__ModuleLoader__.load({
|
|
|
172
172
|
pageSize: (options && options.size) || 24,
|
|
173
173
|
});
|
|
174
174
|
setSkills(d.items || []);
|
|
175
|
+
const hints = (d.hints || []).filter(Boolean);
|
|
175
176
|
if (!(d.items || []).length) {
|
|
176
|
-
// An empty market is either "no match" or "upstream refused us"
|
|
177
|
+
// An empty market is either "no match", "needs a keyword" or "upstream refused us".
|
|
177
178
|
const reasons = (d.sourceErrors || []).length
|
|
178
179
|
? d.sourceErrors
|
|
179
|
-
:
|
|
180
|
-
?
|
|
181
|
-
: [
|
|
180
|
+
: hints.length
|
|
181
|
+
? hints
|
|
182
|
+
: (d.degraded || []).length
|
|
183
|
+
? [`部分来源不可用:${d.degraded.map((x) => x.source).join(', ')}`]
|
|
184
|
+
: ['没有找到匹配的技能。'];
|
|
182
185
|
setNotice(reasons.join(';'));
|
|
186
|
+
} else if (hints.length) {
|
|
187
|
+
setNotice(hints.join(';'));
|
|
183
188
|
} else if ((d.degraded || []).length) {
|
|
184
189
|
setNotice(`部分来源不可用:${d.degraded.map((x) => x.source).join(', ')}`);
|
|
185
190
|
} else {
|
package/index.js
CHANGED
|
@@ -233,8 +233,8 @@ export async function apply(ctx, config = {}) {
|
|
|
233
233
|
const marketCache = makeCache(join(dirname(settingsPath()), "cache"));
|
|
234
234
|
|
|
235
235
|
// ---------------------------------------------------------------- skill provider
|
|
236
|
-
// We own the provider so `disabledSkills` (
|
|
237
|
-
//
|
|
236
|
+
// We own the provider so `disabledSkills` (a `skills.disabled: [name]` list, as used by
|
|
237
|
+
// several agent runtimes) is a real configuration item instead of a directory trick.
|
|
238
238
|
let disposeProvider = null;
|
|
239
239
|
let invalidateSkills = null;
|
|
240
240
|
let skillProviderError = "";
|
|
@@ -301,8 +301,8 @@ export async function apply(ctx, config = {}) {
|
|
|
301
301
|
}
|
|
302
302
|
}, "dsh-market.lifecycle");
|
|
303
303
|
|
|
304
|
-
/** Enable/disable a skill by writing settings.disabledSkills
|
|
305
|
-
*
|
|
304
|
+
/** Enable/disable a skill by writing settings.disabledSkills: the name stays on disk but
|
|
305
|
+
* leaves the catalog, then the prompt cache is invalidated. */
|
|
306
306
|
async function setSkillEnabled(slug, enabled) {
|
|
307
307
|
const key = String(slug || "").trim().toLowerCase();
|
|
308
308
|
if (!key) throw new Error("slug 不能为空");
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-mcp-market",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "SKILL
|
|
5
|
+
"description": "DSH 技能市场与 MCP 连接器市场:浏览实时技能源,一键安装 SKILL.md 技能与 MCP 连接器,装完即刻可用、无需重启。SKILL & MCP marketplace for DeepSeek Harness — browse live sources, install skills and MCP connectors, effective immediately.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"dsh",
|
package/src/extra-sources.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// skillsmp — skillsmp.com/api/skills (paged search; Cloudflare-fronted, needs the curl
|
|
6
6
|
// transport because this process's own TLS fingerprint is refused)
|
|
7
7
|
// modelscope (api/v1/dolphin/skills → 404) is intentionally absent: the endpoint is gone.
|
|
8
|
-
import { getJsonEscalating } from "./http.js";
|
|
8
|
+
import { getJsonEscalating, postJsonEscalating } from "./http.js";
|
|
9
9
|
|
|
10
10
|
const UA = { "User-Agent": "dsh-skill-mcp-market/0.4", accept: "application/json" };
|
|
11
11
|
|
|
@@ -60,11 +60,13 @@ export async function searchClawHub({ query = "", pageSize = 24 }) {
|
|
|
60
60
|
|
|
61
61
|
// ---------------------------------------------------------------- trusttools
|
|
62
62
|
export async function searchTrustTools({ query = "", pageSize = 24 }) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
// ai.trusttools.cn drops this process's TLS handshake (same class of failure as
|
|
64
|
+
// skillsmp), so the POST goes through python → curl → fetch instead of plain fetch.
|
|
65
|
+
const data = await postJsonEscalating(
|
|
66
|
+
"https://ai.trusttools.cn/api/skills/search",
|
|
67
|
+
{ q: query, page: 1, pageSize },
|
|
68
|
+
{ timeoutMs: 25000 },
|
|
69
|
+
);
|
|
68
70
|
const rows = Array.isArray(data.data) ? data.data : [];
|
|
69
71
|
const items = rows.map((raw) => {
|
|
70
72
|
const repo = String(raw.repo_url || "").replace(/^https?:\/\/github\.com\//i, "").replace(/\.git$/i, "");
|
package/src/home.js
CHANGED
|
@@ -39,8 +39,8 @@ export function defaultSettings() {
|
|
|
39
39
|
skillDirs: [join(home, "market", "skills")],
|
|
40
40
|
// Where MCP server definitions (<serverName>.json) live.
|
|
41
41
|
mcpDirs: [join(home, "market", "mcp")],
|
|
42
|
-
//
|
|
43
|
-
//
|
|
42
|
+
// The `skills.disabled` idea as a DSH config item: names that stay on disk but are
|
|
43
|
+
// hidden from the catalog. Absent from this list means enabled.
|
|
44
44
|
disabledSkills: [],
|
|
45
45
|
// Extra root that holds the shipped dsh packages (app.asar). Empty means auto-detect.
|
|
46
46
|
shippedRoot: "",
|
package/src/http.js
CHANGED
|
@@ -107,6 +107,14 @@ const PY_FETCH = [
|
|
|
107
107
|
" sys.stdout.write(response.read().decode('utf-8', 'replace'))",
|
|
108
108
|
].join("\n");
|
|
109
109
|
|
|
110
|
+
const PY_POST = [
|
|
111
|
+
"import sys, json, urllib.request",
|
|
112
|
+
"url, body, headers, timeout = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]), float(sys.argv[4])",
|
|
113
|
+
"request = urllib.request.Request(url, data=body.encode('utf-8'), headers=headers, method='POST')",
|
|
114
|
+
"with urllib.request.urlopen(request, timeout=timeout) as response:",
|
|
115
|
+
" sys.stdout.write(response.read().decode('utf-8', 'replace'))",
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
110
118
|
async function isPython(binary) {
|
|
111
119
|
// A relocatable CPython can be present but unusable (its stdlib may be pinned to the
|
|
112
120
|
// build machine, e.g. "AssertionError: SRE module mismatch"), so the probe imports what
|
|
@@ -230,3 +238,84 @@ export async function getJsonEscalating(url, { headers = {}, timeoutMs = 20000 }
|
|
|
230
238
|
}
|
|
231
239
|
throw new Error(errors[0] || "所有传输方式都失败");
|
|
232
240
|
}
|
|
241
|
+
|
|
242
|
+
/** POST a JSON body through curl (some hosts only answer a non-Node TLS client). */
|
|
243
|
+
export async function curlPostText(url, body, { headers = {}, timeoutMs = 15000 } = {}) {
|
|
244
|
+
const merged = { ...BROWSER_HEADERS, "content-type": "application/json", ...headers };
|
|
245
|
+
const payload = JSON.stringify(body ?? {});
|
|
246
|
+
const args = [
|
|
247
|
+
"-s", "-S", "--compressed",
|
|
248
|
+
"--max-time", String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
|
249
|
+
"-X", "POST",
|
|
250
|
+
"--data-binary", "@-",
|
|
251
|
+
];
|
|
252
|
+
for (const [name, value] of Object.entries(merged)) args.push("-H", `${name}: ${value}`);
|
|
253
|
+
args.push(url);
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const child = execFile("curl.exe", args, { maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs + 5000, windowsHide: true },
|
|
256
|
+
(error, stdout) => (error ? reject(error) : resolve(String(stdout || ""))));
|
|
257
|
+
if (child.stdin) {
|
|
258
|
+
child.stdin.end(payload);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** POST a JSON body through the Python transport. */
|
|
264
|
+
export async function pythonPostText(url, body, { headers = {}, timeoutMs = 20000 } = {}) {
|
|
265
|
+
const python = await findPython();
|
|
266
|
+
if (!python) throw new Error("没有可用的 Python 解释器,无法通过该传输取数");
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
execFile(
|
|
269
|
+
python,
|
|
270
|
+
["-c", PY_POST, url, JSON.stringify(body ?? {}), JSON.stringify({ ...BROWSER_HEADERS, "content-type": "application/json", ...headers }), String(timeoutMs / 1000)],
|
|
271
|
+
{
|
|
272
|
+
timeout: timeoutMs + 5000,
|
|
273
|
+
windowsHide: true,
|
|
274
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
275
|
+
env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" },
|
|
276
|
+
},
|
|
277
|
+
(error, stdout, stderr) => {
|
|
278
|
+
if (error) reject(new Error(`python POST 失败:${String(stderr || error.message).slice(0, 200)}`));
|
|
279
|
+
else resolve(String(stdout));
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* POST escalation for hosts whose edge drops this process's TLS handshake: python → curl →
|
|
287
|
+
* Node fetch, first JSON response wins.
|
|
288
|
+
*/
|
|
289
|
+
export async function postJsonEscalating(url, body, { headers = {}, timeoutMs = 20000 } = {}) {
|
|
290
|
+
const parse = (text) => {
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(text);
|
|
293
|
+
} catch {
|
|
294
|
+
throw new Error(`上游返回的不是 JSON:${String(text).slice(0, 80).replace(/\s+/g, " ")}`);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const attempts = [
|
|
298
|
+
async () => parse(await pythonPostText(url, body, { headers, timeoutMs })),
|
|
299
|
+
async () => parse(await curlPostText(url, body, { headers, timeoutMs })),
|
|
300
|
+
async () => {
|
|
301
|
+
const response = await fetch(url, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { ...BROWSER_HEADERS, "content-type": "application/json", ...headers },
|
|
304
|
+
body: JSON.stringify(body ?? {}),
|
|
305
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
306
|
+
});
|
|
307
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
|
308
|
+
return response.json();
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
const errors = [];
|
|
312
|
+
for (const attempt of attempts) {
|
|
313
|
+
try {
|
|
314
|
+
// eslint-disable-next-line no-await-in-loop
|
|
315
|
+
return await attempt();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
errors.push(String((error && error.message) || error).slice(0, 120));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
throw new Error(errors[0] || "所有传输方式都失败");
|
|
321
|
+
}
|
package/src/provider.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Own skill provider: scans user-configured directories, honours the disabled list,
|
|
2
2
|
// and invalidates the catalog whenever the directories or the settings change.
|
|
3
3
|
//
|
|
4
|
-
// This replaces the shipped filesystem provider so that `disabledSkills`
|
|
5
|
-
//
|
|
4
|
+
// This replaces the shipped filesystem provider so that `disabledSkills` becomes a real
|
|
5
|
+
// configuration item rather than a directory trick.
|
|
6
6
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
7
7
|
import { existsSync, watch } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
package/src/sources.js
CHANGED
|
@@ -54,11 +54,58 @@ export function makeCache(dir) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// ------------------------------------------------------------------ skills.sh
|
|
57
|
+
/**
|
|
58
|
+
* skills.sh has no listing API, but its home page links the skills it is featuring right
|
|
59
|
+
* now. Scrape those links so an empty search box still shows a real catalogue instead of
|
|
60
|
+
* a blank market.
|
|
61
|
+
*/
|
|
62
|
+
export async function browseSkillsSh({ pageSize = 24, cache } = {}) {
|
|
63
|
+
let rows = cache ? await cache.get("skills_sh_home", 30 * 60 * 1000) : null;
|
|
64
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
65
|
+
const html = await fetchText("https://www.skills.sh/", 25000);
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
rows = [];
|
|
68
|
+
for (const match of html.matchAll(/\/([A-Za-z0-9_.-]+)\/skills\/([A-Za-z0-9_.-]+)/g)) {
|
|
69
|
+
const key = `${match[1]}/${match[2]}`;
|
|
70
|
+
if (seen.has(key)) continue;
|
|
71
|
+
seen.add(key);
|
|
72
|
+
rows.push({ owner: `${match[1]}/skills`, slug: match[2] });
|
|
73
|
+
}
|
|
74
|
+
if (rows.length > 0 && cache) await cache.put("skills_sh_home", rows);
|
|
75
|
+
}
|
|
76
|
+
const items = rows.slice(0, pageSize).map(({ owner, slug }) => ({
|
|
77
|
+
id: `skills.sh:${owner}/${slug}`,
|
|
78
|
+
slug,
|
|
79
|
+
name: slug,
|
|
80
|
+
description: "",
|
|
81
|
+
source: "skills.sh",
|
|
82
|
+
sourceLabel: "skills.sh",
|
|
83
|
+
owner,
|
|
84
|
+
homepage: `https://www.skills.sh/${owner}/${slug}`,
|
|
85
|
+
upstreamUrl: owner,
|
|
86
|
+
skillDir: "",
|
|
87
|
+
installs: 0,
|
|
88
|
+
installable: true,
|
|
89
|
+
}));
|
|
90
|
+
return { source: "skills.sh", items, total: rows.length };
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
/** skills.sh exposes a real JSON search API (Algolia-backed). */
|
|
58
94
|
export async function searchSkillsSh({ query = "", pageSize = 24, cache }) {
|
|
59
|
-
// The upstream API rejects queries shorter than 2 characters
|
|
60
|
-
// box is a legitimate state, so
|
|
95
|
+
// The upstream search API rejects queries shorter than 2 characters. Browsing with an
|
|
96
|
+
// empty box is a legitimate state, so fall back to the home-page highlights and say so.
|
|
61
97
|
if (query.trim().length < 2) {
|
|
98
|
+
try {
|
|
99
|
+
const browse = await browseSkillsSh({ pageSize, cache });
|
|
100
|
+
if (browse.items.length > 0) {
|
|
101
|
+
return {
|
|
102
|
+
...browse,
|
|
103
|
+
hint: "未输入关键词:以下是 skills.sh 首页当前推荐的技能,输入至少 2 个字符可精确搜索",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// fall through to the plain hint below
|
|
108
|
+
}
|
|
62
109
|
return { source: "skills.sh", items: [], total: 0, hint: "skills.sh 需要至少 2 个字符的关键词" };
|
|
63
110
|
}
|
|
64
111
|
const url = `https://www.skills.sh/api/search?q=${encodeURIComponent(query)}&limit=${pageSize}`;
|
|
@@ -244,10 +291,12 @@ export async function searchSkills({ query, source = "all", pageSize = 24, cache
|
|
|
244
291
|
const wanted = source === "all" ? ALL_SOURCES : [source];
|
|
245
292
|
const items = [];
|
|
246
293
|
const degraded = [];
|
|
294
|
+
const hints = [];
|
|
247
295
|
for (const name of wanted) {
|
|
248
296
|
try {
|
|
249
297
|
if (name === "skills.sh") {
|
|
250
298
|
const result = await searchSkillsSh({ query, pageSize, cache });
|
|
299
|
+
if (result.hint) hints.push(`skills.sh:${result.hint}`);
|
|
251
300
|
items.push(...result.items);
|
|
252
301
|
} else if (name === "clawhub") {
|
|
253
302
|
const result = await searchClawHub({ query, pageSize });
|
|
@@ -287,8 +336,10 @@ export async function searchSkills({ query, source = "all", pageSize = 24, cache
|
|
|
287
336
|
? []
|
|
288
337
|
: degraded.length
|
|
289
338
|
? degraded.map((entry) => `${entry.source} 不可达:${entry.error}`.slice(0, 200))
|
|
290
|
-
:
|
|
291
|
-
|
|
339
|
+
: hints.length
|
|
340
|
+
? hints
|
|
341
|
+
: ["所选市场暂时没有取到数据,可能是上游不可达或响应超时,请稍后重试"];
|
|
342
|
+
return { items, degraded, hints, sourceErrors, total: items.length };
|
|
292
343
|
}
|
|
293
344
|
|
|
294
345
|
/**
|