@nanmicoder/dsh-skills-hub 0.0.1 → 0.0.3

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 (67) hide show
  1. package/CONTRIBUTING.md +22 -0
  2. package/README.md +32 -82
  3. package/README_EN.md +32 -82
  4. package/README_ZH.md +2 -0
  5. package/assets/readme/marketplace.png +0 -0
  6. package/compatibility.json +1 -1
  7. package/docs/CATALOG.md +39 -0
  8. package/docs/COMPATIBILITY.md +46 -0
  9. package/docs/CONFIGURATION.md +38 -0
  10. package/docs/CONTRACT.md +35 -12
  11. package/docs/RELEASE.md +5 -5
  12. package/lib/catalog/catalog.js +89 -0
  13. package/lib/catalog/skills.json +8673 -0
  14. package/lib/client.js +3349 -1728
  15. package/lib/client.js.map +1 -1
  16. package/lib/config.js +2 -0
  17. package/lib/index.js +16 -6
  18. package/lib/market/cache.js +104 -30
  19. package/lib/market/clawhub-provider.js +139 -22
  20. package/lib/market/disk-cache.js +113 -0
  21. package/lib/market/install-service.js +24 -11
  22. package/lib/market/market-service.js +161 -32
  23. package/lib/market/provider-fetch.js +9 -2
  24. package/lib/market/skillhub-provider.js +6 -2
  25. package/lib/market/types.js +2 -1
  26. package/lib/skills/installed.js +5 -0
  27. package/lib/skills/management.js +182 -4
  28. package/lib/skills/root.js +1 -1
  29. package/lib/types/catalog/catalog.d.ts +73 -0
  30. package/lib/types/client/api.d.ts +45 -8
  31. package/lib/types/client/components/CategoryBar.d.ts +13 -0
  32. package/lib/types/client/components/FilterBar.d.ts +4 -0
  33. package/lib/types/client/components/InstallConfirmDialog.d.ts +20 -11
  34. package/lib/types/client/components/InstalledSkillDetail.d.ts +24 -0
  35. package/lib/types/client/components/InstalledSkills.d.ts +18 -1
  36. package/lib/types/client/components/MarkdownView.d.ts +7 -1
  37. package/lib/types/client/components/MarketSkillDetail.d.ts +34 -0
  38. package/lib/types/client/components/SkillAvatar.d.ts +1 -1
  39. package/lib/types/client/components/SkillDetailShell.d.ts +86 -0
  40. package/lib/types/client/components/SkillFiles.d.ts +32 -0
  41. package/lib/types/client/components/SourceStatusBar.d.ts +4 -0
  42. package/lib/types/client/icons.d.ts +4 -0
  43. package/lib/types/client/locales.d.ts +94 -0
  44. package/lib/types/client/relative-time.d.ts +13 -0
  45. package/lib/types/client/skill-insights.d.ts +30 -0
  46. package/lib/types/client/skill-markdown.d.ts +39 -0
  47. package/lib/types/client/state.d.ts +36 -7
  48. package/lib/types/config.d.ts +2 -0
  49. package/lib/types/market/cache.d.ts +62 -14
  50. package/lib/types/market/clawhub-provider.d.ts +15 -0
  51. package/lib/types/market/disk-cache.d.ts +16 -0
  52. package/lib/types/market/install-service.d.ts +1 -0
  53. package/lib/types/market/market-service.d.ts +40 -6
  54. package/lib/types/market/provider-fetch.d.ts +2 -0
  55. package/lib/types/market/types.d.ts +29 -0
  56. package/lib/types/skills/installed.d.ts +4 -0
  57. package/lib/types/skills/management.d.ts +63 -2
  58. package/lib/types/skills/root.d.ts +8 -0
  59. package/lib/types/web-routes.d.ts +3 -1
  60. package/lib/web-routes.js +85 -8
  61. package/package.json +1 -1
  62. package/release-notes/v0.0.1.md +1 -1
  63. package/release-notes/v0.0.2.md +12 -0
  64. package/release-notes/v0.0.3.md +12 -0
  65. package/lib/types/client/components/InstallStateBadge.d.ts +0 -11
  66. package/lib/types/client/components/SecurityBadge.d.ts +0 -16
  67. package/lib/types/client/components/SkillDetailView.d.ts +0 -21
@@ -22,7 +22,8 @@ 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 } from "./market-service.js";
25
+ import { getMarketSkillDetail, resolveMarketSkill, withMarketOwner } from "./market-service.js";
26
+ import { withProviderConfiguration } from "./provider-fetch.js";
26
27
  import { INSTALL_META_FILE, parseInstalledMeta, readSkillHeadline, } from "../skills/installed.js";
27
28
  import { MARKET_ERROR_CODES, MARKET_LIMITS, sanitizeDirName, skillId, } from "./types.js";
28
29
  const providers = {
@@ -103,9 +104,14 @@ function hasErrorCode(error, code) {
103
104
  return typeof error === 'object' && error !== null && error.code === code;
104
105
  }
105
106
  /** The upstream detail for a skill, or the market's own "not installable" verdict. */
106
- async function loadDetail(source, slug) {
107
- const { skill } = await getMarketSkillDetail(source, slug);
108
- return skill;
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.
@@ -155,7 +161,11 @@ async function readJsonIfExists(filePath) {
155
161
  * @throws {import('./types.ts').MarketUpstreamError} for upstream failures,
156
162
  * unwrapped so the route can classify them (404 vs 502).
157
163
  */
158
- export async function installMarketSkill(source, slug, options) {
164
+ export function installMarketSkill(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)));
167
+ }
168
+ async function installMarketSkillWithConfiguration(source, slug, options) {
159
169
  const dirName = sanitizeDirName(slug);
160
170
  if (dirName === null) {
161
171
  throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill slug cannot be used as a directory name: ${slug}`);
@@ -168,7 +178,7 @@ export async function installMarketSkill(source, slug, options) {
168
178
  if (inFlight.has(lockKey)) {
169
179
  throw new MarketInstallError(409, MARKET_ERROR_CODES.installInProgress, `Install already in progress: ${id}`);
170
180
  }
171
- const task = performInstall(source, slug, dirName, id, options.skillsRoot);
181
+ const task = performInstall(source, slug, dirName, id, options.skillsRoot, options.owner);
172
182
  // Store a settled-safe copy: the lock exists to reject *concurrent* work, and
173
183
  // an unhandled rejection here would crash the host on every failed install.
174
184
  inFlight.set(lockKey, task.catch(() => undefined));
@@ -179,17 +189,16 @@ export async function installMarketSkill(source, slug, options) {
179
189
  inFlight.delete(lockKey);
180
190
  }
181
191
  }
182
- async function performInstall(source, slug, dirName, id, skillsRootInput) {
192
+ async function performInstall(source, slug, dirName, id, skillsRootInput, requestedOwner) {
183
193
  const skillsRoot = path.resolve(skillsRootInput);
184
- const detail = await loadDetail(source, slug);
194
+ const detail = await loadDetail(source, slug, requestedOwner);
185
195
  if (detail.installState === 'installed') {
186
196
  throw new MarketInstallError(409, MARKET_ERROR_CODES.alreadyInstalled, `Skill already installed: ${id}`);
187
197
  }
188
198
  if (detail.installState === 'not-installable') {
189
199
  throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill is not installable (${detail.notInstallableReason ?? 'unknown'}): ${slug}`);
190
200
  }
191
- // The cached detail may be older than the provider's version index, so the
192
- // file list is fetched fresh at install time (mirrors the reference).
201
+ // Re-read the file inventory for the freshly resolved manifest version.
193
202
  const files = await providers[source].listFiles(slug, detail.version);
194
203
  if (files.length === 0 || !files.some((file) => file.path === 'SKILL.md')) {
195
204
  throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill has no installable SKILL.md: ${slug}`);
@@ -250,10 +259,14 @@ async function performInstall(source, slug, dirName, id, skillsRootInput) {
250
259
  ledger.push({ path: segments.join('/'), sha256: digest });
251
260
  }
252
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;
253
265
  const meta = {
254
266
  id,
255
267
  source,
256
268
  slug,
269
+ ...(owner ? { owner } : {}),
257
270
  ...(detail.version === undefined ? {} : { version: detail.version }),
258
271
  installedAt,
259
272
  files: ledger,
@@ -369,7 +382,7 @@ export async function uninstallMarketSkill(source, slug, options) {
369
382
  */
370
383
  async function refreshRemovedSkill(source, slug, id, dirName, meta, headline) {
371
384
  try {
372
- const { skill } = await getMarketSkillDetail(source, slug);
385
+ const { skill } = await getMarketSkillDetail(source, slug, { owner: meta.owner });
373
386
  return { ...skill, installState: 'installable', notInstallableReason: undefined, installedInfo: undefined };
374
387
  }
375
388
  catch {
@@ -5,19 +5,24 @@
5
5
  * cross-source dedupe, per-source health/degradation reporting, TTL caching
6
6
  * (stale-while-error) and locally-computed install state.
7
7
  *
8
- * The module is deliberately free of DSH services and of the filesystem: the
8
+ * The module is deliberately free of DSH services: the
9
9
  * "what is installed locally" question is answered by an injected
10
10
  * `InstalledLookup` (see `setInstalledLookup`), so this file can be unit-tested
11
11
  * by pointing the providers at a stub server.
12
12
  */
13
- import { getSourceHealth, markSourceHealth, MARKET_TTL, marketCache } from "./cache.js";
14
- import { clawhubProvider } from "./clawhub-provider.js";
13
+ import { getSourceHealth, markSourceHealth, marketCache, noteMarketStat } from "./cache.js";
14
+ import { getProviderBase, withProviderConfiguration } from "./provider-fetch.js";
15
+ import { clawhubOwnerFor, clawhubProvider, setClawhubOwnerHints, withClawhubOwner } from "./clawhub-provider.js";
16
+ import { catalogCategories, catalogClawhubOwners, catalogEntryFor, catalogEntryToSkill, filterCatalog, getCatalog, } from "../catalog/catalog.js";
15
17
  import { skillhubProvider } from "./skillhub-provider.js";
16
18
  import { MARKET_LIMITS, MARKET_SOURCES, detectMarketLanguage, sanitizeDirName, skillId, } from "./types.js";
17
19
  const providers = {
18
20
  clawhub: clawhubProvider,
19
21
  skillhub: skillhubProvider,
20
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());
21
26
  /** Nothing is installed until the host installs its real lookup. */
22
27
  const EMPTY_INSTALLED_LOOKUP = {
23
28
  has: () => false,
@@ -128,7 +133,9 @@ export function dedupeSkills(items) {
128
133
  for (const item of copies) {
129
134
  if (item.source === 'skillhub' && item.upstream?.slug) {
130
135
  const original = byClawhubSlug.get(item.upstream.slug);
131
- 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) {
132
139
  original.mirrors = [...(original.mirrors ?? []), item.id];
133
140
  // Enrich the original with SkillHub-only data.
134
141
  if (!original.iconUrl && item.iconUrl)
@@ -154,23 +161,35 @@ export function dedupeSkills(items) {
154
161
  * segment so every key is obviously source-scoped.
155
162
  */
156
163
  function providerPageCacheKey(source, kind, params) {
157
- return `${kind}:${source}:${JSON.stringify([params.q ?? null, params.cursor ?? null, params.limit])}`;
164
+ return `${kind}:${source}:${JSON.stringify([getProviderBase(source), params.q ?? null, params.cursor ?? null, params.limit])}`;
158
165
  }
159
- async function fetchProviderPage(source, params) {
166
+ async function fetchProviderPage(source, params, cache, options = {}) {
160
167
  const isSearch = Boolean(params.q);
161
168
  const cacheKey = providerPageCacheKey(source, isSearch ? 'search' : 'list', params);
162
- const ttl = isSearch ? MARKET_TTL.search : MARKET_TTL.list;
163
- const cached = marketCache.get(cacheKey);
164
- if (cached) {
165
- return { page: cached, status: { status: 'ok', fetchedAt: Date.now(), fromCache: true } };
169
+ const forced = options.force === true;
170
+ if (forced) {
171
+ // A reader asked for this page on purpose; the entry is still rewritten
172
+ // below, so the refresh costs one request and then serves everyone again.
173
+ noteMarketStat('forcedRefreshes');
174
+ }
175
+ else {
176
+ const hit = await cache.getRecord(cacheKey);
177
+ if (hit) {
178
+ noteMarketStat('hits');
179
+ // The real fetch time travels with the payload: a hit is a snapshot, and
180
+ // the panel has to be able to say how old it is.
181
+ return { page: hit.value, status: { status: 'ok', fetchedAt: hit.storedAt, fromCache: true } };
182
+ }
166
183
  }
184
+ noteMarketStat('misses');
167
185
  try {
168
186
  const q = params.q;
169
187
  const page = isSearch && q !== undefined
170
188
  ? await providers[source].search({ q, cursor: params.cursor, limit: params.limit })
171
189
  : await providers[source].list({ cursor: params.cursor, limit: params.limit });
172
- marketCache.set(cacheKey, page, ttl);
173
- return { page, status: { status: 'ok', fetchedAt: Date.now(), fromCache: false } };
190
+ noteMarketStat('upstreamRequests');
191
+ const storedAt = await cache.set(cacheKey, page);
192
+ return { page, status: { status: 'ok', fetchedAt: storedAt, fromCache: false } };
174
193
  }
175
194
  catch (error) {
176
195
  const message = error instanceof Error ? error.message : String(error);
@@ -181,8 +200,9 @@ async function fetchProviderPage(source, params) {
181
200
  markSourceHealth(source, 'failed', message);
182
201
  // Stale-while-error: a degraded source still serves its last good page, so a
183
202
  // temporary upstream outage does not empty the panel.
184
- const stale = marketCache.getStale(cacheKey);
203
+ const stale = await cache.getStale(cacheKey);
185
204
  if (stale) {
205
+ noteMarketStat('staleServed');
186
206
  return {
187
207
  page: stale.value,
188
208
  status: { status: 'cached', fetchedAt: stale.storedAt, fromCache: true, error: message },
@@ -191,7 +211,13 @@ async function fetchProviderPage(source, params) {
191
211
  return { page: null, status: { ...getSourceHealth(source), fromCache: false, error: message } };
192
212
  }
193
213
  }
194
- export async function listMarketSkills(params) {
214
+ export function listMarketSkills(params) {
215
+ if (params.scope !== 'market')
216
+ return Promise.resolve(listCatalogSkills(params));
217
+ const cache = marketCache;
218
+ return withProviderConfiguration(() => listMarketSkillsWithCache(params, cache));
219
+ }
220
+ async function listMarketSkillsWithCache(params, cache) {
195
221
  const cursor = decodeCursor(params.cursor);
196
222
  const isFirstPage = !params.cursor;
197
223
  const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source];
@@ -207,7 +233,7 @@ export async function listMarketSkills(params) {
207
233
  // ClawHub search has no pagination: ask it for one big page and let the
208
234
  // aggregate cap apply, instead of pretending a second page exists.
209
235
  const limit = q && source === 'clawhub' ? MARKET_LIMITS.searchResultCap : params.limit;
210
- outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }));
236
+ outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }, cache, { force: params.refresh === true }));
211
237
  }));
212
238
  let merged = [];
213
239
  const nextCursor = {};
@@ -238,21 +264,94 @@ export async function listMarketSkills(params) {
238
264
  }
239
265
  return { items: merged, nextCursor: encodeCursor(nextCursor), sources };
240
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
+ }
241
303
  // ─── Detail / file content ───────────────────────────────────────────────────
242
- export async function getMarketSkillDetail(source, slug) {
243
- const cacheKey = `detail:${source}:${slug}`;
244
- let detail = marketCache.get(cacheKey);
245
- let sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: true };
246
- if (!detail) {
304
+ export function getMarketSkillDetail(source, slug, options = {}) {
305
+ const cache = marketCache;
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
+ };
326
+ }
327
+ async function getMarketSkillDetailWithCache(source, slug, options, cache) {
328
+ const cacheKey = `detail:${source}:${JSON.stringify([getProviderBase(source), slug, ...ownerKeyPart(source, slug)])}`;
329
+ const forced = options.force === true;
330
+ if (forced)
331
+ noteMarketStat('forcedRefreshes');
332
+ const hit = forced ? undefined : await cache.getRecord(cacheKey);
333
+ let detail;
334
+ let sourceStatus;
335
+ if (hit) {
336
+ noteMarketStat('hits');
337
+ detail = hit.value;
338
+ sourceStatus = { status: 'ok', fetchedAt: hit.storedAt, fromCache: true };
339
+ }
340
+ else {
341
+ noteMarketStat('misses');
247
342
  try {
248
343
  detail = await providers[source].detail(slug);
249
- marketCache.set(cacheKey, detail, MARKET_TTL.detail);
250
- sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: false };
344
+ noteMarketStat('upstreamRequests');
345
+ const storedAt = await cache.set(cacheKey, detail);
346
+ sourceStatus = { status: 'ok', fetchedAt: storedAt, fromCache: false };
251
347
  }
252
348
  catch (error) {
253
- const stale = marketCache.getStale(cacheKey);
349
+ if (options.allowStale === false)
350
+ throw error;
351
+ const stale = await cache.getStale(cacheKey);
254
352
  if (!stale)
255
353
  throw error;
354
+ noteMarketStat('staleServed');
256
355
  detail = stale.value;
257
356
  sourceStatus = {
258
357
  status: 'cached',
@@ -274,13 +373,36 @@ export function isValidMarketFilePath(filePath) {
274
373
  return false;
275
374
  return true;
276
375
  }
277
- export async function getMarketFileContent(source, slug, filePath) {
376
+ export function getMarketFileContent(source, slug, filePath, owner) {
377
+ const cache = marketCache;
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}`] : [];
395
+ }
396
+ async function getMarketFileContentWithCache(source, slug, filePath, cache) {
278
397
  // JSON-encoded for the same reason as the list keys: slug and filePath are
279
398
  // both caller-controlled, so a `:`-joined key is ambiguous.
280
- const cacheKey = `file:${source}:${JSON.stringify([slug, filePath])}`;
281
- const cached = marketCache.get(cacheKey);
282
- if (cached)
283
- return cached;
399
+ const cacheKey = `file:${source}:${JSON.stringify([getProviderBase(source), slug, filePath, ...ownerKeyPart(source, slug)])}`;
400
+ const cached = await cache.getRecord(cacheKey);
401
+ if (cached) {
402
+ noteMarketStat('hits');
403
+ return cached.value;
404
+ }
405
+ noteMarketStat('misses');
284
406
  const fetched = await providers[source].fetchFile(slug, filePath);
285
407
  let content = fetched.content;
286
408
  let truncated = false;
@@ -295,7 +417,7 @@ export async function getMarketFileContent(source, slug, filePath) {
295
417
  size: fetched.size,
296
418
  truncated,
297
419
  };
298
- marketCache.set(cacheKey, result, MARKET_TTL.fileContent);
420
+ await cache.set(cacheKey, result);
299
421
  return result;
300
422
  }
301
423
  // ─── Status ──────────────────────────────────────────────────────────────────
@@ -305,8 +427,15 @@ export function getMarketStatus() {
305
427
  skillhub: getSourceHealth('skillhub'),
306
428
  };
307
429
  }
308
- /** Look up a single skill (used by install) — the detail path, bypassing list. */
309
- export async function resolveMarketSkill(source, slug) {
310
- const { skill } = await getMarketSkillDetail(source, slug);
430
+ /**
431
+ * Look up a single skill for an install (the detail path, bypassing the list).
432
+ *
433
+ * Always forced: a cached manifest is a snapshot of the *catalogue*, and
434
+ * installing is the one action that has to be authorized by what upstream says
435
+ * right now — the file bytes are fetched fresh and hash-verified against the
436
+ * list, so the list must come from the same generation as those bytes.
437
+ */
438
+ export async function resolveMarketSkill(source, slug, owner) {
439
+ const { skill } = await getMarketSkillDetail(source, slug, { force: true, allowStale: false, owner });
311
440
  return skill;
312
441
  }
@@ -10,6 +10,7 @@
10
10
  * `process.env` at module scope, so the module (and every provider built on it)
11
11
  * is directly unit-testable by pointing the bases at a local stub.
12
12
  */
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
14
  import { markSourceHealth } from "./cache.js";
14
15
  import { MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError } from "./types.js";
15
16
  const DEFAULT_CONFIG = {
@@ -20,6 +21,11 @@ const DEFAULT_CONFIG = {
20
21
  userAgent: 'dsh-skills-hub',
21
22
  };
22
23
  let currentConfig = { ...DEFAULT_CONFIG };
24
+ const requestConfig = new AsyncLocalStorage();
25
+ /** Keep cache keys and all chained upstream reads on one configuration snapshot. */
26
+ export function withProviderConfiguration(operation) {
27
+ return requestConfig.run(requestConfig.getStore() ?? currentConfig, operation);
28
+ }
23
29
  /** Strip trailing slashes so `new URL('/api/...', base)` never doubles a separator. */
24
30
  function normalizeBaseUrl(value) {
25
31
  return value.replace(/\/+$/, '');
@@ -51,7 +57,8 @@ export function configureProviderFetch(config) {
51
57
  }
52
58
  /** Base URL of one upstream, as resolved from the active configuration. */
53
59
  export function getProviderBase(source) {
54
- return source === 'clawhub' ? currentConfig.clawhubBaseUrl : currentConfig.skillhubBaseUrl;
60
+ const config = requestConfig.getStore() ?? currentConfig;
61
+ return source === 'clawhub' ? config.clawhubBaseUrl : config.skillhubBaseUrl;
55
62
  }
56
63
  /**
57
64
  * An upstream answered with a non-2xx status (or a retryable 429/5xx).
@@ -93,7 +100,7 @@ function requestHeaders(init, userAgent) {
93
100
  * provider can map it to a meaningful error.
94
101
  */
95
102
  export async function providerFetch(source, url, init) {
96
- const config = currentConfig;
103
+ const config = requestConfig.getStore() ?? currentConfig;
97
104
  const attempts = Math.max(1, config.retries + 1);
98
105
  let lastError;
99
106
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -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) => ({
@@ -70,7 +70,8 @@ export function sanitizeDirName(slug) {
70
70
  return name;
71
71
  }
72
72
  const LANG_MAP = {
73
- md: 'markdown', ts: 'typescript', tsx: 'typescript',
73
+ md: 'markdown', markdown: 'markdown', mdx: 'markdown',
74
+ ts: 'typescript', tsx: 'typescript',
74
75
  js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
75
76
  json: 'json', yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash', zsh: 'bash',
76
77
  py: 'python', toml: 'toml', css: 'css', html: 'html',
@@ -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,