@adea-ai/plugins 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
@@ -0,0 +1,592 @@
1
+ import type { Catalog, Plugin, PluginRelease } from './schema.js'
2
+ import { iconAssetName, type IconContentType } from './icons.js'
3
+ import { immutableAssetUrl } from './publication.js'
4
+
5
+ /**
6
+ * Placement policy that decides which source variant represents a product.
7
+ * Trusted compiler configuration; never derived from upstream plugin content.
8
+ */
9
+ export interface ProductPreference {
10
+ readonly schemaVersion: 1
11
+ readonly sourcePreference: readonly string[]
12
+ readonly canonicalOverrides?: Readonly<Record<string, string>>
13
+ }
14
+
15
+ /**
16
+ * Source order used when no variant is published by the product's own vendor.
17
+ * The Claude Code marketplace leads because it carries the largest catalog;
18
+ * the knowledge-work marketplace re-publishes many of the same products.
19
+ */
20
+ export const DEFAULT_SOURCE_PREFERENCE: readonly string[] = [
21
+ 'claude-official',
22
+ 'cursor-official',
23
+ 'openai-official',
24
+ 'knowledge-work-official',
25
+ ]
26
+
27
+ export const DEFAULT_TOP_COUNT = 6
28
+
29
+ export function parseProductPreference(input: unknown): ProductPreference {
30
+ if (typeof input !== 'object' || input === null) throw new Error('PRODUCT_PREFERENCE_INVALID')
31
+ const raw = input as Partial<ProductPreference>
32
+ if (raw.schemaVersion !== 1) throw new Error('PRODUCT_PREFERENCE_INVALID')
33
+ if (
34
+ !Array.isArray(raw.sourcePreference) ||
35
+ raw.sourcePreference.length === 0 ||
36
+ raw.sourcePreference.some((value) => typeof value !== 'string' || value.length === 0)
37
+ )
38
+ throw new Error('PRODUCT_PREFERENCE_INVALID')
39
+ if (new Set(raw.sourcePreference).size !== raw.sourcePreference.length)
40
+ throw new Error('PRODUCT_PREFERENCE_DUPLICATE_SOURCE')
41
+ const overrides = raw.canonicalOverrides
42
+ if (overrides !== undefined) {
43
+ if (typeof overrides !== 'object' || overrides === null)
44
+ throw new Error('PRODUCT_PREFERENCE_INVALID')
45
+ for (const [productKey, pluginId] of Object.entries(overrides))
46
+ if (typeof pluginId !== 'string' || !pluginId.startsWith('plugin:'))
47
+ throw new Error(`PRODUCT_PREFERENCE_OVERRIDE_INVALID: ${productKey}`)
48
+ }
49
+ return {
50
+ schemaVersion: 1,
51
+ sourcePreference: [...raw.sourcePreference],
52
+ ...(overrides ? { canonicalOverrides: { ...overrides } } : {}),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * A brand mark resolved at compile time and mirrored into the catalog release,
58
+ * so a client renders it without a second lookup or an upstream fetch.
59
+ */
60
+ export interface ProductIcon {
61
+ /** `content` is the vendor's own plugin file; `favicon` is the product site's. */
62
+ readonly kind: 'content' | 'favicon'
63
+ /** Release asset name; fetch it from the release for this `catalogId`. */
64
+ readonly asset: string
65
+ /**
66
+ * Absolute, immutable URL for the asset, present when the build knows where
67
+ * the catalog will be published. A client renders this and never composes a
68
+ * URL; a deployment that hosts assets elsewhere substitutes its base here.
69
+ */
70
+ readonly assetUrl?: string
71
+ readonly digest: string
72
+ readonly contentType: IconContentType
73
+ readonly bytes: number
74
+ /** Plugin-relative path the mark came from, for `content`. */
75
+ readonly path: string | null
76
+ /** Site icon URL, for `favicon`. */
77
+ readonly url: string | null
78
+ readonly homepage: string | null
79
+ }
80
+
81
+ /**
82
+ * Deterministic rendering hint for a product without a brand mark: initials and
83
+ * a stable hue. Clients draw it locally, which keeps it crisp, themable and
84
+ * free of a network request.
85
+ */
86
+ export interface ProductMonogram {
87
+ readonly text: string
88
+ readonly hue: number
89
+ }
90
+
91
+ /**
92
+ * The install-relevant facts of the release a product installs.
93
+ *
94
+ * A browsing client shows a plugin, then plans an install from it: the digest it
95
+ * must pin, the connectors and credentials setup will ask for, and the
96
+ * capabilities the harness has to negotiate. Carrying them here means the grid
97
+ * needs no second artifact.
98
+ */
99
+ export interface ConsumerRelease {
100
+ readonly releaseId: string
101
+ readonly canonicalContentDigest: string
102
+ readonly contentResolution: string
103
+ readonly capabilities: readonly { readonly type: string; readonly name: string }[]
104
+ readonly requiredConnectors: readonly string[]
105
+ readonly requiredCredentials: readonly string[]
106
+ /** Commit the release was resolved from, for audit and update state. */
107
+ readonly sourceRevision: string
108
+ readonly upstreamVersion?: string
109
+ /** Canonical package status, when the release carries one. */
110
+ readonly packageStatus?: string
111
+ readonly packageDigest?: string
112
+ }
113
+
114
+ /** One installable product, resolved from every source variant that publishes it. */ /**
115
+ * The install-relevant facts of the release a product installs.
116
+ *
117
+ * A browsing client shows a plugin, then plans an install from it: the digest it
118
+ * must pin, the connectors and credentials setup will ask for, and the
119
+ * capabilities the harness has to negotiate. Carrying them here means the grid
120
+ * needs no second artifact.
121
+ */
122
+ export interface ConsumerRelease {
123
+ readonly releaseId: string
124
+ readonly canonicalContentDigest: string
125
+ readonly contentResolution: string
126
+ readonly capabilities: readonly { readonly type: string; readonly name: string }[]
127
+ readonly requiredConnectors: readonly string[]
128
+ readonly requiredCredentials: readonly string[]
129
+ /** Commit the release was resolved from, for audit and update state. */
130
+ readonly sourceRevision: string
131
+ readonly upstreamVersion?: string
132
+ /** Canonical package status, when the release carries one. */
133
+ readonly packageStatus?: string
134
+ readonly packageDigest?: string
135
+ }
136
+
137
+ /** One installable product, resolved from every source variant that publishes it. */
138
+ export interface ConsumerProduct {
139
+ readonly productKey: string
140
+ readonly pluginId: string
141
+ readonly variantPluginIds: readonly string[]
142
+ readonly variantsIdentical: boolean
143
+ readonly displayName: string
144
+ readonly upstreamName: string
145
+ readonly description: string
146
+ readonly sourceId: string
147
+ /**
148
+ * Who publishes the product, from the canonical variant. A card renders the
149
+ * first author as the publisher, so it travels with the product.
150
+ */
151
+ readonly authors: readonly string[]
152
+ /** Where the canonical release came from, for source links and audit. */
153
+ readonly provenance: {
154
+ readonly repositoryUrl: string
155
+ readonly resolvedCommitSha: string
156
+ readonly manifestPath: string
157
+ readonly pluginSubdirectory: string
158
+ }
159
+ readonly categories: readonly string[]
160
+ readonly primaryCategory: string
161
+ readonly license: string
162
+ readonly homepage?: string
163
+ readonly capabilitySummary: Readonly<Record<string, number>>
164
+ readonly compatibility: Readonly<Record<string, string>>
165
+ readonly releaseId: string
166
+ /** The release `pluginId` pins, with the facts an install needs. */
167
+ readonly release: ConsumerRelease
168
+ readonly keywords: readonly string[]
169
+ /** Security classification of the canonical variant, for a detail panel. */
170
+ readonly securityClassification: {
171
+ readonly level: string
172
+ readonly reasons: readonly string[]
173
+ readonly permissionSensitiveChanges: readonly string[]
174
+ readonly contentResolution: string
175
+ }
176
+ readonly icon: ProductIcon | null
177
+ readonly monogram: ProductMonogram
178
+ }
179
+
180
+ export interface ConsumerCategory {
181
+ readonly category: string
182
+ readonly productCount: number
183
+ readonly pluginCount: number
184
+ readonly pluginIds: readonly string[]
185
+ readonly productKeys: readonly string[]
186
+ readonly topProductKeys: readonly string[]
187
+ /** Artifact that carries full records for `topProductKeys`. */
188
+ readonly shelfArtifact: string
189
+ /** Artifact that carries full records for every product in the category. */
190
+ readonly listArtifact: string
191
+ }
192
+
193
+ /** A curated name that no longer resolves to a product in its category. */
194
+ export interface LeadingDiagnostic {
195
+ readonly category: string
196
+ readonly name: string
197
+ readonly reason: 'UNKNOWN_CATEGORY' | 'PRODUCT_NOT_FOUND' | 'PRODUCT_NOT_IN_CATEGORY'
198
+ }
199
+
200
+ /** Brand-mark coverage, so an incomplete set is visible rather than implied. */
201
+ export interface IconCoverage {
202
+ readonly total: number
203
+ readonly content: number
204
+ readonly favicon: number
205
+ readonly monogramOnly: number
206
+ /** Products still rendering a monogram, in display order: the curation list. */
207
+ readonly monogramProductKeys: readonly string[]
208
+ }
209
+
210
+ export function iconCoverage(index: Pick<ConsumerIndex, 'products'>): IconCoverage {
211
+ const products = Object.values(index.products)
212
+ const monogramOnly = products
213
+ .filter((product) => !product.icon)
214
+ .map((product) => product.productKey)
215
+ .toSorted()
216
+ return {
217
+ total: products.length,
218
+ content: products.filter((product) => product.icon?.kind === 'content').length,
219
+ favicon: products.filter((product) => product.icon?.kind === 'favicon').length,
220
+ monogramOnly: monogramOnly.length,
221
+ monogramProductKeys: monogramOnly,
222
+ }
223
+ }
224
+
225
+ export interface ConsumerIndex {
226
+ readonly topCount: number
227
+ readonly sourcePreference: readonly string[]
228
+ readonly counts: {
229
+ readonly plugins: number
230
+ readonly products: number
231
+ readonly redundantPlugins: number
232
+ }
233
+ readonly products: Readonly<Record<string, ConsumerProduct>>
234
+ readonly categories: readonly ConsumerCategory[]
235
+ readonly diagnostics: readonly LeadingDiagnostic[]
236
+ /** Present on the navigation artifact; summarised from `products`. */
237
+ readonly iconCoverage?: IconCoverage
238
+ }
239
+
240
+ function isHumanFormatted(value: string): boolean {
241
+ return /[A-Z]/.test(value) || /\s/.test(value)
242
+ }
243
+
244
+ /**
245
+ * Prefers content published by the product's own vendor over a marketplace's
246
+ * own wrapper package, then applies the configured source order.
247
+ */
248
+ function selectCanonical(
249
+ variants: readonly Plugin[],
250
+ sourceRepository: ReadonlyMap<string, string>,
251
+ preference: ProductPreference
252
+ ): Plugin {
253
+ const override = preference.canonicalOverrides?.[variants[0]?.productGroupingKey ?? '']
254
+ const overridden = override ? variants.find((plugin) => plugin.pluginId === override) : undefined
255
+ if (overridden) return overridden
256
+ const rank = (plugin: Plugin): number => {
257
+ const index = preference.sourcePreference.indexOf(plugin.sourceId)
258
+ return index === -1 ? preference.sourcePreference.length : index
259
+ }
260
+ // A variant with no resolved release repository proves nothing about its
261
+ // origin, so it is treated as marketplace-shipped rather than vendor-published.
262
+ const vendorPublished = (plugin: Plugin): boolean => {
263
+ const repository = plugin.availableReleases[0]?.resolvedRepositoryUrl
264
+ return repository !== undefined && repository !== sourceRepository.get(plugin.sourceId)
265
+ }
266
+ return [...variants].toSorted(
267
+ (left, right) =>
268
+ Number(vendorPublished(right)) - Number(vendorPublished(left)) ||
269
+ rank(left) - rank(right) ||
270
+ Number(isHumanFormatted(right.displayName)) - Number(isHumanFormatted(left.displayName)) ||
271
+ left.pluginId.localeCompare(right.pluginId)
272
+ )[0]!
273
+ }
274
+
275
+ /** Picks the most presentable label among a product's variants. */
276
+ function selectDisplayName(variants: readonly Plugin[], canonical: Plugin): string {
277
+ const formatted = variants
278
+ .map((plugin) => plugin.displayName)
279
+ .filter((value) => isHumanFormatted(value) && value !== '')
280
+ .toSorted()
281
+ return formatted[0] ?? canonical.displayName
282
+ }
283
+
284
+ export const SHELF_ARTIFACT_PREFIX = 'shelf-'
285
+ export const CATEGORY_ARTIFACT_PREFIX = 'category-'
286
+ export const CATALOG_INDEX_ARTIFACT = 'catalog-index.v1.json'
287
+
288
+ /** Shard artifact names are derived from the category so they stay stable. */
289
+ export function shardArtifactNames(category: string): {
290
+ shelf: string
291
+ list: string
292
+ } {
293
+ if (!/^[a-z0-9][a-z0-9-]{1,63}$/.test(category))
294
+ throw new Error(`CATEGORY_SLUG_INVALID: ${category}`)
295
+ return {
296
+ shelf: `${SHELF_ARTIFACT_PREFIX}${category}.v1.json`,
297
+ list: `${CATEGORY_ARTIFACT_PREFIX}${category}.v1.json`,
298
+ }
299
+ }
300
+
301
+ /** The release a product's install target pins, when it is still resolvable. */
302
+ function currentRelease(plugin: Plugin): PluginRelease | undefined {
303
+ return (
304
+ plugin.availableReleases.find((release) => release.releaseId === plugin.currentReleaseId) ??
305
+ plugin.availableReleases[0]
306
+ )
307
+ }
308
+
309
+ /** Reads a nested string out of untrusted release metadata. */
310
+ function nestedString(value: unknown, key: string): string | undefined {
311
+ if (typeof value !== 'object' || value === null) return undefined
312
+ const nested = (value as Record<string, unknown>)[key]
313
+ return typeof nested === 'string' && nested.length > 0 ? nested : undefined
314
+ }
315
+
316
+ function describeRelease(plugin: Plugin): ConsumerRelease {
317
+ const release = currentRelease(plugin)!
318
+ const packages = ((release.releaseMetadata ?? {}) as Record<string, unknown>).agentPlugins
319
+ return {
320
+ releaseId: release.releaseId,
321
+ canonicalContentDigest: release.canonicalContentDigest,
322
+ contentResolution: release.contentResolution,
323
+ capabilities: release.capabilities.map((capability) => ({
324
+ type: capability.type,
325
+ name: capability.name,
326
+ })),
327
+ requiredConnectors: [...release.requiredConnectors],
328
+ requiredCredentials: [...release.requiredCredentials],
329
+ sourceRevision: release.resolvedCommitSha,
330
+ ...(release.upstreamVersion ? { upstreamVersion: release.upstreamVersion } : {}),
331
+ ...(nestedString(packages, 'status')
332
+ ? { packageStatus: nestedString(packages, 'status')! }
333
+ : {}),
334
+ ...(nestedString(packages, 'packageDigest')
335
+ ? { packageDigest: nestedString(packages, 'packageDigest')! }
336
+ : {}),
337
+ }
338
+ }
339
+
340
+ /** Prefers the canonical variant's mark, then any variant that has one. */
341
+ function selectProductIcon(
342
+ variants: readonly Plugin[],
343
+ canonical: Plugin,
344
+ catalogId: string,
345
+ publicationRepositoryUrl: string | undefined,
346
+ publicationAssetsBaseUrl: string | undefined
347
+ ): ProductIcon | null {
348
+ const ordered = [canonical, ...variants.filter((plugin) => plugin !== canonical)]
349
+ for (const plugin of ordered) {
350
+ const icon = currentRelease(plugin)?.icon
351
+ if (!icon) continue
352
+ const asset = iconAssetName(icon.digest, icon.contentType)
353
+ return {
354
+ kind: icon.kind,
355
+ asset,
356
+ ...(publicationAssetsBaseUrl
357
+ ? { assetUrl: `${publicationAssetsBaseUrl}/${asset}` }
358
+ : publicationRepositoryUrl
359
+ ? { assetUrl: immutableAssetUrl(publicationRepositoryUrl, catalogId, asset) }
360
+ : {}),
361
+ digest: icon.digest,
362
+ contentType: icon.contentType,
363
+ bytes: icon.bytes,
364
+ path: icon.kind === 'content' ? icon.path : null,
365
+ url: icon.kind === 'favicon' ? icon.url : null,
366
+ homepage: icon.kind === 'favicon' ? icon.homepage : null,
367
+ }
368
+ }
369
+ return null
370
+ }
371
+
372
+ const MONOGRAM_HUE_OFFSETS = [0, 137, 274] as const
373
+
374
+ /**
375
+ * Initials and a hue derived from the product key, so a product without a mark
376
+ * still renders a stable tile instead of a blank one.
377
+ */
378
+ export function monogramFor(displayName: string, productKey: string): ProductMonogram {
379
+ const words = displayName.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
380
+ const first = words[0] ?? productKey
381
+ const second = words[1]
382
+ const text = (
383
+ words.length > 1 && second ? (first[0] ?? '') + (second[0] ?? '') : first.slice(0, 2)
384
+ ).toUpperCase()
385
+ let hash = 0
386
+ for (const character of productKey) hash = (hash * 31 + character.codePointAt(0)!) % 360
387
+ // Keep adjacent hues off each other so side-by-side tiles stay distinct.
388
+ return { text: text || '?', hue: (hash + MONOGRAM_HUE_OFFSETS[hash % 3]!) % 360 }
389
+ }
390
+
391
+ function compactCompatibility(plugin: Plugin): Record<string, string> {
392
+ return Object.fromEntries(
393
+ Object.entries(plugin.harnessCompatibility).map(([harness, value]) => [harness, value.status])
394
+ )
395
+ }
396
+
397
+ /**
398
+ * Builds the deduplicated, pre-sorted consumer view of a catalog.
399
+ *
400
+ * Each product appears once, represented by its canonical variant, and each
401
+ * category carries a display order plus the curated shelf of top products.
402
+ * Nothing here is random: every order is either configured or derived from
403
+ * stable identifiers.
404
+ */
405
+ export function buildConsumerIndex(input: {
406
+ readonly catalog: Pick<Catalog, 'plugins' | 'sources'>
407
+ readonly leading?: Readonly<Record<string, readonly string[]>>
408
+ readonly topCount?: number
409
+ readonly preference?: ProductPreference
410
+ /** Where the catalog will be published, so records can carry absolute URLs. */
411
+ readonly publicationRepositoryUrl?: string
412
+ /** Committed asset base a browser can fetch directly; preferred when set. */
413
+ readonly publicationAssetsBaseUrl?: string
414
+ }): ConsumerIndex {
415
+ const preference = input.preference ?? {
416
+ schemaVersion: 1 as const,
417
+ sourcePreference: [...DEFAULT_SOURCE_PREFERENCE],
418
+ }
419
+ const leading = input.leading ?? {}
420
+ const topCount = input.topCount ?? DEFAULT_TOP_COUNT
421
+ const sourceRepository = new Map(
422
+ input.catalog.sources.map((source) => [source.sourceId, source.repositoryUrl])
423
+ )
424
+ const variantsByProduct = new Map<string, Plugin[]>()
425
+ for (const plugin of input.catalog.plugins) {
426
+ const list = variantsByProduct.get(plugin.productGroupingKey)
427
+ if (list) list.push(plugin)
428
+ else variantsByProduct.set(plugin.productGroupingKey, [plugin])
429
+ }
430
+ const catalogId = (input.catalog as { catalogId?: string }).catalogId ?? ''
431
+ const products: Record<string, ConsumerProduct> = {}
432
+ for (const [productKey, variants] of [...variantsByProduct].toSorted((left, right) =>
433
+ left[0].localeCompare(right[0])
434
+ )) {
435
+ const canonical = selectCanonical(variants, sourceRepository, preference)
436
+ const variantPluginIds = variants
437
+ .map((plugin) => plugin.pluginId)
438
+ .toSorted((left, right) =>
439
+ left === canonical.pluginId
440
+ ? -1
441
+ : right === canonical.pluginId
442
+ ? 1
443
+ : left.localeCompare(right)
444
+ )
445
+ const releaseIds = new Set(variants.map((plugin) => plugin.currentReleaseId))
446
+ const productCategories = [
447
+ ...new Set(variants.flatMap((plugin) => plugin.categories)),
448
+ ].toSorted()
449
+ products[productKey] = {
450
+ productKey,
451
+ pluginId: canonical.pluginId,
452
+ variantPluginIds,
453
+ variantsIdentical: releaseIds.size === 1 && variants.length > 1,
454
+ displayName: selectDisplayName(variants, canonical),
455
+ upstreamName: canonical.upstreamPluginName,
456
+ description:
457
+ canonical.description || (variants.find((v) => v.description)?.description ?? ''),
458
+ sourceId: canonical.sourceId,
459
+ authors: [...canonical.authors],
460
+ provenance: {
461
+ repositoryUrl: canonical.provenance.repositoryUrl,
462
+ resolvedCommitSha: currentRelease(canonical)!.resolvedCommitSha,
463
+ manifestPath: canonical.provenance.manifestPath,
464
+ pluginSubdirectory: canonical.provenance.pluginSubdirectory,
465
+ },
466
+ // A product's categories are the union across its variants: sources
467
+ // disagree (one marketplace files Slack under productivity, another under
468
+ // communication), and a product belongs wherever any variant is filed.
469
+ categories: productCategories,
470
+ primaryCategory: productCategories[0] ?? 'other',
471
+ license: canonical.license.name,
472
+ ...(canonical.homepage ? { homepage: canonical.homepage } : {}),
473
+ capabilitySummary: { ...canonical.capabilitySummary },
474
+ compatibility: compactCompatibility(canonical),
475
+ releaseId: canonical.currentReleaseId,
476
+ release: describeRelease(canonical),
477
+ keywords: [...canonical.keywords],
478
+ securityClassification: {
479
+ level: canonical.securityClassification.level,
480
+ reasons: [...canonical.securityClassification.reasons],
481
+ permissionSensitiveChanges: [
482
+ ...canonical.securityClassification.permissionSensitiveChanges,
483
+ ],
484
+ contentResolution: canonical.securityClassification.contentResolution,
485
+ },
486
+ icon: selectProductIcon(
487
+ variants,
488
+ canonical,
489
+ catalogId,
490
+ input.publicationRepositoryUrl,
491
+ input.publicationAssetsBaseUrl
492
+ ),
493
+ monogram: monogramFor(selectDisplayName(variants, canonical), productKey),
494
+ }
495
+ }
496
+
497
+ const categoryNames = [
498
+ ...new Set(input.catalog.plugins.flatMap((plugin) => plugin.categories)),
499
+ ].toSorted()
500
+ const diagnostics: LeadingDiagnostic[] = []
501
+ // Resolve every curated name first, so a category's shelf can never lose a
502
+ // curated product to another category that merely reached it by fill order.
503
+ const curatedByCategory = new Map<string, string[]>()
504
+ for (const category of categoryNames) {
505
+ const members = input.catalog.plugins.filter((plugin) => plugin.categories.includes(category))
506
+ const curated: string[] = []
507
+ for (const name of leading[category] ?? []) {
508
+ const matches = members.filter((plugin) => plugin.upstreamPluginName === name)
509
+ if (matches.length === 0) {
510
+ diagnostics.push({
511
+ category,
512
+ name,
513
+ reason: variantsByProduct.has(name) ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
514
+ })
515
+ continue
516
+ }
517
+ const key = matches[0]!.productGroupingKey
518
+ if (!curated.includes(key)) curated.push(key)
519
+ }
520
+ curatedByCategory.set(category, curated)
521
+ }
522
+ // A shelf product headlined once, assigned to the first category that curates
523
+ // it. Every curated product is reserved up front, so a later category's fill
524
+ // can never take it before its owner is processed.
525
+ const curatedOwner = new Map<string, string>()
526
+ for (const category of categoryNames)
527
+ for (const productKey of curatedByCategory.get(category)!)
528
+ if (!curatedOwner.has(productKey)) curatedOwner.set(productKey, category)
529
+ const filled = new Set<string>()
530
+ const categories: ConsumerCategory[] = categoryNames.map((category) => {
531
+ const members = input.catalog.plugins.filter((plugin) => plugin.categories.includes(category))
532
+ const curated = curatedByCategory.get(category)!
533
+ const curatedRank = new Map(curated.map((key, index) => [key, index]))
534
+ const productKeys = [...new Set(members.map((plugin) => plugin.productGroupingKey))].toSorted(
535
+ (left, right) =>
536
+ (curatedRank.get(left) ?? Number.POSITIVE_INFINITY) -
537
+ (curatedRank.get(right) ?? Number.POSITIVE_INFINITY) || left.localeCompare(right)
538
+ )
539
+ const memberIds = new Set(members.map((plugin) => plugin.pluginId))
540
+ // Curated first in configured order, canonical variant ahead of its own
541
+ // variants, then alphabetical for a predictable browsing tail.
542
+ const orderedIds: string[] = []
543
+ for (const productKey of productKeys) {
544
+ const product = products[productKey]!
545
+ for (const pluginId of product.variantPluginIds)
546
+ if (memberIds.has(pluginId)) orderedIds.push(pluginId)
547
+ }
548
+ // Curated products this category owns, then deterministic fill so a shelf
549
+ // stays full without repeating a product another category already shows.
550
+ const topProductKeys: string[] = []
551
+ for (const productKey of productKeys) {
552
+ if (topProductKeys.length === topCount) break
553
+ if (curatedOwner.get(productKey) !== category) continue
554
+ topProductKeys.push(productKey)
555
+ }
556
+ for (const productKey of productKeys) {
557
+ if (topProductKeys.length === topCount) break
558
+ if (curatedOwner.has(productKey) || filled.has(productKey)) continue
559
+ topProductKeys.push(productKey)
560
+ filled.add(productKey)
561
+ }
562
+ return {
563
+ category,
564
+ productCount: productKeys.length,
565
+ pluginCount: members.length,
566
+ pluginIds: orderedIds,
567
+ productKeys,
568
+ topProductKeys,
569
+ shelfArtifact: shardArtifactNames(category).shelf,
570
+ listArtifact: shardArtifactNames(category).list,
571
+ }
572
+ })
573
+ for (const category of Object.keys(leading))
574
+ if (!categoryNames.includes(category))
575
+ for (const name of leading[category]!)
576
+ diagnostics.push({ category, name, reason: 'UNKNOWN_CATEGORY' })
577
+ return {
578
+ topCount,
579
+ sourcePreference: [...preference.sourcePreference],
580
+ counts: {
581
+ plugins: input.catalog.plugins.length,
582
+ products: Object.keys(products).length,
583
+ redundantPlugins: input.catalog.plugins.length - Object.keys(products).length,
584
+ },
585
+ products,
586
+ categories,
587
+ diagnostics: diagnostics.toSorted(
588
+ (left, right) =>
589
+ left.category.localeCompare(right.category) || left.name.localeCompare(right.name)
590
+ ),
591
+ }
592
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { normalizeDisplayName } from './display-name.js'
3
+
4
+ describe('plugin display names', () => {
5
+ test('turns a slug-shaped name into a written one', () => {
6
+ expect(normalizeDisplayName('adobe-for-creativity')).toBe('Adobe for Creativity')
7
+ expect(normalizeDisplayName('aws-agents-for-devsecops')).toBe('AWS Agents for DevSecOps')
8
+ expect(normalizeDisplayName('42crunch-api-security-testing')).toBe(
9
+ '42Crunch API Security Testing'
10
+ )
11
+ expect(normalizeDisplayName('build-ios-apps')).toBe('Build iOS Apps')
12
+ expect(normalizeDisplayName('carta-cap-table')).toBe('Carta Cap Table')
13
+ expect(normalizeDisplayName('huggingface-skills')).toBe('Hugging Face Skills')
14
+ expect(normalizeDisplayName('ui5-typescript-conversion')).toBe('UI5 TypeScript Conversion')
15
+ expect(normalizeDisplayName('mcp_server_dev')).toBe('MCP Server Dev')
16
+ expect(normalizeDisplayName('monday CRM')).toBe('Monday CRM')
17
+ expect(normalizeDisplayName('amd-skills')).toBe('AMD Skills')
18
+ })
19
+
20
+ test('keeps the vendor casing a name already carries', () => {
21
+ // Idempotence matters: the function runs on every build, over names a
22
+ // curator may have written by hand.
23
+ for (const name of [
24
+ 'Gmail',
25
+ 'iMessage',
26
+ 'eToro Trading',
27
+ 'PDF Viewer',
28
+ 'Setup MCP Agent Analytics',
29
+ 'GitHub',
30
+ 'NVIDIA',
31
+ 'Datadog',
32
+ ])
33
+ expect(normalizeDisplayName(name)).toBe(name)
34
+ })
35
+
36
+ test('keeps domain-style names and small words readable', () => {
37
+ expect(normalizeDisplayName('incident.io')).toBe('Incident.io')
38
+ expect(normalizeDisplayName('monday.com')).toBe('Monday.com')
39
+ // The slug form hides the domain, so the token rules alone cannot get there.
40
+ expect(normalizeDisplayName('monday-com')).toBe('Monday.com')
41
+ expect(normalizeDisplayName('bigdata-com')).toBe('BigData.com')
42
+ expect(normalizeDisplayName('incident-io')).toBe('Incident.io')
43
+ expect(normalizeDisplayName('vpai')).toBe('VPAI')
44
+ expect(normalizeDisplayName('Claude for Financial Advisors')).toBe(
45
+ 'Claude for Financial Advisors'
46
+ )
47
+ expect(normalizeDisplayName('claude-for-financial-advisors')).toBe(
48
+ 'Claude for Financial Advisors'
49
+ )
50
+ })
51
+
52
+ test('leaves a name it cannot improve alone', () => {
53
+ expect(normalizeDisplayName('')).toBe('')
54
+ expect(normalizeDisplayName(' ')).toBe(' ')
55
+ })
56
+ })