dsh-skill-store 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cxy9204
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # dsh-skill-store
2
+
3
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/cxy9204/dsh-skill-store)
5
+
6
+ **DeepSeek Harness 社区技能商店** —— 在应用内浏览、搜索并一键安装来自三个数据源的 **14 万+** 个 Agent Skills,按星标 / 下载量排序,中文分类直达。
7
+
8
+ 装了它之后:打开 DSH Web 界面,进入「设置 → 技能商店」,就能直接搜技能、看热度、点一下装到本地,不用再去 GitHub 手动翻仓库、下载 ZIP、解压到 skills 目录。
9
+
10
+ ---
11
+
12
+ ## 三个数据源
13
+
14
+ > 数量为本机 2026-09-02 实测,源站在持续增长,实际以界面显示为准。
15
+
16
+ | 数据源 | 技能数 | 星标 | 下载量 | 分类 | 内容下载 |
17
+ |---|---:|:---:|:---:|:---:|:---:|
18
+ | **SkillHub**(默认推荐) | 134,262 | ✅ | ✅ | ✅ 13 个中英双语大类 | ✅ |
19
+ | **ClawHub** | 6,632 | ✅ 最高 3982★ | ✅ 最高 47 万 | ✅ 1694 个 topics | ✅ |
20
+ | **GitHub 镜像**(awesome-skills-cn) | 2,063 | ❌ 目录非仓库 | ❌ | ⚠️ 11 个 collection 级 | ✅ |
21
+
22
+ 三个生态互不重叠(SkillHub 与 ClawHub 名称匹配率仅 0.2%),因此设计为**并列切换**而非合并。
23
+
24
+ ### GitHub 源的分类标签与上游可信度
25
+
26
+ | Collection | 分类 | 上游仓库 |
27
+ |---|---|---|
28
+ | `anthropics-skills` | Anthropic 官方 | anthropics/skills |
29
+ | `openai-skills` | OpenAI 官方 | openai/skills |
30
+ | `claude-scientific-skills` | 科学研究 | K-Dense-AI/claude-scientific-skills |
31
+ | `huggingface-skills` | HuggingFace | huggingface/skills |
32
+ | `obsidian-skills` | Obsidian | kepano/obsidian-skills |
33
+ | 其余 6 个 | 社区合集 | 见源码 `COLLECTIONS` |
34
+
35
+ 上游仓库 Star 数会显示在卡片上,作为来源可信度参考。
36
+
37
+ ---
38
+
39
+ ## 安装
40
+
41
+ ```bash
42
+ dsh plugin --profile web add dsh-skill-store
43
+ ```
44
+
45
+ 安装后**重启 DSH Web** 即可在设置中看到「技能商店」入口。
46
+
47
+ 从源码安装:
48
+
49
+ ```bash
50
+ git clone https://github.com/cxy9204/dsh-skill-store.git
51
+ cd dsh-skill-store
52
+ npm install
53
+ dsh plugin --profile web add file:./path/to/dsh-skill-store
54
+ ```
55
+
56
+ 卸载:
57
+
58
+ ```bash
59
+ dsh plugin --profile web remove dsh-skill-store
60
+ ```
61
+
62
+ ---
63
+
64
+ ## 功能
65
+
66
+ - **三源切换** —— 顶部按钮切换 GitHub / ClawHub / SkillHub,切到后两者会出现排序下拉框
67
+ - **热度排序** —— 按星标 / 下载量 / 安装量 / 名称排序,热门技能一目了然
68
+ - **分类筛选** —— 下拉选择分类,每项标注技能数量
69
+ - **全文搜索** —— 同时匹配技能名与描述
70
+ - **一键安装** —— 技能写入 `~/.dsh/skills/<name>/SKILL.md`,装完即可用
71
+ - **详情预览** —— 点卡片看完整 SKILL.md 正文与热度数据
72
+ - **渐进式加载** —— 骨架索引秒开,描述与热度在后台逐步填充,进度可查
73
+
74
+ ## 实机输出
75
+
76
+ 插件挂载后的真实运行状态(DSH Web 默认 `127.0.0.1:3080`):
77
+
78
+ ```bash
79
+ $ curl -s http://127.0.0.1:3080/api/dsh-skill-store/status
80
+ ```
81
+
82
+ ```json
83
+ {
84
+ "github": { "total": 2063, "enriched": 0, "pending": 2063,
85
+ "activeMirror": "https://gcore.jsdelivr.net/gh/lingxling/awesome-skills-cn@main" },
86
+ "clawhub": { "loaded": true, "count": 6632, "builtAt": "2026-09-02T07:10:44.288Z" },
87
+ "skillhub":{ "loaded": true, "count": 2005, "builtAt": "2026-09-02T07:08:05.871Z",
88
+ "progress": { "pages": 20, "fetched": 2005, "total": 134262 } }
89
+ }
90
+ ```
91
+
92
+ `skillhub.count` 小于 `progress.total` 是渐进加载的正常中间态:索引边跑边用,可用技能数持续增长直至全量。
93
+
94
+ 单个技能的真实元数据(SkillHub API 原文截取):
95
+
96
+ ```json
97
+ {
98
+ "name": "B站视频分析",
99
+ "slug": "bilibili-content-research",
100
+ "canonicalName": "@user_825d9e7e/bilibili-content-research",
101
+ "category": "content-creation",
102
+ "subCategories": ["自媒体运营", "选题策划"],
103
+ "stars": 1, "downloads": 167, "version": "1.0.2"
104
+ }
105
+ ```
106
+
107
+ 界面入口:**设置 → 技能商店**。顶部三源切换按钮、搜索框、分类下拉、排序下拉(ClawHub / SkillHub 下出现),卡片显示名称、描述与星标 / 下载量,点击卡片弹出详情预览与安装按钮。
108
+
109
+ ## 设计说明
110
+
111
+ **为什么 GitHub 源没有星标?** 单个技能在仓库里是一个**目录**而不是一个仓库,本身不存在独立的 star 字段。实测原始仓库 frontmatter 中 `tags` 覆盖率仅 2%、`category` 6%,全仓库只有 8 个不同 tag —— 所以「全是未分类」是数据源缺陷而非 bug。本插件改为按 collection 归类并标注上游仓库 Star 作为替代。
112
+
113
+ **网络适配(国内可用)**:`raw.githubusercontent.com` 在国内常被 DNS 阻断(报错 `ENOTFOUND`,且**不抛异常、只表现为数据为空**,极难排查)。本插件改用 jsDelivr CDN 多镜像回退,并记忆上次成功的源:
114
+
115
+ ```
116
+ cdn.jsdelivr.net → gcore.jsdelivr.net → fastly.jsdelivr.net → raw.githubusercontent.com
117
+ ```
118
+
119
+ **两阶段加载**:全量抓取 13 万个 SKILL.md 不现实。骨架只遍历目录树(十几秒),详情按需单拉 + 后台渐进填充,每 60 条落盘,重启续跑。
120
+
121
+ ---
122
+
123
+ ## 权限与外部服务
124
+
125
+ 本插件会向以下外部服务发出**只读** HTTPS 请求:
126
+
127
+ | 服务 | 用途 |
128
+ |---|---|
129
+ | `api.github.com` | 读取 awesome-skills-cn 目录树 |
130
+ | `*.jsdelivr.net` | 读取 SKILL.md 文件内容(CDN 镜像) |
131
+ | `clawhub.ai` | ClawHub 技能注册表与文件内容 |
132
+ | `api.skillhub.cn` | SkillHub 技能注册表与文件内容 |
133
+
134
+ 本地写入仅限:
135
+ - `~/.dsh/skills/` —— 安装技能的目标目录
136
+ - `~/.dsh/skill-store-cache/` —— 索引缓存
137
+
138
+ **无遥测、无数据上传、不需要任何账号或 Token。** 设置 `GITHUB_TOKEN` 环境变量只是把 GitHub API 限额从 60 次/小时提升到 5000 次/小时,不设置也能正常使用。
139
+
140
+ ## 兼容性
141
+
142
+ - **Profile**:`web`(需要 web 界面,headless 下 UI 不可用,API 端点仍可用)
143
+ - **注入依赖**:`@deepseek-ai/dsh-client-ui-settings`、`@deepseek-ai/dsh-client-ui-slots`
144
+ - **React**:18.x(可选 peerDependency,由宿主提供)
145
+ - **Node.js**:≥ 20(依赖内置 `fetch` 与 ESM)
146
+
147
+ ## 开发
148
+
149
+ ```bash
150
+ git clone https://github.com/cxy9204/dsh-skill-store.git
151
+ cd dsh-skill-store
152
+ node --check lib/index.js # 语法检查
153
+ ```
154
+
155
+ 源码结构:
156
+
157
+ ```
158
+ lib/index.js 主插件:路由、GitHub 源索引、安装逻辑
159
+ lib/client.js 前端 UI(React,宿主注入)
160
+ lib/clawhub.js ClawHub 数据源
161
+ lib/skillhub.js SkillHub 数据源
162
+ cordis.patch.yml DSH 插件装载声明
163
+ ```
164
+
165
+ 调试接口:
166
+
167
+ ```bash
168
+ curl http://localhost:<port>/api/dsh-skill-store/status
169
+ curl "http://localhost:<port>/api/dsh-skill-store/sources"
170
+ ```
171
+
172
+ ## 常见问题
173
+
174
+ **首次打开显示 0 个技能?** SkillHub 源首次索引约需 1-3 分钟在后台跑,刷新即可。访问 `/status` 端点可查看实时进度。
175
+
176
+ **ClawHub 技能安装失败?** ClawHub 存在同名歧义(如 `github` 有 6 个作者),插件会自动选第一个(通常最热)。若装错,请从详情页确认作者。
177
+
178
+ **安装后界面没出现?** DSH 插件需要重启 Web 进程才能完成前端注入。
179
+
180
+ ## 许可证
181
+
182
+ MIT © 2026 cxy9204
183
+
184
+ 本项目为独立社区项目,与 DeepSeek AI 无隶属关系。
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-skill-store
3
+ name: dsh-skill-store
package/lib/clawhub.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * @file ClawHub registry source for dsh-skill-store.
3
+ *
4
+ * ClawHub (https://clawhub.ai) exposes a public read API that carries exactly
5
+ * the metadata the GitHub mirror lacks: per-skill stars, downloads, installs
6
+ * and a real topic taxonomy (1694 distinct topics observed, including Chinese
7
+ * ones). Content is retrievable through the `file` endpoint, so skills from
8
+ * this source can be installed too.
9
+ *
10
+ * Endpoints used:
11
+ * GET /api/v1/skills?limit=&cursor=&sort=stars|downloads → list
12
+ * GET /api/v1/skills/{slug}[?owner={handle}] → detail
13
+ * GET /api/v1/skills/{slug}/file?owner=&path=&version= → raw file
14
+ *
15
+ * A slug may be ambiguous (several authors published the same name). The API
16
+ * answers 409 AMBIGUOUS_SKILL_SLUG with a `matches` array; we pick the first
17
+ * match (the registry orders them by relevance) and remember the owner.
18
+ */
19
+
20
+ const BASE = "https://clawhub.ai/api/v1";
21
+ const UA = "dsh-skill-store/0.1.0";
22
+
23
+ /** In-memory fallback cache; disk cache is managed by the caller. */
24
+ let _cache = null;
25
+ let _fetching = false;
26
+ let _lastError = null;
27
+
28
+ async function getJson(url) {
29
+ try {
30
+ const res = await fetch(url, {
31
+ headers: { "User-Agent": UA, Accept: "application/json" },
32
+ signal: AbortSignal.timeout(20000),
33
+ });
34
+ const text = await res.text();
35
+ let json = null;
36
+ try { json = JSON.parse(text); } catch { /* non-JSON body */ }
37
+ return { status: res.status, json, text };
38
+ } catch (e) {
39
+ return { status: 0, json: null, text: String(e) };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolve the owning handle for a slug. Tries the bare slug first; on 409 the
45
+ * response enumerates the competing authors and we take the first one.
46
+ * @returns {Promise<{owner: string|null, detail: object|null}>}
47
+ */
48
+ async function resolveOwner(slug) {
49
+ const bare = await getJson(`${BASE}/skills/${encodeURIComponent(slug)}`);
50
+ if (bare.status === 200 && bare.json?.skill) {
51
+ return { owner: bare.json.owner?.handle ?? null, detail: bare.json };
52
+ }
53
+
54
+ if (bare.status === 409) {
55
+ const matches = bare.json?.matches || [];
56
+ if (matches.length > 0) {
57
+ const pick = matches[0];
58
+ const withOwner = await getJson(
59
+ `${BASE}/skills/${encodeURIComponent(slug)}?owner=${encodeURIComponent(pick.ownerHandle)}`
60
+ );
61
+ if (withOwner.status === 200 && withOwner.json?.skill) {
62
+ return { owner: pick.ownerHandle, detail: withOwner.json };
63
+ }
64
+ return { owner: pick.ownerHandle, detail: null };
65
+ }
66
+ }
67
+ return { owner: null, detail: null };
68
+ }
69
+
70
+ /**
71
+ * Walk the whole registry with cursor pagination.
72
+ * @param {{maxPages?: number, sort?: string}} opts
73
+ */
74
+ async function fetchAll(opts = {}) {
75
+ const { maxPages = 60, sort = "" } = opts;
76
+ const out = new Map();
77
+ let cursor = null;
78
+ let pages = 0;
79
+
80
+ while (pages < maxPages) {
81
+ const qs = new URLSearchParams({ limit: "100" });
82
+ if (sort) qs.set("sort", sort);
83
+ if (cursor) qs.set("cursor", cursor);
84
+
85
+ const r = await getJson(`${BASE}/skills?${qs.toString()}`);
86
+ if (r.status !== 200 || !r.json?.items) {
87
+ _lastError = `list page ${pages + 1}: HTTP ${r.status}`;
88
+ break;
89
+ }
90
+ for (const item of r.json.items) {
91
+ if (item?.slug) out.set(item.slug, item);
92
+ }
93
+ pages++;
94
+ if (!r.json.nextCursor || r.json.items.length === 0) break;
95
+ cursor = r.json.nextCursor;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Build (or return) the registry index.
102
+ * @param {boolean} force
103
+ * @param {{maxPages?: number}} opts
104
+ */
105
+ async function getRegistry(force = false, opts = {}) {
106
+ if (!force && _cache) return _cache;
107
+ if (_fetching) return _cache || emptyRegistry();
108
+
109
+ _fetching = true;
110
+ _lastError = null;
111
+ try {
112
+ // Two passes: stars ordering surfaces popular skills even if the walk is
113
+ // cut short, then a full walk collects everything.
114
+ const byStars = await fetchAll({ sort: "stars", maxPages: 8, ...opts });
115
+ const all = await fetchAll(opts);
116
+ for (const [k, v] of byStars) if (!all.has(k)) all.set(k, v);
117
+
118
+ const skills = {};
119
+ const categories = {};
120
+ for (const [slug, item] of all) {
121
+ const st = item.stats || {};
122
+ const topics = Array.isArray(item.topics) ? item.topics.filter(Boolean) : [];
123
+ skills[slug] = {
124
+ name: slug,
125
+ displayName: item.displayName || slug,
126
+ description: item.summary || item.description || slug,
127
+ source: "clawhub",
128
+ stars: st.stars ?? 0,
129
+ downloads: st.downloads ?? 0,
130
+ installs: st.installs ?? 0,
131
+ comments: st.comments ?? 0,
132
+ topics,
133
+ version: item.latestVersion?.version ?? null,
134
+ updatedAt: item.updatedAt ?? null,
135
+ slug,
136
+ owner: null, // resolved lazily at install time
137
+ browseUrl: `https://clawhub.ai/skills/${encodeURIComponent(slug)}`,
138
+ enriched: true, // metadata comes free with the listing
139
+ };
140
+ const cats = topics.length ? topics : ["uncategorized"];
141
+ for (const c of cats) {
142
+ (categories[c] = categories[c] || []).push(slug);
143
+ }
144
+ }
145
+
146
+ _cache = {
147
+ builtAt: new Date().toISOString(),
148
+ count: Object.keys(skills).length,
149
+ skills,
150
+ categories,
151
+ _error: _lastError,
152
+ };
153
+ return _cache;
154
+ } catch (e) {
155
+ _lastError = e.message;
156
+ return emptyRegistry(e.message);
157
+ } finally {
158
+ _fetching = false;
159
+ }
160
+ }
161
+
162
+ function emptyRegistry(error) {
163
+ return {
164
+ builtAt: new Date().toISOString(),
165
+ count: 0,
166
+ skills: {},
167
+ categories: {},
168
+ _error: error || _lastError || null,
169
+ };
170
+ }
171
+
172
+ function query({ search, category, limit = 50, offset = 0, sort = "stars" } = {}) {
173
+ const reg = _cache || emptyRegistry();
174
+ let names = Object.keys(reg.skills);
175
+
176
+ if (category && reg.categories[category]) {
177
+ const inCat = new Set(reg.categories[category]);
178
+ names = names.filter((n) => inCat.has(n));
179
+ }
180
+ if (search) {
181
+ const q = search.toLowerCase();
182
+ names = names.filter((n) => {
183
+ const s = reg.skills[n];
184
+ return n.toLowerCase().includes(q)
185
+ || String(s.displayName || "").toLowerCase().includes(q)
186
+ || String(s.description || "").toLowerCase().includes(q)
187
+ || (s.topics || []).some((t) => String(t).toLowerCase().includes(q));
188
+ });
189
+ }
190
+
191
+ if (sort === "downloads") names.sort((a, b) => (reg.skills[b].downloads || 0) - (reg.skills[a].downloads || 0));
192
+ else if (sort === "installs") names.sort((a, b) => (reg.skills[b].installs || 0) - (reg.skills[a].installs || 0));
193
+ else if (sort === "recent") names.sort((a, b) => (reg.skills[b].updatedAt || 0) - (reg.skills[a].updatedAt || 0));
194
+ else names.sort((a, b) => (reg.skills[b].stars || 0) - (reg.skills[a].stars || 0));
195
+
196
+ return {
197
+ entries: names.slice(offset, offset + limit).map((n) => reg.skills[n]).filter(Boolean),
198
+ total: names.length,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Download a skill file. Resolves the owning handle when the slug is ambiguous.
204
+ * @param {string} slug
205
+ * @param {string} [path]
206
+ */
207
+ async function fetchFile(slug, path = "SKILL.md") {
208
+ const reg = _cache;
209
+ const entry = reg?.skills?.[slug];
210
+ const version = entry?.version || null;
211
+
212
+ const { owner, detail } = await resolveOwner(slug);
213
+ const qs = new URLSearchParams();
214
+ if (owner) qs.set("owner", owner);
215
+ qs.set("path", path);
216
+ if (version) qs.set("version", version);
217
+
218
+ const r = await getJson(`${BASE}/skills/${encodeURIComponent(slug)}/file?${qs.toString()}`);
219
+ if (r.status === 200 && r.text) {
220
+ // remember owner for next time
221
+ if (entry && owner) entry.owner = owner;
222
+ return { content: r.text, owner, version, detail };
223
+ }
224
+
225
+ // Some skills store the instructions in a differently named file.
226
+ if (path === "SKILL.md") {
227
+ for (const alt of ["skill.md", "README.md"]) {
228
+ const qs2 = new URLSearchParams();
229
+ if (owner) qs2.set("owner", owner);
230
+ qs2.set("path", alt);
231
+ if (version) qs2.set("version", version);
232
+ const r2 = await getJson(`${BASE}/skills/${encodeURIComponent(slug)}/file?${qs2.toString()}`);
233
+ if (r2.status === 200 && r2.text) {
234
+ if (entry && owner) entry.owner = owner;
235
+ return { content: r2.text, owner, version, path: alt, detail };
236
+ }
237
+ }
238
+ }
239
+ return { content: null, owner, version, error: `HTTP ${r.status}` };
240
+ }
241
+
242
+ function status() {
243
+ const reg = _cache;
244
+ return {
245
+ loaded: !!reg,
246
+ fetching: _fetching,
247
+ count: reg?.count ?? 0,
248
+ builtAt: reg?.builtAt ?? null,
249
+ error: reg?._error ?? _lastError ?? null,
250
+ };
251
+ }
252
+
253
+ export { getRegistry, query, fetchFile, resolveOwner, status, BASE };