@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.
Files changed (44) hide show
  1. package/CONTRIBUTING.md +7 -3
  2. package/README.md +24 -12
  3. package/README_EN.md +24 -12
  4. package/README_ZH.md +1 -1
  5. package/assets/readme/marketplace.png +0 -0
  6. package/compatibility.json +6 -6
  7. package/docs/CATALOG.md +39 -0
  8. package/docs/COMPATIBILITY.md +31 -1
  9. package/docs/CONTRACT.md +15 -10
  10. package/docs/RELEASE.md +4 -4
  11. package/lib/catalog/catalog.js +89 -0
  12. package/lib/catalog/skills.json +8673 -0
  13. package/lib/client.js +2549 -1819
  14. package/lib/client.js.map +1 -1
  15. package/lib/market/clawhub-provider.js +139 -22
  16. package/lib/market/install-service.js +19 -8
  17. package/lib/market/market-service.js +88 -9
  18. package/lib/market/skillhub-provider.js +6 -2
  19. package/lib/skills/installed.js +5 -0
  20. package/lib/types/catalog/catalog.d.ts +73 -0
  21. package/lib/types/client/api.d.ts +12 -3
  22. package/lib/types/client/components/CategoryBar.d.ts +13 -0
  23. package/lib/types/client/components/FilterBar.d.ts +4 -0
  24. package/lib/types/client/components/InstallConfirmDialog.d.ts +14 -11
  25. package/lib/types/client/components/InstalledSkillDetail.d.ts +1 -1
  26. package/lib/types/client/components/InstalledSkills.d.ts +1 -1
  27. package/lib/types/client/components/MarketSkillDetail.d.ts +34 -0
  28. package/lib/types/client/components/SkillDetailShell.d.ts +66 -43
  29. package/lib/types/client/locales.d.ts +64 -0
  30. package/lib/types/client/skill-insights.d.ts +30 -0
  31. package/lib/types/client/state.d.ts +23 -3
  32. package/lib/types/market/clawhub-provider.d.ts +15 -0
  33. package/lib/types/market/install-service.d.ts +1 -0
  34. package/lib/types/market/market-service.d.ts +25 -3
  35. package/lib/types/market/types.d.ts +29 -0
  36. package/lib/types/skills/installed.d.ts +4 -0
  37. package/lib/types/web-routes.d.ts +4 -13
  38. package/lib/web-routes.js +48 -19
  39. package/package.json +26 -27
  40. package/release-notes/v0.0.3.md +12 -0
  41. package/release-notes/v0.0.4.md +11 -0
  42. package/lib/types/client/components/InstallStateBadge.d.ts +0 -11
  43. package/lib/types/client/components/SecurityBadge.d.ts +0 -16
  44. 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. Ambiguous slugs return
348
- // 409 AMBIGUOUS_SKILL_SLUG with candidate owners; disambiguate via ?owner=
349
- // (first match = primary listing) and remember the resolution.
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 cachedOwner = ownerCache.get(slug);
353
- if (cachedOwner && !url.searchParams.has('owner')) {
354
- url.searchParams.set('owner', cachedOwner);
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 resolvedOwner = body?.code === 'AMBIGUOUS_SKILL_SLUG' ? asString(body.matches?.[0]?.ownerHandle) : undefined;
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
- return {
401
- status: clean ? 'benign' : 'flagged',
402
- reports: [
403
- {
404
- vendor: 'clawhub-scan',
405
- status: vendorStatus,
406
- statusText: clean
407
- ? security?.hasWarnings === true ? 'Clean (with warnings)' : 'Clean'
408
- : `Scan status: ${vendorStatus}`,
409
- reportUrl: asString(security?.virustotalUrl),
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
- return { items: nativeResults.slice(0, limit).map(normalizeSearchResult) };
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
- return resolveMarketSkill(source, slug);
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
- return withProviderConfiguration(() => installMarketSkillWithConfiguration(source, slug, options));
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
- if (original) {
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) => ({
@@ -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;