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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * @file SkillHub (skillhub.cn) registry source for dsh-skill-store.
3
+ *
4
+ * SkillHub is Tencent's Chinese-language skill community. Its public read API
5
+ * exposes a much richer dataset than the GitHub mirror: 131k+ skills with
6
+ * per-skill stars / downloads / installs, a bilingual 13-category taxonomy,
7
+ * Chinese descriptions, and downloadable SKILL.md content.
8
+ *
9
+ * NOTE the base host is `api.skillhub.cn`, and the two path families differ:
10
+ * /api/skills — listing (no version segment)
11
+ * /api/v1/skills/... — detail / files / file
12
+ * Ownership is passed as a `namespace={handle}` query param, NOT as a path
13
+ * segment and NOT as `@handle/slug`.
14
+ *
15
+ * Endpoints used:
16
+ * GET /api/skills?page=&pageSize=&sortBy=&order= → list
17
+ * GET /api/v1/categories → taxonomy
18
+ * GET /api/v1/showcase/hot → curated hot 100
19
+ * GET /api/v1/showcase/trending → curated trending 100
20
+ * GET /api/v1/skills/{slug}?namespace={ns} → detail
21
+ * GET /api/v1/skills/{slug}/files?namespace={ns} → file manifest
22
+ * GET /api/v1/skills/{slug}/file?path=SKILL.md&namespace= → raw content
23
+ */
24
+
25
+ const BASE = "https://api.skillhub.cn";
26
+ const UA = "dsh-skill-store/0.1.0";
27
+
28
+ /** category key → { name, nameEn } */
29
+ const CATEGORY_LABELS = {
30
+ "pay-skill": { name: "支付技能", nameEn: "Pay Skill" },
31
+ "office-efficiency": { name: "办公效率", nameEn: "Office Efficiency" },
32
+ "content-creation": { name: "内容创作", nameEn: "Content Creation" },
33
+ "dev-programming": { name: "开发编程", nameEn: "Development" },
34
+ "data-analysis": { name: "数据分析", nameEn: "Data Analysis" },
35
+ "design-media": { name: "设计多媒体", nameEn: "Design & Media" },
36
+ "ai-agent": { name: "AI Agent", nameEn: "AI Agent" },
37
+ "knowledge-management":{ name: "知识管理", nameEn: "Knowledge Management" },
38
+ "business-ops": { name: "商业运营", nameEn: "Business Operations" },
39
+ "education": { name: "教育学习", nameEn: "Education" },
40
+ "professional": { name: "行业专业", nameEn: "Professional" },
41
+ "it-ops-security": { name: "IT 运维与安全", nameEn: "IT Ops & Security" },
42
+ "life-service": { name: "生活服务", nameEn: "Life Service" },
43
+ };
44
+
45
+ let _cache = null;
46
+ let _fetching = false;
47
+ let _lastError = null;
48
+ let _progress = { pages: 0, fetched: 0 };
49
+
50
+ async function getJson(url) {
51
+ try {
52
+ const res = await fetch(url, {
53
+ headers: { "User-Agent": UA, Accept: "application/json", Referer: "https://skillhub.cn/" },
54
+ signal: AbortSignal.timeout(20000),
55
+ });
56
+ const text = await res.text();
57
+ let json = null;
58
+ try { json = JSON.parse(text); } catch { /* plain text is valid for /file */ }
59
+ return { status: res.status, json, text };
60
+ } catch (e) {
61
+ return { status: 0, json: null, text: String(e) };
62
+ }
63
+ }
64
+
65
+ /** Fetch one page of the listing. */
66
+ async function fetchPage(page, pageSize, sortBy, order) {
67
+ const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
68
+ if (sortBy) qs.set("sortBy", sortBy);
69
+ if (order) qs.set("order", order);
70
+ const r = await getJson(`${BASE}/api/skills?${qs.toString()}`);
71
+ if (r.status !== 200 || !r.json?.data?.skills) return null;
72
+ return { skills: r.json.data.skills, total: r.json.data.total ?? 0 };
73
+ }
74
+
75
+ function normalise(raw) {
76
+ const cat = raw.category || "";
77
+ const meta = CATEGORY_LABELS[cat];
78
+ const subCats = Array.isArray(raw.subCategories)
79
+ ? raw.subCategories.map((s) => s.name || s.key).filter(Boolean)
80
+ : [];
81
+ const tags = [];
82
+ if (meta?.name) tags.push(meta.name);
83
+ for (const s of subCats) if (!tags.includes(s)) tags.push(s);
84
+ if (Array.isArray(raw.tags)) for (const t of raw.tags) if (!tags.includes(t)) tags.push(String(t));
85
+
86
+ // Skills mirrored from ClawHub carry a canonicalName like
87
+ // "@clawhub_pskoett/self-improving-agent" while `ownerName` is the bare
88
+ // upstream handle ("pskoett"). The file endpoint only accepts the namespaced
89
+ // form, so prefer the handle embedded in canonicalName.
90
+ let handle = raw.ownerName || raw.namespace?.handle || null;
91
+ const canon = typeof raw.namespace?.canonicalName === "string" ? raw.namespace.canonicalName : null;
92
+ if (canon && canon.startsWith("@")) {
93
+ const inner = canon.slice(1);
94
+ const slash = inner.indexOf("/");
95
+ if (slash > 0) handle = inner.slice(0, slash); // e.g. "clawhub_pskoett"
96
+ }
97
+ return {
98
+ name: raw.slug,
99
+ displayName: raw.name || raw.slug,
100
+ description: raw.description_zh || raw.description || raw.slug,
101
+ source: "skillhub",
102
+ slug: raw.slug,
103
+ namespace: handle,
104
+ canonicalName: raw.namespace?.canonicalName || (handle ? `@${handle}/${raw.slug}` : null),
105
+ category: cat,
106
+ categoryLabel: meta?.name || cat || "未分类",
107
+ topics: tags,
108
+ stars: raw.stars ?? 0,
109
+ downloads: raw.downloads ?? 0,
110
+ installs: raw.installs ?? 0,
111
+ score: raw.score ?? 0,
112
+ version: raw.version ?? null,
113
+ verified: !!raw.verified,
114
+ iconUrl: raw.iconUrl || null,
115
+ updatedAt: raw.updated_at ?? null,
116
+ createdAt: raw.created_at ?? null,
117
+ browseUrl: handle
118
+ ? `https://skillhub.cn/skills/${handle}/${raw.slug}`
119
+ : `https://skillhub.cn/skills/${raw.slug}`,
120
+ enriched: true, // listing already carries full metadata
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Build (or return) the registry index.
126
+ *
127
+ * The registry has 130k+ entries, so a full walk is impractical. We take the
128
+ * curated showcase feeds first, then walk the listing ordered by downloads.
129
+ *
130
+ * @param {boolean} force
131
+ * @param {{maxPages?: number, pageSize?: number}} opts
132
+ */
133
+ async function getRegistry(force = false, opts = {}) {
134
+ if (!force && _cache) return _cache;
135
+ if (_fetching) return _cache || emptyRegistry();
136
+
137
+ const { maxPages = 20, pageSize = 100 } = opts;
138
+ _fetching = true;
139
+ _lastError = null;
140
+ _progress = { pages: 0, fetched: 0 };
141
+
142
+ try {
143
+ const skills = {};
144
+
145
+ // Curated feeds: hot + trending (100 each).
146
+ for (const sec of ["hot", "trending"]) {
147
+ const r = await getJson(`${BASE}/api/v1/showcase/${sec}`);
148
+ if (r.status === 200 && Array.isArray(r.json?.skills)) {
149
+ for (const raw of r.json.skills) {
150
+ const n = normalise(raw);
151
+ if (n.name) skills[n.name] = { ...n, showcase: sec };
152
+ }
153
+ }
154
+ }
155
+
156
+ // Listing walk, most-downloaded first.
157
+ for (let page = 1; page <= maxPages; page++) {
158
+ const p = await fetchPage(page, pageSize, "downloads", "desc");
159
+ if (!p || p.skills.length === 0) break;
160
+ for (const raw of p.skills) {
161
+ const n = normalise(raw);
162
+ if (n.name && !skills[n.name]) skills[n.name] = n;
163
+ }
164
+ _progress.pages = page;
165
+ _progress.fetched = Object.keys(skills).length;
166
+ _progress.total = p.total;
167
+ }
168
+
169
+ const categories = {};
170
+ for (const [name, s] of Object.entries(skills)) {
171
+ const cats = s.topics.length ? s.topics : ["未分类"];
172
+ for (const c of cats) {
173
+ (categories[c] = categories[c] || []).push(name);
174
+ }
175
+ }
176
+
177
+ _cache = {
178
+ builtAt: new Date().toISOString(),
179
+ count: Object.keys(skills).length,
180
+ skills,
181
+ categories,
182
+ _error: _lastError,
183
+ };
184
+ return _cache;
185
+ } catch (e) {
186
+ _lastError = e.message;
187
+ return emptyRegistry(e.message);
188
+ } finally {
189
+ _fetching = false;
190
+ }
191
+ }
192
+
193
+ function emptyRegistry(error) {
194
+ return {
195
+ builtAt: new Date().toISOString(),
196
+ count: 0,
197
+ skills: {},
198
+ categories: {},
199
+ _error: error || _lastError || null,
200
+ };
201
+ }
202
+
203
+ function query({ search, category, limit = 50, offset = 0, sort = "stars" } = {}) {
204
+ const reg = _cache || emptyRegistry();
205
+ let names = Object.keys(reg.skills);
206
+
207
+ if (category && reg.categories[category]) {
208
+ const inCat = new Set(reg.categories[category]);
209
+ names = names.filter((n) => inCat.has(n));
210
+ }
211
+ if (search) {
212
+ const q = search.toLowerCase();
213
+ names = names.filter((n) => {
214
+ const s = reg.skills[n];
215
+ return n.toLowerCase().includes(q)
216
+ || String(s.displayName || "").toLowerCase().includes(q)
217
+ || String(s.description || "").toLowerCase().includes(q)
218
+ || (s.topics || []).some((t) => String(t).toLowerCase().includes(q));
219
+ });
220
+ }
221
+
222
+ if (sort === "downloads") names.sort((a, b) => (reg.skills[b].downloads || 0) - (reg.skills[a].downloads || 0));
223
+ else if (sort === "installs") names.sort((a, b) => (reg.skills[b].installs || 0) - (reg.skills[a].installs || 0));
224
+ else if (sort === "recent") names.sort((a, b) => (reg.skills[b].updatedAt || 0) - (reg.skills[a].updatedAt || 0));
225
+ else names.sort((a, b) => (reg.skills[b].stars || 0) - (reg.skills[a].stars || 0));
226
+
227
+ return {
228
+ entries: names.slice(offset, offset + limit).map((n) => reg.skills[n]).filter(Boolean),
229
+ total: names.length,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Download a skill's SKILL.md.
235
+ * @param {string} slug
236
+ * @param {string} [namespace] owner handle; resolved from the index if omitted
237
+ */
238
+ async function fetchFile(slug, namespace = null) {
239
+ const entry = _cache?.skills?.[slug];
240
+ const ns = namespace || entry?.namespace || null;
241
+ const version = entry?.version || null;
242
+
243
+ /**
244
+ * Try one combination of namespace/version/path.
245
+ * Returns the response object; caller inspects status.
246
+ */
247
+ const attempt = (p, n, v) => {
248
+ const qs = new URLSearchParams({ path: p });
249
+ if (n) qs.set("namespace", n);
250
+ if (v) qs.set("version", v);
251
+ return getJson(`${BASE}/api/v1/skills/${encodeURIComponent(slug)}/file?${qs.toString()}`);
252
+ };
253
+
254
+ // Namespaced lookups first, then namespace-less (ClawHub-mirrored skills are
255
+ // only reachable without a namespace), each with and without version.
256
+ const candidates = [];
257
+ for (const n of [ns, null]) {
258
+ for (const v of [version, null]) {
259
+ candidates.push({ path: "SKILL.md", ns: n, ver: v });
260
+ }
261
+ }
262
+ // Deduplicate (ns may already be null).
263
+ const seen = new Set();
264
+ let last = null;
265
+ for (const c of candidates) {
266
+ const key = `${c.path}|${c.ns}|${c.ver}`;
267
+ if (seen.has(key)) continue;
268
+ seen.add(key);
269
+ last = await attempt(c.path, c.ns, c.ver);
270
+ if (last.status === 200 && last.text) {
271
+ return { content: last.text, namespace: c.ns, version: c.ver, path: c.path };
272
+ }
273
+ }
274
+
275
+ // Last resort: read the file manifest and pull the first markdown entry.
276
+ for (const n of [ns, null]) {
277
+ const mq = new URLSearchParams();
278
+ if (n) mq.set("namespace", n);
279
+ const m = await getJson(`${BASE}/api/v1/skills/${encodeURIComponent(slug)}/files?${mq.toString()}`);
280
+ const files = m.json?.files || [];
281
+ for (const f of files) {
282
+ if (!/\.md$/i.test(f.path)) continue;
283
+ const rf = await attempt(f.path, n, null);
284
+ if (rf.status === 200 && rf.text) {
285
+ return { content: rf.text, namespace: n, version: null, path: f.path };
286
+ }
287
+ }
288
+ }
289
+
290
+ return { content: null, namespace: ns, version, error: `HTTP ${last?.status ?? 0}` };
291
+ }
292
+
293
+ function status() {
294
+ return {
295
+ loaded: !!_cache,
296
+ fetching: _fetching,
297
+ count: _cache?.count ?? 0,
298
+ builtAt: _cache?.builtAt ?? null,
299
+ progress: { ..._progress },
300
+ error: _cache?._error ?? _lastError ?? null,
301
+ };
302
+ }
303
+
304
+ export { getRegistry, query, fetchFile, status, CATEGORY_LABELS, BASE };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "dsh-skill-store",
3
+ "version": "0.1.0",
4
+ "description": "Community skill marketplace for DeepSeek Harness: browse, search, and one-click install skills from awesome-skills-cn and the wider Claude Code / OpenClaw skill ecosystem.",
5
+ "license": "MIT",
6
+ "author": "cxy9204",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cxy9204/dsh-skill-store.git"
10
+ },
11
+ "homepage": "https://github.com/cxy9204/dsh-skill-store#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/cxy9204/dsh-skill-store/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "lib/index.js",
17
+ "exports": {
18
+ ".": "./lib/index.js",
19
+ "./client": "./lib/client.js",
20
+ "./package.json": "./package.json",
21
+ "./cordis.patch.yml": "./cordis.patch.yml"
22
+ },
23
+ "files": [
24
+ "LICENSE",
25
+ "README.md",
26
+ "lib",
27
+ "cordis.patch.yml"
28
+ ],
29
+ "dependencies": {},
30
+ "peerDependencies": {
31
+ "react": "^18.0.0",
32
+ "react-dom": "^18.0.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "react": { "optional": true },
36
+ "react-dom": { "optional": true }
37
+ },
38
+ "dsh": {
39
+ "client": {
40
+ "platform": "web",
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-ui-settings",
43
+ "@deepseek-ai/dsh-client-ui-slots"
44
+ ]
45
+ },
46
+ "bundle": { "patch": "./cordis.patch.yml" }
47
+ },
48
+ "keywords": [
49
+ "deepseek-harness",
50
+ "dsh",
51
+ "dsh-plugin",
52
+ "skill",
53
+ "skill-store",
54
+ "skill-marketplace",
55
+ "community-skills"
56
+ ]
57
+ }