@adea-ai/plugins 4.0.0 → 4.0.6

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 (147) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +76 -0
  17. package/dist/catalog-store.js +463 -0
  18. package/dist/catalog-store.js.map +1 -0
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +7 -29
  23. package/dist/consumer-index.js +24 -15
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +5 -4
  34. package/dist/icon-mirror.js +73 -72
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -309
  40. package/dist/index.js +12 -1900
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +12 -12
  64. package/dist/publication.js +31 -21
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +4 -3
  70. package/dist/site-icons.js +38 -11
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +3 -3
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.test.ts +80 -0
  101. package/src/artifacts.ts +234 -0
  102. package/src/build.ts +131 -0
  103. package/src/capabilities.ts +153 -0
  104. package/src/catalog-store.ts +519 -0
  105. package/src/concurrency.ts +19 -0
  106. package/src/consumer-index.test.ts +147 -16
  107. package/src/consumer-index.ts +35 -43
  108. package/src/default-head.test.ts +143 -0
  109. package/src/digest.ts +55 -0
  110. package/src/fs-atomic.ts +32 -0
  111. package/src/harness.test.ts +4 -32
  112. package/src/harness.ts +2 -1
  113. package/src/icon-mirror.ts +78 -72
  114. package/src/icons.test.ts +0 -4
  115. package/src/icons.ts +4 -4
  116. package/src/index.test.ts +172 -46
  117. package/src/index.ts +58 -2562
  118. package/src/integrity-manifest.ts +41 -21
  119. package/src/json-values.ts +11 -0
  120. package/src/manifest-retry.test.ts +49 -0
  121. package/src/plugin-release.ts +457 -0
  122. package/src/plugin-skips.ts +110 -0
  123. package/src/policy.ts +39 -0
  124. package/src/portable-catalog.test.ts +0 -5
  125. package/src/portable-catalog.ts +16 -143
  126. package/src/portable-verify.ts +71 -0
  127. package/src/publication.test.ts +29 -1
  128. package/src/publication.ts +38 -22
  129. package/src/published-output.ts +60 -0
  130. package/src/site-icons.test.ts +73 -1
  131. package/src/site-icons.ts +31 -14
  132. package/src/snapshots.ts +287 -0
  133. package/src/source-config-sync.test.ts +84 -0
  134. package/src/source-digest.test.ts +61 -8
  135. package/src/source-resolution.ts +233 -0
  136. package/src/sources-json.test.ts +18 -2
  137. package/src/sources.ts +27 -33
  138. package/src/synchronize.ts +213 -0
  139. package/src/taxonomy.ts +87 -0
  140. package/src/upstream.ts +194 -0
  141. package/src/verify-artifacts.ts +365 -0
  142. package/src/write-artifacts.test.ts +374 -0
  143. package/dist/source-digest.d.ts +0 -2
  144. package/dist/source-digest.js +0 -12
  145. package/dist/source-digest.js.map +0 -1
  146. package/native/README.md +0 -24
  147. package/src/source-digest.ts +0 -14
@@ -186,6 +186,15 @@ describe('artifact shards', () => {
186
186
  plugin({ sourceId: 'claude-official', name: 'sales', categories: ['business-operations'] }),
187
187
  ]
188
188
 
189
+ test('index options can change companion artifacts without changing the catalog ID', () => {
190
+ const base = catalog(plugins)
191
+ const first = createArtifacts(base, lockFor(plugins) as never, { topCount: 1 })
192
+ const second = createArtifacts(base, lockFor(plugins) as never, { topCount: 2 })
193
+ expect(first['catalog.v1.json']).toBe(second['catalog.v1.json'])
194
+ expect(first['catalog-index.v1.json']).not.toBe(second['catalog-index.v1.json'])
195
+ expect(first['integrity.json']).not.toBe(second['integrity.json'])
196
+ })
197
+
189
198
  test('publishes navigation, one index, and a shelf and list per category', () => {
190
199
  const { artifacts } = artifactsOf(plugins)
191
200
  const names = Object.keys(artifacts).toSorted()
@@ -478,6 +487,77 @@ describe('artifact shards', () => {
478
487
  })
479
488
 
480
489
  describe('icon mirroring', () => {
490
+ test('tries another pinned source when a shared icon cannot be fetched', async () => {
491
+ const { base, index } = artifactsOf([
492
+ plugin({
493
+ sourceId: 'cursor-official',
494
+ name: 'first',
495
+ categories: ['productivity'],
496
+ icon: { path: 'first/logo.svg', bytes: CLEAN_SVG },
497
+ }),
498
+ plugin({
499
+ sourceId: 'claude-official',
500
+ name: 'second',
501
+ categories: ['productivity'],
502
+ icon: { path: 'second/logo.svg', bytes: CLEAN_SVG },
503
+ }),
504
+ ])
505
+ const sources = resolveIconSources(base, index)
506
+ const directory = await mkdtemp(join(tmpdir(), 'adea-icons-fallback-'))
507
+ try {
508
+ const calls: string[] = []
509
+ const result = await stageIconAssets({
510
+ sources,
511
+ assetsDirectory: directory,
512
+ readBytes: async (source) => {
513
+ if (source.kind !== 'content') throw new Error('unexpected favicon')
514
+ calls.push(source.path)
515
+ if (source.path === 'first/logo.svg') throw new Error('source unavailable')
516
+ return CLEAN_SVG
517
+ },
518
+ })
519
+ expect(calls).toEqual(['first/logo.svg', 'second/logo.svg'])
520
+ expect(result.staged.map((asset) => asset.name)).toEqual([svgAsset])
521
+ expect(result.skipped).toEqual([])
522
+ expect(await fs.readFile(join(directory, svgAsset))).toEqual(Buffer.from(CLEAN_SVG))
523
+ await fs.rm(join(directory, svgAsset))
524
+ const successfulCalls: string[] = []
525
+ const successful = await stageIconAssets({
526
+ sources,
527
+ assetsDirectory: directory,
528
+ readBytes: async (source) => {
529
+ if (source.kind !== 'content') throw new Error('unexpected favicon')
530
+ successfulCalls.push(source.path)
531
+ return CLEAN_SVG
532
+ },
533
+ })
534
+ expect(successfulCalls).toEqual(['first/logo.svg'])
535
+ expect(successful.staged).toHaveLength(1)
536
+ expect(successful.skipped).toEqual([])
537
+ await fs.rm(join(directory, svgAsset))
538
+ const mismatched = await stageIconAssets({
539
+ sources,
540
+ assetsDirectory: directory,
541
+ readBytes: async (source) =>
542
+ source.kind === 'content' && source.path === 'first/logo.svg' ? OTHER_SVG : CLEAN_SVG,
543
+ })
544
+ expect(mismatched.staged.map((asset) => asset.name)).toEqual([svgAsset])
545
+ expect(mismatched.skipped).toEqual([])
546
+ await fs.rm(join(directory, svgAsset))
547
+ const failed = await stageIconAssets({
548
+ sources,
549
+ assetsDirectory: directory,
550
+ readBytes: async () => {
551
+ throw new Error('unavailable')
552
+ },
553
+ })
554
+ expect(failed.staged).toEqual([])
555
+ expect(failed.skipped).toEqual([{ asset: svgAsset, reason: 'FETCH_FAILED: unavailable' }])
556
+ } finally {
557
+ await fs.rm(directory, { recursive: true, force: true })
558
+ }
559
+ })
560
+
481
561
  test('resolves each asset to the commit it was published from', () => {
482
562
  const plugins = [
483
563
  plugin({
@@ -0,0 +1,234 @@
1
+ import { publishCatalog, type PublishOptions } from './catalog-store.js'
2
+ import {
3
+ buildConsumerIndex,
4
+ CATALOG_INDEX_ARTIFACT,
5
+ iconCoverage,
6
+ type ConsumerIndex,
7
+ type ProductPreference,
8
+ } from './consumer-index.js'
9
+ import { digest } from './digest.js'
10
+ import { publishedAssetOf, type PublishedAsset } from './integrity-manifest.js'
11
+ import { catalogIdSuffix, immutableAssetUrl } from './publication.js'
12
+ import type { Catalog, SourcesLock } from './schema.js'
13
+
14
+ /**
15
+ * Published artifacts by file name. The core six are always present; the
16
+ * consumer shards (`catalog-index.v1.json`, `shelf-<category>.v1.json` and
17
+ * `category-<category>.v1.json`) follow the configured categories.
18
+ */
19
+ export interface GeneratedArtifacts {
20
+ readonly [name: string]: string
21
+ readonly 'catalog.v1.json': string
22
+ readonly 'catalog-summary.v1.json': string
23
+ readonly 'sources.lock.json': string
24
+ readonly 'compatibility.v1.json': string
25
+ readonly 'categories.v1.json': string
26
+ readonly 'integrity.json': string
27
+ }
28
+
29
+ export interface ConsumerIndexOptions {
30
+ readonly leading?: Readonly<Record<string, readonly string[]>>
31
+ readonly topCount?: number
32
+ readonly productPreference?: ProductPreference
33
+ /**
34
+ * Where this catalog is published. When known, product records carry an
35
+ * absolute, immutable URL for their brand mark so no client composes one.
36
+ */
37
+ readonly publicationRepositoryUrl?: string
38
+ /**
39
+ * Base URL of the committed asset directory that browsers fetch directly.
40
+ *
41
+ * GitHub release assets answer a cross-origin `fetch` with no CORS header and
42
+ * redirect through a URL that cannot be cached, so a browser neither reads the
43
+ * index nor reuses an icon. The same files served from the repository are
44
+ * CORS-enabled, direct and cacheable.
45
+ */
46
+ readonly publicationAssetsBaseUrl?: string
47
+ /**
48
+ * Base URL of the content-addressed snapshot directory, one level below the
49
+ * asset branch. Catalog artifacts are addressed by catalog identity, so they
50
+ * live in `catalogs/<catalogId>/` while digest-addressed brand marks sit at
51
+ * the branch root.
52
+ */
53
+ readonly publicationCatalogsBaseUrl?: string
54
+ /**
55
+ * Whether curated-name drift is published in the consumer index. An offline
56
+ * build replays fixtures rather than the upstream catalogs, so its curation
57
+ * findings are noise and are omitted by default.
58
+ */
59
+ readonly curationDiagnostics?: boolean
60
+ }
61
+
62
+ export function createArtifacts(
63
+ catalog: Catalog,
64
+ lock: SourcesLock,
65
+ options: ConsumerIndexOptions = {}
66
+ ): GeneratedArtifacts {
67
+ const index = buildConsumerIndex({ ...options, catalog })
68
+ const summary = {
69
+ schemaVersion: 1,
70
+ catalogId: catalog.catalogId,
71
+ generatedAt: catalog.generatedAt,
72
+ sourceCount: catalog.sources.length,
73
+ pluginCount: catalog.plugins.length,
74
+ categories: [...new Set(catalog.plugins.flatMap((plugin) => plugin.categories))].toSorted(),
75
+ productGroups: [
76
+ ...new Set(catalog.plugins.map((plugin) => plugin.productGroupingKey)),
77
+ ].toSorted(),
78
+ search: catalog.plugins
79
+ .map((plugin) => ({
80
+ pluginId: plugin.pluginId,
81
+ text: `${plugin.displayName} ${plugin.description} ${plugin.keywords.join(' ')}`.trim(),
82
+ }))
83
+ .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)),
84
+ }
85
+ const compatibility = {
86
+ schemaVersion: 1,
87
+ catalogId: catalog.catalogId,
88
+ plugins: catalog.plugins.map((plugin) => ({
89
+ pluginId: plugin.pluginId,
90
+ harnessCompatibility: plugin.harnessCompatibility,
91
+ })),
92
+ }
93
+ // Navigation stays small and points at the shards a client then fetches:
94
+ // `shelf-*` for a default view, `category-*` for "view more", and
95
+ // `catalog-index.v1.json` for the complete product list.
96
+ const navigation = {
97
+ schemaVersion: 1,
98
+ catalogId: catalog.catalogId,
99
+ generatedAt: catalog.generatedAt,
100
+ topCount: index.topCount,
101
+ sourcePreference: index.sourcePreference,
102
+ counts: index.counts,
103
+ catalogIndexArtifact: CATALOG_INDEX_ARTIFACT,
104
+ iconCoverage: iconCoverage(index),
105
+ // The index URL rides in the navigation artifact so a client can fetch the
106
+ // browsing index from the publication it was told about, verify it against
107
+ // the digest in integrity.json, and never compose a URL or need this
108
+ // service to proxy it. Absent for an unpublished build.
109
+ ...(options.publicationCatalogsBaseUrl
110
+ ? {
111
+ catalogIndexUrl: `${options.publicationCatalogsBaseUrl}/${catalogIdSuffix(catalog.catalogId)}/${CATALOG_INDEX_ARTIFACT}`,
112
+ }
113
+ : options.publicationAssetsBaseUrl
114
+ ? { catalogIndexUrl: `${options.publicationAssetsBaseUrl}/${CATALOG_INDEX_ARTIFACT}` }
115
+ : options.publicationRepositoryUrl
116
+ ? {
117
+ catalogIndexUrl: immutableAssetUrl(
118
+ options.publicationRepositoryUrl,
119
+ catalog.catalogId,
120
+ CATALOG_INDEX_ARTIFACT
121
+ ),
122
+ }
123
+ : {}),
124
+ // Marks ride in the navigation artifact so a client that already receives it
125
+ // renders compiled brand marks without fetching the browsing index. Keys are
126
+ // sorted for a stable artifact.
127
+ brandMarks: Object.fromEntries(
128
+ Object.keys(index.products)
129
+ .toSorted()
130
+ .flatMap((productKey) => {
131
+ const assetUrl = index.products[productKey]?.icon?.assetUrl
132
+ return assetUrl ? [[productKey, assetUrl] as const] : []
133
+ })
134
+ ),
135
+ categories: index.categories,
136
+ diagnostics: options.curationDiagnostics === false ? [] : index.diagnostics,
137
+ }
138
+ const shards: Record<string, string> = {
139
+ [CATALOG_INDEX_ARTIFACT]: json({
140
+ schemaVersion: 1,
141
+ catalogId: catalog.catalogId,
142
+ generatedAt: catalog.generatedAt,
143
+ topCount: index.topCount,
144
+ sourcePreference: index.sourcePreference,
145
+ counts: index.counts,
146
+ products: index.products,
147
+ categories: index.categories.map((category) => ({
148
+ category: category.category,
149
+ productCount: category.productCount,
150
+ productKeys: category.productKeys,
151
+ })),
152
+ }),
153
+ }
154
+ for (const category of index.categories) {
155
+ shards[category.shelfArtifact] = json({
156
+ schemaVersion: 1,
157
+ catalogId: catalog.catalogId,
158
+ category: category.category,
159
+ topCount: index.topCount,
160
+ products: category.topProductKeys.map((productKey) => index.products[productKey]),
161
+ })
162
+ shards[category.listArtifact] = json({
163
+ schemaVersion: 1,
164
+ catalogId: catalog.catalogId,
165
+ category: category.category,
166
+ productCount: category.productCount,
167
+ pluginCount: category.pluginCount,
168
+ products: category.productKeys.map((productKey) => index.products[productKey]),
169
+ })
170
+ }
171
+ const files: Record<string, string> = {
172
+ 'catalog.v1.json': json(catalog),
173
+ 'catalog-summary.v1.json': json(summary),
174
+ 'sources.lock.json': json(lock),
175
+ 'compatibility.v1.json': json(compatibility),
176
+ 'categories.v1.json': json(navigation),
177
+ ...shards,
178
+ }
179
+ const integrityBody = {
180
+ schemaVersion: 1,
181
+ catalogId: catalog.catalogId,
182
+ // An artifact's digest is the canonical digest of its text, not a hash of
183
+ // the file bytes: consumers read the artifact as a string and compare it
184
+ // with `canonicalDigest`, so a checker hashing the raw bytes will disagree
185
+ // with every published release. Mirrored marks below are the byte case,
186
+ // because a client stores those bytes.
187
+ files: Object.fromEntries(
188
+ Object.entries(files)
189
+ .toSorted()
190
+ .map(([name, content]) => [name, digest(content)])
191
+ ),
192
+ // Mirrored brand marks are published next to the artifacts, never inside
193
+ // them: the digest and size let a consumer or the release check verify the
194
+ // bytes it fetched without trusting the release listing.
195
+ assets: iconAssets(index),
196
+ }
197
+ return { ...files, 'integrity.json': json(integrityBody) } as GeneratedArtifacts
198
+ }
199
+
200
+ export function iconAssets(index: ConsumerIndex): readonly PublishedAsset[] {
201
+ const assets = new Map<string, PublishedAsset>()
202
+ for (const product of Object.values(index.products)) {
203
+ if (!product.icon) continue
204
+ assets.set(product.icon.asset, publishedAssetOf(product.icon))
205
+ }
206
+ return [...assets.values()].toSorted((left, right) => left.name.localeCompare(right.name))
207
+ }
208
+
209
+ /**
210
+ * Publishes a verified artifact set to `generatedDirectory` under the output's
211
+ * coordination lock. See `catalog-store.ts` for the publication contract.
212
+ */
213
+ export async function writeArtifacts(
214
+ generatedDirectory: string,
215
+ artifacts: GeneratedArtifacts,
216
+ options: PublishOptions = {}
217
+ ): Promise<void> {
218
+ await publishCatalog(generatedDirectory, artifacts as Record<string, string>, options)
219
+ }
220
+
221
+ function sortArtifactKeys(value: unknown): unknown {
222
+ if (Array.isArray(value)) return value.map(sortArtifactKeys)
223
+ if (value !== null && typeof value === 'object')
224
+ return Object.fromEntries(
225
+ Object.keys(value as Record<string, unknown>)
226
+ .toSorted()
227
+ .map((key) => [key, sortArtifactKeys((value as Record<string, unknown>)[key])])
228
+ )
229
+ return value
230
+ }
231
+
232
+ function json(value: unknown): string {
233
+ return `${JSON.stringify(sortArtifactKeys(value), null, 2)}\n`
234
+ }
package/src/build.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { mapWithConcurrency } from './concurrency.js'
2
+ import { contentId } from './digest.js'
3
+ import { normalizePlugin, type ReleaseOptions } from './plugin-release.js'
4
+ import {
5
+ createSkippedPlugin,
6
+ PluginSafetyError,
7
+ UPSTREAM_WEATHER_SKIP_CODES,
8
+ type SkippedPlugin,
9
+ } from './plugin-skips.js'
10
+ import { assertRepositoryAllowed, DEFAULT_POLICY, type CatalogPolicy } from './policy.js'
11
+ import {
12
+ CatalogSchema,
13
+ PluginSchema,
14
+ type Catalog,
15
+ type CatalogSource,
16
+ type Plugin,
17
+ } from './schema.js'
18
+ import type { ResolvedSource } from './source-resolution.js'
19
+ import { canonicalRepositoryUrl } from './sources.js'
20
+ import { orderLeadingPlugins } from './taxonomy.js'
21
+
22
+ export interface BuildCatalogInput extends Omit<
23
+ ReleaseOptions,
24
+ 'policy' | 'metadataOnly' | 'resolveExternalRefs'
25
+ > {
26
+ readonly resolvedSources: readonly ResolvedSource[]
27
+ /** Each category's curated products, which head the catalog in this order. */
28
+ readonly leading?: Readonly<Record<string, readonly string[]>>
29
+ readonly policy?: CatalogPolicy
30
+ readonly metadataOnly?: boolean
31
+ readonly resolveExternalRefs?: boolean
32
+ }
33
+
34
+ interface BuildCatalogResult {
35
+ readonly catalog: Catalog
36
+ readonly skippedPlugins: readonly SkippedPlugin[]
37
+ }
38
+
39
+ export async function buildCatalog(input: BuildCatalogInput): Promise<Catalog> {
40
+ return (await buildCatalogInternal(input)).catalog
41
+ }
42
+
43
+ export async function buildCatalogInternal(input: BuildCatalogInput): Promise<BuildCatalogResult> {
44
+ const policy = input.policy ?? DEFAULT_POLICY
45
+ const options: ReleaseOptions = {
46
+ ...input,
47
+ policy,
48
+ metadataOnly: input.metadataOnly ?? false,
49
+ resolveExternalRefs: input.resolveExternalRefs ?? false,
50
+ }
51
+ for (const source of input.resolvedSources) {
52
+ assertRepositoryAllowed(source.config.repositoryUrl, policy)
53
+ for (const entry of source.parsed.entries) {
54
+ if (entry.source.kind === 'git') assertRepositoryAllowed(entry.source.repositoryUrl, policy)
55
+ }
56
+ }
57
+ const sourceRecords: CatalogSource[] = input.resolvedSources
58
+ .map(({ config, commitSha, retrievedAt, manifestDigest }) => ({
59
+ sourceId: config.sourceId,
60
+ displayName: config.displayName,
61
+ repositoryUrl: canonicalRepositoryUrl(config.repositoryUrl),
62
+ marketplaceDialect: config.marketplaceDialect,
63
+ manifestPath: config.manifestPath,
64
+ defaultBranch: config.defaultBranch,
65
+ resolvedCommitSha: commitSha,
66
+ retrievalTimestamp: retrievedAt,
67
+ trustClassification: config.trustClassification,
68
+ sourceManifestDigest: manifestDigest,
69
+ synchronizationStatus: 'synchronized' as const,
70
+ }))
71
+ .toSorted((left, right) => left.sourceId.localeCompare(right.sourceId))
72
+ let plugins: Plugin[] = []
73
+ let skippedPlugins: SkippedPlugin[] = []
74
+ for (const source of input.resolvedSources) {
75
+ if (source.parsed.entries.length > policy.maxMarketplacePlugins)
76
+ throw new Error('MARKETPLACE_TOO_MANY_PLUGINS')
77
+ const normalized = await mapWithConcurrency(source.parsed.entries, 8, async (entry) => {
78
+ try {
79
+ const plugin = await normalizePlugin(source, entry, options)
80
+ try {
81
+ return { plugin: PluginSchema.parse(plugin) }
82
+ } catch {
83
+ throw new PluginSafetyError('PLUGIN_SCHEMA_INVALID', [])
84
+ }
85
+ } catch (error) {
86
+ const skipped = createSkippedPlugin(source, entry, error)
87
+ if (!skipped) throw error
88
+ return { skipped }
89
+ }
90
+ })
91
+ const sourcePlugins: Plugin[] = []
92
+ const sourceSkipped: SkippedPlugin[] = []
93
+ for (const result of normalized) {
94
+ if (result.plugin) sourcePlugins.push(result.plugin)
95
+ if (result.skipped) sourceSkipped.push(result.skipped)
96
+ }
97
+ const weatherSkips = sourceSkipped.filter((skipped) =>
98
+ UPSTREAM_WEATHER_SKIP_CODES.has(skipped.reasonCode)
99
+ )
100
+ if (source.parsed.entries.length > 0 && weatherSkips.length === source.parsed.entries.length)
101
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [
102
+ canonicalRepositoryUrl(source.config.repositoryUrl),
103
+ ])
104
+ for (const plugin of sourcePlugins) plugins.push(plugin)
105
+ for (const skipped of sourceSkipped) skippedPlugins.push(skipped)
106
+ }
107
+ plugins = plugins.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
108
+ skippedPlugins = skippedPlugins.toSorted(
109
+ (left, right) =>
110
+ left.pluginId.localeCompare(right.pluginId) ||
111
+ left.reasonCode.localeCompare(right.reasonCode) ||
112
+ left.paths.join('\0').localeCompare(right.paths.join('\0'))
113
+ )
114
+ const body = {
115
+ schemaVersion: 1 as const,
116
+ generatedAt:
117
+ sourceRecords
118
+ .map((s) => s.retrievalTimestamp)
119
+ .toSorted()
120
+ .at(-1) ?? new Date(0).toISOString(),
121
+ sources: sourceRecords,
122
+ plugins: orderLeadingPlugins({ plugins }, input.leading).plugins,
123
+ }
124
+ return {
125
+ catalog: CatalogSchema.parse({
126
+ ...body,
127
+ catalogId: contentId('catalog', body),
128
+ }),
129
+ skippedPlugins,
130
+ }
131
+ }
@@ -0,0 +1,153 @@
1
+ import { basename } from 'node:path'
2
+ import { DEFAULT_POLICY, type CatalogPolicy } from './policy.js'
3
+ import type { MarketplaceDialect } from './sources.js'
4
+ import {
5
+ HarnessSchema,
6
+ type Capability,
7
+ type Harness,
8
+ type HarnessCompatibility,
9
+ type SecurityClassification,
10
+ } from './schema.js'
11
+
12
+ export function classifyCapabilities(
13
+ files: ReadonlyMap<string, Uint8Array>,
14
+ metadata?: Record<string, unknown>,
15
+ policy: CatalogPolicy = DEFAULT_POLICY
16
+ ): Capability[] {
17
+ const groups = new Map<string, string[]>()
18
+ const add = (type: Capability['type'], path: string) =>
19
+ groups.set(type, [...(groups.get(type) ?? []), path])
20
+ for (const path of files.keys()) {
21
+ const lower = path.toLocaleLowerCase()
22
+ const file = basename(lower)
23
+ if (file === 'skill.md' || lower.includes('/skills/')) add('skill', path)
24
+ if (file === '.mcp.json' || file === 'mcp.json' || lower.includes('/mcp-servers/'))
25
+ add('mcp-server', path)
26
+ if (lower.includes('/commands/') || lower.startsWith('commands/')) add('command', path)
27
+ if (lower.includes('/agents/') || lower.startsWith('agents/') || lower.includes('/subagents/'))
28
+ add('agent', path)
29
+ if (lower.includes('/hooks/') || lower.startsWith('hooks/') || file === 'hooks.json')
30
+ add('hook', path)
31
+ if (
32
+ lower.includes('/rules/') ||
33
+ lower.startsWith('rules/') ||
34
+ file === 'agents.md' ||
35
+ file === 'claude.md' ||
36
+ lower.includes('.cursor/rules/')
37
+ )
38
+ add('rule', path)
39
+ if (lower.includes('browser') || lower.includes('playwright') || lower.includes('chrome'))
40
+ add('browser', path)
41
+ if (lower.includes('/schedule') || lower.includes('/cron') || file.includes('schedule'))
42
+ add('scheduled-task', path)
43
+ if (lower.includes('/ui/') || lower.startsWith('ui/') || lower.includes('/components/'))
44
+ add('ui-component', path)
45
+ if (
46
+ lower.startsWith('bin/') ||
47
+ lower.startsWith('scripts/') ||
48
+ /\.(sh|bash|py|exe|bin)$/.test(file)
49
+ )
50
+ add('executable', path)
51
+ }
52
+ if (metadata && (metadata.mcpServers !== undefined || metadata.mcp !== undefined))
53
+ add('mcp-server', 'plugin-manifest')
54
+ if (metadata && (metadata.hooks !== undefined || metadata.hook !== undefined))
55
+ add('hook', 'plugin-manifest')
56
+ if (groups.size === 0) groups.set('unknown', ['plugin-root'])
57
+ return [...groups.entries()].map(([type, paths]) => ({
58
+ type: type as Capability['type'],
59
+ name: type,
60
+ paths: [...new Set(paths)].toSorted(),
61
+ metadata: {},
62
+ securityImpact: policy.sensitiveCapabilityTypes.includes(type) ? 'sensitive' : 'none',
63
+ }))
64
+ }
65
+
66
+ export function capabilitySummary(
67
+ capabilities: readonly Capability[]
68
+ ): Record<Capability['type'], number> {
69
+ const summary = {} as Record<Capability['type'], number>
70
+ for (const capability of capabilities) summary[capability.type] = capability.paths.length
71
+ return summary
72
+ }
73
+
74
+ export function createCompatibility(
75
+ dialect: MarketplaceDialect,
76
+ capabilities: readonly Capability[],
77
+ policy: CatalogPolicy = DEFAULT_POLICY
78
+ ): Record<Harness, HarnessCompatibility> {
79
+ const types = [...new Set(capabilities.map((capability) => capability.type))].toSorted()
80
+ const sensitive = types.filter((type) => policy.sensitiveCapabilityTypes.includes(type))
81
+ const make = (
82
+ status: HarnessCompatibility['status'],
83
+ reasons: string[]
84
+ ): HarnessCompatibility => ({
85
+ status,
86
+ reasons: [...new Set(reasons)].toSorted(),
87
+ responsibleCapabilities: types,
88
+ })
89
+ const result = {} as Record<Harness, HarnessCompatibility>
90
+ for (const harness of HarnessSchema.options) {
91
+ const native =
92
+ (harness === 'claude-code' && dialect === 'claude') ||
93
+ (harness === 'cursor' && dialect === 'cursor') ||
94
+ (harness === 'codex' && dialect === 'openai')
95
+ if (types.includes('executable'))
96
+ result[harness] = make('unsupported', [
97
+ 'Executable components require harness-specific execution authority.',
98
+ ])
99
+ else if (types.includes('unknown'))
100
+ result[harness] = make('unknown', [
101
+ 'The plugin content was not classified into a supported component type.',
102
+ ])
103
+ else if (native && sensitive.length === 0)
104
+ result[harness] = make('native', [
105
+ `The ${dialect} marketplace dialect matches the ${harness} harness.`,
106
+ ])
107
+ else if (sensitive.length > 0 && ['pi', 'hermes', 'opencode'].includes(harness))
108
+ result[harness] = make('partially-supported', [
109
+ `Sensitive capabilities require ${harness} policy and runtime review.`,
110
+ ...sensitive.map((type) => `Capability ${type} is not granted by this catalog.`),
111
+ ])
112
+ else if (
113
+ types.every((type) => ['skill', 'rule', 'mcp-server', 'command', 'agent'].includes(type))
114
+ )
115
+ result[harness] = make(
116
+ native ? 'native' : 'portable',
117
+ native
118
+ ? [`The ${dialect} marketplace dialect matches the ${harness} harness.`]
119
+ : ['Files can be copied or translated into the harness layout.']
120
+ )
121
+ else
122
+ result[harness] = make('requires-translation', [
123
+ 'One or more capabilities need a harness-specific projection.',
124
+ ])
125
+ }
126
+ return result
127
+ }
128
+
129
+ export function createSecurity(
130
+ capabilities: readonly Capability[],
131
+ policy: CatalogPolicy,
132
+ metadataOnly: boolean
133
+ ): SecurityClassification {
134
+ const sensitive = [
135
+ ...new Set(
136
+ capabilities
137
+ .filter((capability) => policy.sensitiveCapabilityTypes.includes(capability.type))
138
+ .map((capability) => capability.type)
139
+ ),
140
+ ].toSorted()
141
+ const reasons =
142
+ sensitive.length > 0
143
+ ? sensitive.map((type) => `Contains ${type} capability metadata.`)
144
+ : metadataOnly
145
+ ? ['Content was not fetched in metadata-only mode.']
146
+ : ['No permission-sensitive executable capability was detected.']
147
+ return {
148
+ level: sensitive.length > 0 ? 'sensitive' : metadataOnly ? 'review' : 'low',
149
+ reasons,
150
+ permissionSensitiveChanges: sensitive,
151
+ contentResolution: metadataOnly ? 'metadata-only' : 'complete',
152
+ }
153
+ }