@nanmicoder/dsh-skills-hub 0.0.2 → 0.0.4
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/CONTRIBUTING.md +7 -3
- package/README.md +24 -12
- package/README_EN.md +24 -12
- package/README_ZH.md +1 -1
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +6 -6
- package/docs/CATALOG.md +39 -0
- package/docs/COMPATIBILITY.md +31 -1
- package/docs/CONTRACT.md +15 -10
- package/docs/RELEASE.md +4 -4
- package/lib/catalog/catalog.js +89 -0
- package/lib/catalog/skills.json +8673 -0
- package/lib/client.js +2549 -1819
- package/lib/client.js.map +1 -1
- package/lib/market/clawhub-provider.js +139 -22
- package/lib/market/install-service.js +19 -8
- package/lib/market/market-service.js +88 -9
- package/lib/market/skillhub-provider.js +6 -2
- package/lib/skills/installed.js +5 -0
- package/lib/types/catalog/catalog.d.ts +73 -0
- package/lib/types/client/api.d.ts +12 -3
- package/lib/types/client/components/CategoryBar.d.ts +13 -0
- package/lib/types/client/components/FilterBar.d.ts +4 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +14 -11
- package/lib/types/client/components/InstalledSkillDetail.d.ts +1 -1
- package/lib/types/client/components/InstalledSkills.d.ts +1 -1
- package/lib/types/client/components/MarketSkillDetail.d.ts +34 -0
- package/lib/types/client/components/SkillDetailShell.d.ts +66 -43
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/skill-insights.d.ts +30 -0
- package/lib/types/client/state.d.ts +23 -3
- package/lib/types/market/clawhub-provider.d.ts +15 -0
- package/lib/types/market/install-service.d.ts +1 -0
- package/lib/types/market/market-service.d.ts +25 -3
- package/lib/types/market/types.d.ts +29 -0
- package/lib/types/skills/installed.d.ts +4 -0
- package/lib/types/web-routes.d.ts +4 -13
- package/lib/web-routes.js +48 -19
- package/package.json +26 -27
- package/release-notes/v0.0.3.md +12 -0
- package/release-notes/v0.0.4.md +11 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +0 -11
- package/lib/types/client/components/SecurityBadge.d.ts +0 -16
- package/lib/types/client/components/SkillDetailView.d.ts +0 -19
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* - GET /api/v1/skills/{slug} → {skill, latestVersion, owner, metadata, moderation}
|
|
8
8
|
* - GET /api/v1/skills/{slug}/versions/{v} → {version:{license, files[], security}}
|
|
9
9
|
* - GET /api/v1/skills/{slug}/file?path= → raw file text
|
|
10
|
+
*
|
|
11
|
+
* Slugs are not unique: every endpoint accepts `?owner=`, and without it a
|
|
12
|
+
* shared slug answers 409 AMBIGUOUS_SKILL_SLUG. See `clawhubOwnerFor`.
|
|
10
13
|
*/
|
|
14
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
11
15
|
import { getProviderBase, providerFetch, providerFetchJson, readResponseTextWithLimit, MarketHttpError, } from "./provider-fetch.js";
|
|
12
16
|
import { markSourceHealth } from "./cache.js";
|
|
13
17
|
import { detectMarketLanguage, MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError, skillId, } from "./types.js";
|
|
@@ -344,14 +348,77 @@ function usableFileEntries(files) {
|
|
|
344
348
|
}
|
|
345
349
|
return out;
|
|
346
350
|
}
|
|
347
|
-
// ClawHub slugs are not unique across owners.
|
|
348
|
-
//
|
|
349
|
-
//
|
|
351
|
+
// ClawHub slugs are not unique across owners. Every read is therefore made
|
|
352
|
+
// for one owner, chosen in this order:
|
|
353
|
+
// 1. the owner the reader's request names (the card it came from);
|
|
354
|
+
// 2. the owner the curated catalogue pins for that slug;
|
|
355
|
+
// 3. an owner resolved earlier for the slug (remembered below);
|
|
356
|
+
// 4. otherwise, on 409 AMBIGUOUS_SKILL_SLUG, the most-downloaded candidate.
|
|
357
|
+
// The registry lists candidates oldest-first, and for popular slugs that is
|
|
358
|
+
// routinely a later copy by another author — "first match" opened (and
|
|
359
|
+
// installed) somebody else's skill.
|
|
350
360
|
const ownerCache = new Map();
|
|
361
|
+
/**
|
|
362
|
+
* Owners the curated catalogue already knows. A catalogue entry names the exact
|
|
363
|
+
* skill it recommends, so its owner must win over any guessing.
|
|
364
|
+
*/
|
|
365
|
+
const ownerHints = new Map();
|
|
366
|
+
/** The owner one detail/file/install operation was asked for. */
|
|
367
|
+
const requestedOwner = new AsyncLocalStorage();
|
|
368
|
+
/** Shape of a ClawHub owner handle (also the route's validation rule). */
|
|
369
|
+
export const CLAWHUB_OWNER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
370
|
+
/** Most candidates probed when resolving an ambiguous slug without an owner. */
|
|
371
|
+
const MAX_AMBIGUOUS_PROBES = 8;
|
|
372
|
+
export function setClawhubOwnerHints(hints) {
|
|
373
|
+
ownerHints.clear();
|
|
374
|
+
for (const [slug, owner] of hints)
|
|
375
|
+
ownerHints.set(slug, owner);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Run one operation pinned to `owner` for `slug`. Without an owner the
|
|
379
|
+
* operation runs unpinned and falls back to the hint/cache/probe order.
|
|
380
|
+
*/
|
|
381
|
+
export function withClawhubOwner(slug, owner, operation) {
|
|
382
|
+
if (!owner)
|
|
383
|
+
return operation();
|
|
384
|
+
return requestedOwner.run({ slug, owner }, operation);
|
|
385
|
+
}
|
|
386
|
+
/** The owner a read of `slug` will use right now, if one is known before asking upstream. */
|
|
387
|
+
export function clawhubOwnerFor(slug) {
|
|
388
|
+
const requested = requestedOwner.getStore();
|
|
389
|
+
if (requested?.slug === slug)
|
|
390
|
+
return requested.owner;
|
|
391
|
+
return ownerHints.get(slug) ?? ownerCache.get(slug);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Pick the candidate readers most plausibly mean: the most-downloaded one.
|
|
395
|
+
* Probes are owner-qualified detail reads; a candidate that fails to answer
|
|
396
|
+
* simply does not compete.
|
|
397
|
+
*/
|
|
398
|
+
async function resolveAmbiguousOwner(slug, candidates) {
|
|
399
|
+
const base = getProviderBase('clawhub');
|
|
400
|
+
const probed = await Promise.all(candidates.slice(0, MAX_AMBIGUOUS_PROBES).map(async (owner) => {
|
|
401
|
+
const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base);
|
|
402
|
+
url.searchParams.set('owner', owner);
|
|
403
|
+
try {
|
|
404
|
+
const res = await providerFetch('clawhub', url.toString());
|
|
405
|
+
if (!res.ok)
|
|
406
|
+
return { owner, downloads: -1 };
|
|
407
|
+
const { content } = await readResponseTextWithLimit('clawhub', res, MARKET_LIMITS.maxTotalSize, 'ambiguous-slug probe');
|
|
408
|
+
const parsed = JSON.parse(content);
|
|
409
|
+
return { owner, downloads: asNumber(parsed.skill?.stats?.downloads) ?? 0 };
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return { owner, downloads: -1 };
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
const best = probed.filter((entry) => entry.downloads >= 0).sort((a, b) => b.downloads - a.downloads)[0];
|
|
416
|
+
return best?.owner;
|
|
417
|
+
}
|
|
351
418
|
async function clawhubFetch(url, slug) {
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
354
|
-
url.searchParams.set('owner',
|
|
419
|
+
const knownOwner = clawhubOwnerFor(slug);
|
|
420
|
+
if (knownOwner && !url.searchParams.has('owner')) {
|
|
421
|
+
url.searchParams.set('owner', knownOwner);
|
|
355
422
|
}
|
|
356
423
|
const res = await providerFetch('clawhub', url.toString());
|
|
357
424
|
if (res.status !== 409)
|
|
@@ -371,7 +438,13 @@ async function clawhubFetch(url, slug) {
|
|
|
371
438
|
throw error;
|
|
372
439
|
body = null;
|
|
373
440
|
}
|
|
374
|
-
const
|
|
441
|
+
const candidates = body?.code === 'AMBIGUOUS_SKILL_SLUG'
|
|
442
|
+
? (body.matches ?? []).map((match) => asString(match?.ownerHandle)).filter((owner) => owner !== undefined)
|
|
443
|
+
: [];
|
|
444
|
+
// A pinned owner that still answers 409 is not a guess we may widen.
|
|
445
|
+
const resolvedOwner = knownOwner === undefined && candidates.length > 0
|
|
446
|
+
? await resolveAmbiguousOwner(slug, candidates)
|
|
447
|
+
: undefined;
|
|
375
448
|
if (!resolvedOwner) {
|
|
376
449
|
throw new MarketHttpError('clawhub', 409, MARKET_ERROR_CODES.upstreamError, `clawhub responded 409 for ${url.pathname}`);
|
|
377
450
|
}
|
|
@@ -392,24 +465,61 @@ async function clawhubFetchJson(url, slug) {
|
|
|
392
465
|
throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub returned invalid JSON');
|
|
393
466
|
}
|
|
394
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* ClawHub's scan of one version: an overall status plus per-scanner verdicts
|
|
470
|
+
* (VirusTotal, skillspector, an LLM review). The overall status decides the
|
|
471
|
+
* badge; each scanner becomes its own report so the reader sees which one
|
|
472
|
+
* objected and why.
|
|
473
|
+
*/
|
|
395
474
|
function mapSecurity(security) {
|
|
396
475
|
const vendorStatus = asString(security?.status);
|
|
397
476
|
if (!vendorStatus)
|
|
398
477
|
return { status: 'unknown', reports: [] };
|
|
399
478
|
const clean = vendorStatus === 'clean';
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
],
|
|
412
|
-
|
|
479
|
+
const reports = [
|
|
480
|
+
{
|
|
481
|
+
vendor: 'clawhub-scan',
|
|
482
|
+
status: vendorStatus,
|
|
483
|
+
statusText: clean
|
|
484
|
+
? security?.hasWarnings === true ? 'Clean (with warnings)' : 'Clean'
|
|
485
|
+
: `Scan status: ${vendorStatus}`,
|
|
486
|
+
reportUrl: asString(security?.virustotalUrl),
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
const scanners = [
|
|
490
|
+
['VirusTotal', security?.scanners?.vt],
|
|
491
|
+
['skillspector', security?.scanners?.skillspector],
|
|
492
|
+
['LLM review', security?.scanners?.llm],
|
|
493
|
+
];
|
|
494
|
+
for (const [vendor, scanner] of scanners) {
|
|
495
|
+
const status = asString(scanner?.normalizedStatus) ?? asString(scanner?.status);
|
|
496
|
+
if (!status)
|
|
497
|
+
continue;
|
|
498
|
+
const recommendation = asString(scanner?.recommendation);
|
|
499
|
+
const summary = asString(scanner?.summary);
|
|
500
|
+
const reportUrl = vendor === 'VirusTotal' ? asString(security?.virustotalUrl) : undefined;
|
|
501
|
+
reports.push({
|
|
502
|
+
vendor,
|
|
503
|
+
status,
|
|
504
|
+
statusText: recommendation ? `${status} · ${recommendation}` : status,
|
|
505
|
+
...(summary ? { summary } : {}),
|
|
506
|
+
...(reportUrl ? { reportUrl } : {}),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return { status: clean ? 'benign' : 'flagged', reports };
|
|
510
|
+
}
|
|
511
|
+
function changelogOf(version, latest) {
|
|
512
|
+
const text = meaningfulChangelog(latest?.changelog);
|
|
513
|
+
return text ? { changelog: { version, text, publishedAt: asNumber(latest?.createdAt) } } : {};
|
|
514
|
+
}
|
|
515
|
+
/** A release note worth showing: registries stamp placeholders on synced versions. */
|
|
516
|
+
export function meaningfulChangelog(text) {
|
|
517
|
+
const value = asString(text)?.trim();
|
|
518
|
+
if (!value)
|
|
519
|
+
return undefined;
|
|
520
|
+
if (/^synced by .*pipeline$/i.test(value))
|
|
521
|
+
return undefined;
|
|
522
|
+
return value;
|
|
413
523
|
}
|
|
414
524
|
function normalizeListItem(item) {
|
|
415
525
|
return {
|
|
@@ -418,7 +528,7 @@ function normalizeListItem(item) {
|
|
|
418
528
|
slug: item.slug,
|
|
419
529
|
name: asString(item.displayName) || item.slug,
|
|
420
530
|
summary: asString(item.summary) ?? '',
|
|
421
|
-
author: { handle: '' },
|
|
531
|
+
author: { handle: asString(item.ownerHandle) || '' },
|
|
422
532
|
stats: {
|
|
423
533
|
downloads: asNumber(item.stats?.downloads) ?? 0,
|
|
424
534
|
installs: asNumber(item.stats?.installs),
|
|
@@ -505,7 +615,11 @@ export const clawhubProvider = {
|
|
|
505
615
|
}
|
|
506
616
|
const nativeResults = usable.filter((result) => (result.source === undefined || result.source === 'clawhub')
|
|
507
617
|
&& (result.install?.kind === undefined || result.install.kind === 'clawhub'));
|
|
508
|
-
|
|
618
|
+
// A card id is `clawhub:<slug>`, so a page can hold one owner per slug: the
|
|
619
|
+
// most relevant (first) result keeps the slot and carries its owner.
|
|
620
|
+
const seen = new Set();
|
|
621
|
+
const distinct = nativeResults.filter((result) => !seen.has(result.slug) && Boolean(seen.add(result.slug)));
|
|
622
|
+
return { items: distinct.slice(0, limit).map(normalizeSearchResult) };
|
|
509
623
|
},
|
|
510
624
|
async detail(slug) {
|
|
511
625
|
const base = getProviderBase('clawhub');
|
|
@@ -549,6 +663,7 @@ export const clawhubProvider = {
|
|
|
549
663
|
frontmatter = parsed.frontmatter;
|
|
550
664
|
}
|
|
551
665
|
const item = normalizeListItem(data.skill);
|
|
666
|
+
const owner = asString(data.owner?.handle);
|
|
552
667
|
return {
|
|
553
668
|
...item,
|
|
554
669
|
version,
|
|
@@ -559,6 +674,8 @@ export const clawhubProvider = {
|
|
|
559
674
|
},
|
|
560
675
|
securityStatus: security.status,
|
|
561
676
|
securityReports: security.reports.length ? security.reports : undefined,
|
|
677
|
+
...changelogOf(version, data.latestVersion),
|
|
678
|
+
...(owner ? { pageUrl: `https://clawhub.ai/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}` } : {}),
|
|
562
679
|
description: body,
|
|
563
680
|
descriptionFrontmatter: frontmatter,
|
|
564
681
|
license,
|
|
@@ -22,7 +22,7 @@ import * as fs from 'node:fs/promises';
|
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
import { clawhubProvider } from "./clawhub-provider.js";
|
|
24
24
|
import { skillhubProvider } from "./skillhub-provider.js";
|
|
25
|
-
import { getMarketSkillDetail, resolveMarketSkill } from "./market-service.js";
|
|
25
|
+
import { getMarketSkillDetail, resolveMarketSkill, withMarketOwner } from "./market-service.js";
|
|
26
26
|
import { withProviderConfiguration } from "./provider-fetch.js";
|
|
27
27
|
import { INSTALL_META_FILE, parseInstalledMeta, readSkillHeadline, } from "../skills/installed.js";
|
|
28
28
|
import { MARKET_ERROR_CODES, MARKET_LIMITS, sanitizeDirName, skillId, } from "./types.js";
|
|
@@ -104,8 +104,14 @@ function hasErrorCode(error, code) {
|
|
|
104
104
|
return typeof error === 'object' && error !== null && error.code === code;
|
|
105
105
|
}
|
|
106
106
|
/** The upstream detail for a skill, or the market's own "not installable" verdict. */
|
|
107
|
-
async function loadDetail(source, slug) {
|
|
108
|
-
|
|
107
|
+
async function loadDetail(source, slug, owner) {
|
|
108
|
+
const detail = await resolveMarketSkill(source, slug, owner);
|
|
109
|
+
// The bytes must belong to the author the reader chose: a registry that
|
|
110
|
+
// answers for another owner is refused rather than installed.
|
|
111
|
+
if (owner && detail.author.handle && detail.author.handle !== owner) {
|
|
112
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notInstallable, `Skill ${slug} resolved to ${detail.author.handle}, not the requested ${owner}`);
|
|
113
|
+
}
|
|
114
|
+
return detail;
|
|
109
115
|
}
|
|
110
116
|
/**
|
|
111
117
|
* Refuse to publish over anything we do not own.
|
|
@@ -156,7 +162,8 @@ async function readJsonIfExists(filePath) {
|
|
|
156
162
|
* unwrapped so the route can classify them (404 vs 502).
|
|
157
163
|
*/
|
|
158
164
|
export function installMarketSkill(source, slug, options) {
|
|
159
|
-
|
|
165
|
+
// One owner for the manifest, the file list and every file read.
|
|
166
|
+
return withProviderConfiguration(() => withMarketOwner(source, slug, options.owner, () => installMarketSkillWithConfiguration(source, slug, options)));
|
|
160
167
|
}
|
|
161
168
|
async function installMarketSkillWithConfiguration(source, slug, options) {
|
|
162
169
|
const dirName = sanitizeDirName(slug);
|
|
@@ -171,7 +178,7 @@ async function installMarketSkillWithConfiguration(source, slug, options) {
|
|
|
171
178
|
if (inFlight.has(lockKey)) {
|
|
172
179
|
throw new MarketInstallError(409, MARKET_ERROR_CODES.installInProgress, `Install already in progress: ${id}`);
|
|
173
180
|
}
|
|
174
|
-
const task = performInstall(source, slug, dirName, id, options.skillsRoot);
|
|
181
|
+
const task = performInstall(source, slug, dirName, id, options.skillsRoot, options.owner);
|
|
175
182
|
// Store a settled-safe copy: the lock exists to reject *concurrent* work, and
|
|
176
183
|
// an unhandled rejection here would crash the host on every failed install.
|
|
177
184
|
inFlight.set(lockKey, task.catch(() => undefined));
|
|
@@ -182,9 +189,9 @@ async function installMarketSkillWithConfiguration(source, slug, options) {
|
|
|
182
189
|
inFlight.delete(lockKey);
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
|
-
async function performInstall(source, slug, dirName, id, skillsRootInput) {
|
|
192
|
+
async function performInstall(source, slug, dirName, id, skillsRootInput, requestedOwner) {
|
|
186
193
|
const skillsRoot = path.resolve(skillsRootInput);
|
|
187
|
-
const detail = await loadDetail(source, slug);
|
|
194
|
+
const detail = await loadDetail(source, slug, requestedOwner);
|
|
188
195
|
if (detail.installState === 'installed') {
|
|
189
196
|
throw new MarketInstallError(409, MARKET_ERROR_CODES.alreadyInstalled, `Skill already installed: ${id}`);
|
|
190
197
|
}
|
|
@@ -252,10 +259,14 @@ async function performInstall(source, slug, dirName, id, skillsRootInput) {
|
|
|
252
259
|
ledger.push({ path: segments.join('/'), sha256: digest });
|
|
253
260
|
}
|
|
254
261
|
const installedAt = new Date().toISOString();
|
|
262
|
+
// The author is recorded so "view in marketplace" and later reads find this
|
|
263
|
+
// exact skill again, not another one sharing its slug.
|
|
264
|
+
const owner = detail.author.handle || requestedOwner;
|
|
255
265
|
const meta = {
|
|
256
266
|
id,
|
|
257
267
|
source,
|
|
258
268
|
slug,
|
|
269
|
+
...(owner ? { owner } : {}),
|
|
259
270
|
...(detail.version === undefined ? {} : { version: detail.version }),
|
|
260
271
|
installedAt,
|
|
261
272
|
files: ledger,
|
|
@@ -371,7 +382,7 @@ export async function uninstallMarketSkill(source, slug, options) {
|
|
|
371
382
|
*/
|
|
372
383
|
async function refreshRemovedSkill(source, slug, id, dirName, meta, headline) {
|
|
373
384
|
try {
|
|
374
|
-
const { skill } = await getMarketSkillDetail(source, slug);
|
|
385
|
+
const { skill } = await getMarketSkillDetail(source, slug, { owner: meta.owner });
|
|
375
386
|
return { ...skill, installState: 'installable', notInstallableReason: undefined, installedInfo: undefined };
|
|
376
387
|
}
|
|
377
388
|
catch {
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { getSourceHealth, markSourceHealth, marketCache, noteMarketStat } from "./cache.js";
|
|
14
14
|
import { getProviderBase, withProviderConfiguration } from "./provider-fetch.js";
|
|
15
|
-
import { clawhubProvider } from "./clawhub-provider.js";
|
|
15
|
+
import { clawhubOwnerFor, clawhubProvider, setClawhubOwnerHints, withClawhubOwner } from "./clawhub-provider.js";
|
|
16
|
+
import { catalogCategories, catalogClawhubOwners, catalogEntryFor, catalogEntryToSkill, filterCatalog, getCatalog, } from "../catalog/catalog.js";
|
|
16
17
|
import { skillhubProvider } from "./skillhub-provider.js";
|
|
17
18
|
import { MARKET_LIMITS, MARKET_SOURCES, detectMarketLanguage, sanitizeDirName, skillId, } from "./types.js";
|
|
18
19
|
const providers = {
|
|
19
20
|
clawhub: clawhubProvider,
|
|
20
21
|
skillhub: skillhubProvider,
|
|
21
22
|
};
|
|
23
|
+
// Catalogue entries name an exact ClawHub owner; detail, files and install must
|
|
24
|
+
// resolve the same skill the card showed.
|
|
25
|
+
setClawhubOwnerHints(catalogClawhubOwners());
|
|
22
26
|
/** Nothing is installed until the host installs its real lookup. */
|
|
23
27
|
const EMPTY_INSTALLED_LOOKUP = {
|
|
24
28
|
has: () => false,
|
|
@@ -129,7 +133,9 @@ export function dedupeSkills(items) {
|
|
|
129
133
|
for (const item of copies) {
|
|
130
134
|
if (item.source === 'skillhub' && item.upstream?.slug) {
|
|
131
135
|
const original = byClawhubSlug.get(item.upstream.slug);
|
|
132
|
-
|
|
136
|
+
// Same slug, different author: the mirror copies another skill.
|
|
137
|
+
const sameAuthor = !item.upstream.owner || !original?.author.handle || item.upstream.owner === original.author.handle;
|
|
138
|
+
if (original && sameAuthor) {
|
|
133
139
|
original.mirrors = [...(original.mirrors ?? []), item.id];
|
|
134
140
|
// Enrich the original with SkillHub-only data.
|
|
135
141
|
if (!original.iconUrl && item.iconUrl)
|
|
@@ -206,6 +212,8 @@ async function fetchProviderPage(source, params, cache, options = {}) {
|
|
|
206
212
|
}
|
|
207
213
|
}
|
|
208
214
|
export function listMarketSkills(params) {
|
|
215
|
+
if (params.scope !== 'market')
|
|
216
|
+
return Promise.resolve(listCatalogSkills(params));
|
|
209
217
|
const cache = marketCache;
|
|
210
218
|
return withProviderConfiguration(() => listMarketSkillsWithCache(params, cache));
|
|
211
219
|
}
|
|
@@ -256,13 +264,68 @@ async function listMarketSkillsWithCache(params, cache) {
|
|
|
256
264
|
}
|
|
257
265
|
return { items: merged, nextCursor: encodeCursor(nextCursor), sources };
|
|
258
266
|
}
|
|
267
|
+
// ─── Curated catalogue ───────────────────────────────────────────────────────
|
|
268
|
+
const CATALOG_CURSOR_PREFIX = 'catalog:';
|
|
269
|
+
/** Provenance of every catalogue answer: one snapshot, read when it was generated. */
|
|
270
|
+
function catalogStatus() {
|
|
271
|
+
return { status: 'ok', fetchedAt: getCatalog().generatedAt, fromCache: true };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* One page of the curated catalogue.
|
|
275
|
+
*
|
|
276
|
+
* Everything is local, so every filter — security and installed included — runs
|
|
277
|
+
* before pagination: a page is always full until the list is exhausted, which
|
|
278
|
+
* is what keeps infinite scroll from stalling on a filtered-out page.
|
|
279
|
+
*/
|
|
280
|
+
export function listCatalogSkills(params) {
|
|
281
|
+
let items = filterCatalog({ q: params.q, category: params.category, source: params.source })
|
|
282
|
+
.map(catalogEntryToSkill)
|
|
283
|
+
.map((item) => annotateInstallState(item));
|
|
284
|
+
if (params.security !== 'all')
|
|
285
|
+
items = items.filter((item) => item.securityStatus === params.security);
|
|
286
|
+
if (params.installed !== 'all') {
|
|
287
|
+
items = items.filter((item) => params.installed === 'installed' ? item.installState === 'installed' : item.installState !== 'installed');
|
|
288
|
+
}
|
|
289
|
+
const raw = params.cursor?.startsWith(CATALOG_CURSOR_PREFIX) ? params.cursor.slice(CATALOG_CURSOR_PREFIX.length) : '';
|
|
290
|
+
const offset = Math.max(0, Number.parseInt(raw, 10) || 0);
|
|
291
|
+
const end = offset + params.limit;
|
|
292
|
+
const status = catalogStatus();
|
|
293
|
+
return {
|
|
294
|
+
items: items.slice(offset, end),
|
|
295
|
+
nextCursor: end < items.length ? `${CATALOG_CURSOR_PREFIX}${end}` : null,
|
|
296
|
+
total: items.length,
|
|
297
|
+
sources: { clawhub: status, skillhub: status },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export function listMarketCategories() {
|
|
301
|
+
return { items: catalogCategories(), status: catalogStatus() };
|
|
302
|
+
}
|
|
259
303
|
// ─── Detail / file content ───────────────────────────────────────────────────
|
|
260
304
|
export function getMarketSkillDetail(source, slug, options = {}) {
|
|
261
305
|
const cache = marketCache;
|
|
262
|
-
return withProviderConfiguration(() => getMarketSkillDetailWithCache(source, slug, options, cache));
|
|
306
|
+
return withProviderConfiguration(() => withMarketOwner(source, slug, options.owner, () => getMarketSkillDetailWithCache(source, slug, options, cache))).then((result) => ({ ...result, skill: withCatalogMetadata(result.skill) }));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Overlay the catalogue's editorial fields on a live detail.
|
|
310
|
+
*
|
|
311
|
+
* Upstream stays authoritative for everything it owns (version, files,
|
|
312
|
+
* security, stats); the catalogue only adds what upstream does not have — our
|
|
313
|
+
* category, the editor's pick and the reader-facing Chinese summary.
|
|
314
|
+
*/
|
|
315
|
+
function withCatalogMetadata(detail) {
|
|
316
|
+
const entry = catalogEntryFor(detail.source, detail.slug);
|
|
317
|
+
if (!entry)
|
|
318
|
+
return detail;
|
|
319
|
+
return {
|
|
320
|
+
...detail,
|
|
321
|
+
category: entry.category,
|
|
322
|
+
featured: entry.featured === true ? true : undefined,
|
|
323
|
+
summary: entry.summary || detail.summary,
|
|
324
|
+
tags: detail.tags.length > 0 ? detail.tags : [...entry.tags],
|
|
325
|
+
};
|
|
263
326
|
}
|
|
264
327
|
async function getMarketSkillDetailWithCache(source, slug, options, cache) {
|
|
265
|
-
const cacheKey = `detail:${source}:${JSON.stringify([getProviderBase(source), slug])}`;
|
|
328
|
+
const cacheKey = `detail:${source}:${JSON.stringify([getProviderBase(source), slug, ...ownerKeyPart(source, slug)])}`;
|
|
266
329
|
const forced = options.force === true;
|
|
267
330
|
if (forced)
|
|
268
331
|
noteMarketStat('forcedRefreshes');
|
|
@@ -310,14 +373,30 @@ export function isValidMarketFilePath(filePath) {
|
|
|
310
373
|
return false;
|
|
311
374
|
return true;
|
|
312
375
|
}
|
|
313
|
-
export function getMarketFileContent(source, slug, filePath) {
|
|
376
|
+
export function getMarketFileContent(source, slug, filePath, owner) {
|
|
314
377
|
const cache = marketCache;
|
|
315
|
-
return withProviderConfiguration(() => getMarketFileContentWithCache(source, slug, filePath, cache));
|
|
378
|
+
return withProviderConfiguration(() => withMarketOwner(source, slug, owner, () => getMarketFileContentWithCache(source, slug, filePath, cache)));
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Pin a ClawHub read to the owner the reader asked for. ClawHub slugs are
|
|
382
|
+
* shared across authors, so `clawhub:<slug>` alone does not name one skill.
|
|
383
|
+
*/
|
|
384
|
+
export function withMarketOwner(source, slug, owner, operation) {
|
|
385
|
+
return source === 'clawhub' ? withClawhubOwner(slug, owner, operation) : operation();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Cache keys of owner-dependent reads carry the owner the read will use, so a
|
|
389
|
+
* snapshot of one author's skill is never served for another's. Appended only
|
|
390
|
+
* when known, keeping unambiguous slugs' keys unchanged.
|
|
391
|
+
*/
|
|
392
|
+
function ownerKeyPart(source, slug) {
|
|
393
|
+
const owner = source === 'clawhub' ? clawhubOwnerFor(slug) : undefined;
|
|
394
|
+
return owner ? [`owner=${owner}`] : [];
|
|
316
395
|
}
|
|
317
396
|
async function getMarketFileContentWithCache(source, slug, filePath, cache) {
|
|
318
397
|
// JSON-encoded for the same reason as the list keys: slug and filePath are
|
|
319
398
|
// both caller-controlled, so a `:`-joined key is ambiguous.
|
|
320
|
-
const cacheKey = `file:${source}:${JSON.stringify([getProviderBase(source), slug, filePath])}`;
|
|
399
|
+
const cacheKey = `file:${source}:${JSON.stringify([getProviderBase(source), slug, filePath, ...ownerKeyPart(source, slug)])}`;
|
|
321
400
|
const cached = await cache.getRecord(cacheKey);
|
|
322
401
|
if (cached) {
|
|
323
402
|
noteMarketStat('hits');
|
|
@@ -356,7 +435,7 @@ export function getMarketStatus() {
|
|
|
356
435
|
* right now — the file bytes are fetched fresh and hash-verified against the
|
|
357
436
|
* list, so the list must come from the same generation as those bytes.
|
|
358
437
|
*/
|
|
359
|
-
export async function resolveMarketSkill(source, slug) {
|
|
360
|
-
const { skill } = await getMarketSkillDetail(source, slug, { force: true, allowStale: false });
|
|
438
|
+
export async function resolveMarketSkill(source, slug, owner) {
|
|
439
|
+
const { skill } = await getMarketSkillDetail(source, slug, { force: true, allowStale: false, owner });
|
|
361
440
|
return skill;
|
|
362
441
|
}
|
|
@@ -13,7 +13,7 @@ import { getProviderBase, providerFetch, providerFetchJson, readResponseTextWith
|
|
|
13
13
|
import { detectMarketLanguage, MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError, skillId, } from "./types.js";
|
|
14
14
|
// The frontmatter parser is shared with the ClawHub provider: both sources hand
|
|
15
15
|
// us a raw SKILL.md and the detail contract promises a frontmatter-free body.
|
|
16
|
-
import { parseFrontmatter } from "./clawhub-provider.js";
|
|
16
|
+
import { meaningfulChangelog, parseFrontmatter } from "./clawhub-provider.js";
|
|
17
17
|
import { markSourceHealth } from "./cache.js";
|
|
18
18
|
/**
|
|
19
19
|
* Vendor statuses that mean "no findings". Anything else (suspicious,
|
|
@@ -88,8 +88,9 @@ function parseUpstream(item) {
|
|
|
88
88
|
try {
|
|
89
89
|
const segments = new URL(upstreamUrl).pathname.split('/').filter(Boolean);
|
|
90
90
|
const slug = segments[segments.length - 1];
|
|
91
|
+
const owner = segments.length > 1 ? segments[segments.length - 2] : undefined;
|
|
91
92
|
if (slug)
|
|
92
|
-
return { source: 'clawhub', slug };
|
|
93
|
+
return { source: 'clawhub', slug, ...(owner ? { owner } : {}) };
|
|
93
94
|
}
|
|
94
95
|
catch {
|
|
95
96
|
// Malformed upstream URL — treat as a native entry.
|
|
@@ -200,6 +201,7 @@ export const skillhubProvider = {
|
|
|
200
201
|
}
|
|
201
202
|
const security = mapSecurity(data.securityReports, data.skill.verified === undefined ? undefined : Boolean(data.skill.verified));
|
|
202
203
|
const version = asString(data.latestVersion?.version) || item.version;
|
|
204
|
+
const changelog = meaningfulChangelog(data.latestVersion?.changelog);
|
|
203
205
|
let files = [];
|
|
204
206
|
try {
|
|
205
207
|
files = await skillhubProvider.listFiles(slug);
|
|
@@ -242,6 +244,8 @@ export const skillhubProvider = {
|
|
|
242
244
|
},
|
|
243
245
|
securityStatus: security.status,
|
|
244
246
|
securityReports: security.reports.length ? security.reports : undefined,
|
|
247
|
+
...(changelog ? { changelog: { version, text: changelog, publishedAt: asNumber(data.latestVersion?.createdAt) } } : {}),
|
|
248
|
+
pageUrl: `https://skillhub.cn/skills/${encodeURIComponent(slug)}`,
|
|
245
249
|
description,
|
|
246
250
|
...(descriptionFrontmatter ? { descriptionFrontmatter } : {}),
|
|
247
251
|
files: files.map((file) => ({
|
package/lib/skills/installed.js
CHANGED
|
@@ -57,6 +57,9 @@ export function parseInstalledMeta(value) {
|
|
|
57
57
|
const version = typeof candidate['version'] === 'string' && candidate['version'] !== ''
|
|
58
58
|
? candidate['version']
|
|
59
59
|
: undefined;
|
|
60
|
+
const owner = typeof candidate['owner'] === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(candidate['owner'])
|
|
61
|
+
? candidate['owner']
|
|
62
|
+
: undefined;
|
|
60
63
|
const files = [];
|
|
61
64
|
const rawFiles = candidate['files'];
|
|
62
65
|
if (Array.isArray(rawFiles)) {
|
|
@@ -75,6 +78,7 @@ export function parseInstalledMeta(value) {
|
|
|
75
78
|
id,
|
|
76
79
|
source: source,
|
|
77
80
|
slug,
|
|
81
|
+
...(owner === undefined ? {} : { owner }),
|
|
78
82
|
...(version === undefined ? {} : { version }),
|
|
79
83
|
installedAt,
|
|
80
84
|
files,
|
|
@@ -289,6 +293,7 @@ async function readDirectorySkill(rootPath, dirName) {
|
|
|
289
293
|
id: meta.id,
|
|
290
294
|
source: meta.source,
|
|
291
295
|
slug: meta.slug,
|
|
296
|
+
...(meta.owner === undefined ? {} : { owner: meta.owner }),
|
|
292
297
|
...(version === undefined ? {} : { version }),
|
|
293
298
|
installedAt: meta.installedAt,
|
|
294
299
|
...shared,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated catalogue — the marketplace's home list.
|
|
3
|
+
*
|
|
4
|
+
* The upstream registries hold ~80k ClawHub and ~110k SkillHub skills; nobody
|
|
5
|
+
* chooses from that. The home list is instead a few hundred skills reviewed in
|
|
6
|
+
* this repository (`catalog/curation.json`) and snapshotted by
|
|
7
|
+
* `scripts/catalog-refresh.mjs` into `skills.json`, which ships with the plugin.
|
|
8
|
+
*
|
|
9
|
+
* Only the *list* comes from the snapshot. Opening a card, reading a file and
|
|
10
|
+
* installing still go to the owning registry (through the usual cache), so a
|
|
11
|
+
* reader never installs the snapshot's version of anything.
|
|
12
|
+
*/
|
|
13
|
+
import { type MarketCategory, type MarketSource, type NormalizedSkill, type SecurityStatus } from '../market/types.ts';
|
|
14
|
+
export type CatalogEntry = {
|
|
15
|
+
source: MarketSource;
|
|
16
|
+
slug: string;
|
|
17
|
+
/** Registry owner; disambiguates ClawHub slugs shared by several authors. */
|
|
18
|
+
owner: string;
|
|
19
|
+
name: string;
|
|
20
|
+
/** Reader-facing summary (zh-CN). */
|
|
21
|
+
summary: string;
|
|
22
|
+
/** Original upstream summary, kept for English readers and search. */
|
|
23
|
+
summaryEn?: string;
|
|
24
|
+
/** Key into `categories`. */
|
|
25
|
+
category: string;
|
|
26
|
+
tags: string[];
|
|
27
|
+
/** Editor's pick: listed first and marked on the card. */
|
|
28
|
+
featured?: boolean;
|
|
29
|
+
stats: {
|
|
30
|
+
downloads: number;
|
|
31
|
+
installs?: number;
|
|
32
|
+
stars?: number;
|
|
33
|
+
};
|
|
34
|
+
version?: string;
|
|
35
|
+
updatedAt?: number;
|
|
36
|
+
iconUrl?: string;
|
|
37
|
+
security: SecurityStatus;
|
|
38
|
+
/** Why the security verdict is what it is, when it is not clean. */
|
|
39
|
+
securityNote?: string;
|
|
40
|
+
requiresApiKey?: boolean;
|
|
41
|
+
license?: string;
|
|
42
|
+
};
|
|
43
|
+
export type CatalogFile = {
|
|
44
|
+
version: 1;
|
|
45
|
+
/** Epoch millis of the upstream reads behind this snapshot. */
|
|
46
|
+
generatedAt: number;
|
|
47
|
+
categories: Array<{
|
|
48
|
+
key: string;
|
|
49
|
+
name: string;
|
|
50
|
+
nameEn: string;
|
|
51
|
+
}>;
|
|
52
|
+
skills: CatalogEntry[];
|
|
53
|
+
};
|
|
54
|
+
export declare function getCatalog(): CatalogFile;
|
|
55
|
+
export declare function catalogEntryFor(source: MarketSource, slug: string): CatalogEntry | undefined;
|
|
56
|
+
/** Category bar entries, in editorial order, with how many skills each holds. */
|
|
57
|
+
export declare function catalogCategories(): Array<MarketCategory & {
|
|
58
|
+
count: number;
|
|
59
|
+
}>;
|
|
60
|
+
/** ClawHub owners the catalogue pins, for the provider's slug disambiguation. */
|
|
61
|
+
export declare function catalogClawhubOwners(): Array<readonly [string, string]>;
|
|
62
|
+
export declare function catalogEntryToSkill(entry: CatalogEntry): NormalizedSkill;
|
|
63
|
+
/**
|
|
64
|
+
* Entries matching every whitespace-separated term, in catalogue order.
|
|
65
|
+
*
|
|
66
|
+
* A name/slug hit ranks above a summary or tag hit: typing a skill's name
|
|
67
|
+
* should put that skill first, not the one that mentions it most.
|
|
68
|
+
*/
|
|
69
|
+
export declare function filterCatalog(params: {
|
|
70
|
+
q?: string;
|
|
71
|
+
category?: string;
|
|
72
|
+
source?: 'all' | MarketSource;
|
|
73
|
+
}): CatalogEntry[];
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* never a value from the Host's `src/market/*`, which would drag Node code into
|
|
10
10
|
* the browser bundle (contract §2).
|
|
11
11
|
*/
|
|
12
|
-
import type { MarketFileContent, MarketListResult, MarketSource, NormalizedSkill, NormalizedSkillDetail, SecurityStatus, SourceStatusInfo } from '../market/types.ts';
|
|
12
|
+
import type { MarketCategoriesResult, MarketFileContent, MarketListResult, MarketSource, NormalizedSkill, NormalizedSkillDetail, SecurityStatus, SourceStatusInfo } from '../market/types.ts';
|
|
13
13
|
/**
|
|
14
14
|
* Structural mirror of the Host's `InstalledSkillRecord`
|
|
15
15
|
* (`src/skills/installed.ts`, contract §5.2).
|
|
@@ -29,6 +29,8 @@ export interface InstalledSkillRecord {
|
|
|
29
29
|
id: string;
|
|
30
30
|
source: MarketSource | 'local';
|
|
31
31
|
slug: string;
|
|
32
|
+
/** Registry author recorded at install time, when known. */
|
|
33
|
+
owner?: string;
|
|
32
34
|
name: string;
|
|
33
35
|
dirName: string;
|
|
34
36
|
dirPath: string;
|
|
@@ -43,6 +45,10 @@ export interface InstalledSkillRecord {
|
|
|
43
45
|
/** List query accepted by `GET /api/skills-hub/skills`. */
|
|
44
46
|
export interface MarketQuery {
|
|
45
47
|
q?: string;
|
|
48
|
+
/** `catalog` (default): the curated list. `market`: live upstream search. */
|
|
49
|
+
scope?: 'catalog' | 'market';
|
|
50
|
+
/** Catalogue category key; `'all'` (or absent) means no category filter. */
|
|
51
|
+
category?: string;
|
|
46
52
|
source: 'all' | MarketSource;
|
|
47
53
|
security: 'all' | SecurityStatus;
|
|
48
54
|
installed: 'all' | 'installed' | 'installable';
|
|
@@ -71,15 +77,18 @@ export declare function isAbortError(error: unknown): boolean;
|
|
|
71
77
|
export declare function fetchMarketList(query: MarketQuery, signal?: AbortSignal, options?: {
|
|
72
78
|
refresh?: boolean;
|
|
73
79
|
}): Promise<MarketListResult>;
|
|
80
|
+
/** The category bar's entries (curated catalogue), with the snapshot's provenance. */
|
|
81
|
+
export declare function fetchMarketCategories(signal?: AbortSignal): Promise<MarketCategoriesResult>;
|
|
74
82
|
/** One skill's full detail plus the health of the source that served it. */
|
|
75
83
|
export declare function fetchSkillDetail(id: string, signal?: AbortSignal, options?: {
|
|
76
84
|
refresh?: boolean;
|
|
85
|
+
owner?: string;
|
|
77
86
|
}): Promise<{
|
|
78
87
|
skill: NormalizedSkillDetail;
|
|
79
88
|
sourceStatus: SourceStatusInfo;
|
|
80
89
|
}>;
|
|
81
90
|
/** One file of a skill, for the Files tab. */
|
|
82
|
-
export declare function fetchSkillFile(id: string, path: string, signal?: AbortSignal): Promise<MarketFileContent>;
|
|
91
|
+
export declare function fetchSkillFile(id: string, path: string, signal?: AbortSignal, owner?: string): Promise<MarketFileContent>;
|
|
83
92
|
/** Health of every market source, refreshed independently of the list. */
|
|
84
93
|
export declare function fetchSourceStatus(signal?: AbortSignal): Promise<Record<MarketSource, SourceStatusInfo>>;
|
|
85
94
|
/**
|
|
@@ -89,7 +98,7 @@ export declare function fetchSourceStatus(signal?: AbortSignal): Promise<Record<
|
|
|
89
98
|
*/
|
|
90
99
|
export declare function fetchInstalled(signal?: AbortSignal): Promise<InstalledSkillRecord[]>;
|
|
91
100
|
/** Install a market skill into the local skills directory. */
|
|
92
|
-
export declare function installSkill(id: string): Promise<{
|
|
101
|
+
export declare function installSkill(id: string, owner?: string): Promise<{
|
|
93
102
|
installedPath: string;
|
|
94
103
|
skill: NormalizedSkill;
|
|
95
104
|
}>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MarketCategory } from '../../market/types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Category chips above the curated catalogue.
|
|
4
|
+
*
|
|
5
|
+
* The categories are the catalogue's own, so every chip has skills behind it
|
|
6
|
+
* and its count is exact. Rendered only once they loaded.
|
|
7
|
+
*/
|
|
8
|
+
export declare function CategoryBar(props: {
|
|
9
|
+
categories: MarketCategory[];
|
|
10
|
+
active: string;
|
|
11
|
+
disabled: boolean;
|
|
12
|
+
onSelect: (category: string) => void;
|
|
13
|
+
}): JSX.Element | null;
|