@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,800 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { isIP } from 'node:net'
3
+ import { posix } from 'node:path'
4
+ import { validateHeaderName, validateHeaderValue } from 'node:http'
5
+ import { parseDocument } from 'yaml'
6
+ import {
7
+ AGENT_PLUGINS_VERSION,
8
+ PLUGIN_SCHEMA_ID,
9
+ MCP_SCHEMA_ID,
10
+ NORMALIZER_VERSION,
11
+ AgentManifestSchema,
12
+ McpServerSchema,
13
+ SkillFrontmatterSchema,
14
+ AgentPackageSchema,
15
+ type AgentManifest,
16
+ type McpServer,
17
+ type AgentPackage,
18
+ type PackageFile,
19
+ type Diagnostic,
20
+ } from './agent-plugins-schema.js'
21
+
22
+ export { NORMALIZER_VERSION }
23
+ const decoder = new TextDecoder('utf-8', { fatal: true })
24
+ const encoder = new TextEncoder()
25
+ const manifestKeys = new Set([
26
+ '$schema',
27
+ 'name',
28
+ 'version',
29
+ 'description',
30
+ 'author',
31
+ 'homepage',
32
+ 'repository',
33
+ 'license',
34
+ 'keywords',
35
+ 'extensions',
36
+ ])
37
+ const extensionName = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/
38
+ const remoteTransportHeaders = new Set([
39
+ 'authorization',
40
+ 'connection',
41
+ 'content-length',
42
+ 'host',
43
+ 'keep-alive',
44
+ 'proxy-authorization',
45
+ 'proxy-connection',
46
+ 'te',
47
+ 'trailer',
48
+ 'transfer-encoding',
49
+ 'upgrade',
50
+ ])
51
+ const own = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key)
52
+ export const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
53
+ export function stableJson(value: unknown): string {
54
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
55
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
56
+ const record = value as Record<string, unknown>
57
+ return `{${Object.keys(record)
58
+ .toSorted(compare)
59
+ .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
60
+ .join(',')}}`
61
+ }
62
+ export function sha256(value: string | Uint8Array): string {
63
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`
64
+ }
65
+ /** Existing v1 source-byte identity, deliberately distinct from the derived package identity. */
66
+ export function sourceDigest(files: ReadonlyMap<string, Uint8Array>): string {
67
+ const hash = createHash('sha256')
68
+ for (const [path, bytes] of [...files].toSorted(([a], [b]) => a.localeCompare(b))) {
69
+ hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
70
+ hash.update(bytes)
71
+ }
72
+ return `sha256:${hash.digest('hex')}`
73
+ }
74
+ export function packageDigest(files: readonly PackageFile[]): string {
75
+ return sha256(
76
+ stableJson({
77
+ algorithm: 'adea-package-files/1',
78
+ files: [...files]
79
+ .toSorted((a, b) => compare(a.targetPath, b.targetPath))
80
+ .map((file) => ({
81
+ path: file.targetPath,
82
+ digest: file.digest,
83
+ mode: file.action === 'copy' ? 'preserve-source' : file.mode,
84
+ })),
85
+ })
86
+ )
87
+ }
88
+ export function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
90
+ }
91
+ /** JSON grammar plus duplicate-key detection, including escaped/nested keys. No reviver or code evaluation. */
92
+ export function parsePluginJson(text: string): unknown {
93
+ const value: unknown = JSON.parse(text)
94
+ const document = parseDocument(text, { schema: 'json', uniqueKeys: true, strict: true })
95
+ if (document.errors.length > 0) throw new Error('Invalid or duplicate JSON object key.')
96
+ return value
97
+ }
98
+ function hasControlCharacters(value: string, includeSpace = false): boolean {
99
+ for (const character of value) {
100
+ const code = character.codePointAt(0)!
101
+ if (code < (includeSpace ? 33 : 32) || code === 127) return true
102
+ }
103
+ return false
104
+ }
105
+
106
+ export function assertPackagePath(path: string): void {
107
+ if (
108
+ !path ||
109
+ path.length > 1024 ||
110
+ /[\\:]/.test(path) ||
111
+ hasControlCharacters(path) ||
112
+ path.startsWith('/') ||
113
+ path
114
+ .split('/')
115
+ .some(
116
+ (part) =>
117
+ !part ||
118
+ part === '.' ||
119
+ part === '..' ||
120
+ /[. ]$/.test(part) ||
121
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
122
+ )
123
+ ) {
124
+ throw new Error('Unsafe package-relative path.')
125
+ }
126
+ }
127
+ /** Lexical checks do not replace realpath containment checks at activation time. */
128
+ export function containedRelative(path: string): string {
129
+ if (path.includes('\\') || hasControlCharacters(path)) throw new Error('Invalid path characters.')
130
+ const normalized = posix.normalize(path)
131
+ if (
132
+ normalized.startsWith('/') ||
133
+ normalized === '..' ||
134
+ normalized.startsWith('../') ||
135
+ /^[A-Za-z]:/.test(normalized)
136
+ )
137
+ throw new Error('Path escapes its root.')
138
+ return normalized
139
+ }
140
+ function checkCwd(cwd: string): void {
141
+ const prefix = ['./', '${PLUGIN_ROOT}', '${PLUGIN_DATA}'].find((candidate) =>
142
+ candidate === './'
143
+ ? cwd.startsWith(candidate)
144
+ : cwd === candidate || cwd.startsWith(`${candidate}/`)
145
+ )
146
+ if (!prefix) throw new Error('cwd must be rooted in the plugin or plugin data directory.')
147
+ const suffix = prefix === './' ? cwd.slice(2) : cwd.slice(prefix.length).replace(/^\//, '')
148
+ if (/\$\{(?:PLUGIN_ROOT|PLUGIN_DATA)\}/.test(suffix))
149
+ throw new Error('cwd cannot combine filesystem roots.')
150
+ containedRelative(suffix || '.')
151
+ }
152
+ function isLoopback(hostname: string): boolean {
153
+ const host = hostname.replace(/^\[|\]$/g, '')
154
+ return host === 'localhost' || (isIP(host) === 4 && host.startsWith('127.')) || host === '::1'
155
+ }
156
+ export function validateServer(input: unknown): McpServer {
157
+ const server = McpServerSchema.parse(input)
158
+ if (server.type === 'stdio') {
159
+ if (server.command.startsWith('./')) {
160
+ const path = containedRelative(server.command.slice(2))
161
+ if (path === '.' || path.includes('${')) throw new Error('Invalid package executable.')
162
+ } else if (
163
+ !/^[^\s/\\:$]+$/.test(server.command) ||
164
+ hasControlCharacters(server.command) ||
165
+ server.command === '.' ||
166
+ server.command === '..'
167
+ ) {
168
+ throw new Error(
169
+ 'command must be one bare executable or ./package-relative path; no shell strings or expansion.'
170
+ )
171
+ }
172
+ for (const [key, value] of Object.entries(server.env ?? {})) {
173
+ if (/^(PLUGIN_ROOT|PLUGIN_DATA)$/i.test(key))
174
+ throw new Error('Reserved plugin environment variable.')
175
+ if (!key || key.includes('=') || key.includes('\0') || value.includes('\0'))
176
+ throw new Error('Invalid environment entry.')
177
+ }
178
+ if (server.args?.some((arg) => arg.includes('\0'))) throw new Error('Invalid argument.')
179
+ if (server.cwd !== undefined) checkCwd(server.cwd)
180
+ } else {
181
+ const url = new URL(server.url)
182
+ if (
183
+ !['http:', 'https:'].includes(url.protocol) ||
184
+ url.username ||
185
+ url.password ||
186
+ server.url.includes('#') ||
187
+ /^https?:\/\/[^/]*@/.test(server.url) ||
188
+ server.url.includes('\\') ||
189
+ hasControlCharacters(server.url, true) ||
190
+ (url.protocol !== 'https:' && !isLoopback(url.hostname))
191
+ )
192
+ throw new Error(
193
+ 'Remote MCP requires HTTPS, except HTTP loopback; credentials and fragments are forbidden.'
194
+ )
195
+ const keys = new Set<string>()
196
+ for (const [key, value] of Object.entries(server.headers ?? {})) {
197
+ validateHeaderName(key)
198
+ validateHeaderValue(key, value)
199
+ const folded = key.toLowerCase()
200
+ if (remoteTransportHeaders.has(folded))
201
+ throw new Error('Protocol-owned or authorization HTTP header.')
202
+ if (keys.has(folded)) throw new Error('Duplicate case-insensitive HTTP header.')
203
+ keys.add(folded)
204
+ }
205
+ }
206
+ return server
207
+ }
208
+
209
+ function trimPortableEdges(value: string): string {
210
+ let start = 0
211
+ let end = value.length
212
+ while (start < end && (value[start] === '.' || value[start] === '-')) start += 1
213
+ while (end > start && (value[end - 1] === '.' || value[end - 1] === '-')) end -= 1
214
+ return value.slice(start, end)
215
+ }
216
+ function portableName(name: string): string {
217
+ const cleaned =
218
+ trimPortableEdges(
219
+ name
220
+ .toLowerCase()
221
+ .replace(/[^a-z0-9.-]+/g, '-')
222
+ .replace(/-+/g, '-')
223
+ .replace(/\.+/g, '.')
224
+ ) || 'plugin'
225
+ return cleaned.length <= 64
226
+ ? cleaned
227
+ : `${trimPortableEdges(cleaned.slice(0, 54))}-${sha256(name).slice(-8)}`
228
+ }
229
+ function diagnostic(
230
+ code: string,
231
+ scope: Diagnostic['scope'],
232
+ path: string,
233
+ message: string,
234
+ severity: Diagnostic['severity'] = 'error'
235
+ ): Diagnostic {
236
+ return { code, scope, path, severity, message }
237
+ }
238
+ function nativeManifest(raw: unknown, diagnostics: Diagnostic[]): AgentManifest {
239
+ if (!isRecord(raw)) throw new Error('The root manifest must be an object.')
240
+ const filtered = Object.fromEntries(Object.entries(raw).filter(([key]) => manifestKeys.has(key)))
241
+ for (const key of Object.keys(raw)
242
+ .filter((field) => !manifestKeys.has(field))
243
+ .toSorted(compare))
244
+ diagnostics.push(
245
+ diagnostic(
246
+ 'UNKNOWN_MANIFEST_FIELD',
247
+ 'plugin',
248
+ `plugin.json/${key}`,
249
+ 'Unknown core field ignored; it does not override fixed component locations.',
250
+ 'warning'
251
+ )
252
+ )
253
+ if (own(filtered, 'extensions')) {
254
+ if (!isRecord(filtered.extensions)) {
255
+ delete filtered.extensions
256
+ diagnostics.push(
257
+ diagnostic(
258
+ 'INVALID_EXTENSIONS',
259
+ 'extension',
260
+ 'plugin.json/extensions',
261
+ 'Non-object extensions field ignored.',
262
+ 'warning'
263
+ )
264
+ )
265
+ } else {
266
+ // No extension namespace is implemented by this portable compiler.
267
+ // Never recursively validate an unknown namespace's client-owned data.
268
+ const extensions = Object.fromEntries(
269
+ Object.entries(filtered.extensions).filter(([key, value]) => {
270
+ if (extensionName.test(key) && isRecord(value)) return true
271
+ diagnostics.push(
272
+ diagnostic(
273
+ 'IGNORED_EXTENSION',
274
+ 'extension',
275
+ `plugin.json/extensions/${key}`,
276
+ 'Unimplemented extension data is opaque and was not projected into the portable manifest.',
277
+ 'warning'
278
+ )
279
+ )
280
+ return false
281
+ })
282
+ )
283
+ filtered.extensions = extensions
284
+ }
285
+ }
286
+ return AgentManifestSchema.parse(filtered)
287
+ }
288
+ function legacyManifest(raw: Record<string, unknown>, name: string): AgentManifest {
289
+ const output: Record<string, unknown> = { $schema: PLUGIN_SCHEMA_ID, name: portableName(name) }
290
+ for (const key of ['version', 'description', 'homepage', 'repository', 'license'])
291
+ if (typeof raw[key] === 'string') output[key] = raw[key]
292
+ if (typeof raw.author === 'string') output.author = { name: raw.author }
293
+ else if (isRecord(raw.author))
294
+ output.author = Object.fromEntries(
295
+ Object.entries(raw.author).filter(
296
+ ([key, value]) => ['name', 'email', 'url'].includes(key) && typeof value === 'string'
297
+ )
298
+ )
299
+ if (Array.isArray(raw.keywords))
300
+ output.keywords = raw.keywords.filter((item) => typeof item === 'string')
301
+ return AgentManifestSchema.parse(output)
302
+ }
303
+ function replaceLegacyRoot(value: string): string {
304
+ return value
305
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', '${PLUGIN_ROOT}')
306
+ .replaceAll('${CURSOR_PLUGIN_ROOT}', '${PLUGIN_ROOT}')
307
+ }
308
+ function translateLegacyServer(input: unknown): McpServer {
309
+ if (!isRecord(input)) throw new Error('Server must be an object.')
310
+ const out = { ...input }
311
+ if (out.type === 'http') out.type = 'streamable-http'
312
+ if (out.type === undefined && typeof out.command === 'string') out.type = 'stdio'
313
+ // A URL alone is ambiguous across legacy dialects. Never infer transport from a suffix.
314
+ if (out.type === undefined)
315
+ throw new Error('Legacy remote server must declare http, streamable-http, or sse explicitly.')
316
+ if (typeof out.command === 'string') {
317
+ const command = replaceLegacyRoot(out.command)
318
+ out.command = command.startsWith('${PLUGIN_ROOT}/')
319
+ ? `./${command.slice('${PLUGIN_ROOT}/'.length)}`
320
+ : command
321
+ }
322
+ if (Array.isArray(out.args))
323
+ out.args = out.args.map((arg) => (typeof arg === 'string' ? replaceLegacyRoot(arg) : arg))
324
+ if (isRecord(out.env))
325
+ out.env = Object.fromEntries(
326
+ Object.entries(out.env).map(([key, value]) => [
327
+ key,
328
+ typeof value === 'string' ? replaceLegacyRoot(value) : value,
329
+ ])
330
+ )
331
+ if (typeof out.cwd === 'string') out.cwd = replaceLegacyRoot(out.cwd)
332
+ const strings: string[] = []
333
+ const collect = (value: unknown): void => {
334
+ if (typeof value === 'string') strings.push(value)
335
+ else if (Array.isArray(value)) value.forEach(collect)
336
+ else if (isRecord(value)) Object.values(value).forEach(collect)
337
+ }
338
+ collect(out)
339
+ if (strings.some((value) => /\$\{(?!PLUGIN_ROOT\}|PLUGIN_DATA\})[^}]+\}/.test(value)))
340
+ throw new Error(
341
+ 'Legacy environment/credential interpolation needs an explicit Control Plane binding; it is not portable substitution.'
342
+ )
343
+ if (
344
+ out.type !== 'stdio' &&
345
+ strings.some((value) => /\$\{(?:PLUGIN_ROOT|PLUGIN_DATA)\}/.test(value))
346
+ )
347
+ throw new Error('Remote MCP fields cannot use plugin path expansion.')
348
+ return validateServer(out)
349
+ }
350
+
351
+ export interface CompilePackageInput {
352
+ readonly files: ReadonlyMap<string, Uint8Array>
353
+ readonly symlinks?: readonly string[]
354
+ readonly name: string
355
+ readonly sourceDigest?: string
356
+ }
357
+ /** Compile a package recipe only. No writes, subprocesses, network access or lifecycle scripts. */
358
+ export function compileAgentPackage(input: CompilePackageInput): AgentPackage {
359
+ const diagnostics: Diagnostic[] = []
360
+ const nonPortable: AgentPackage['nonPortable'] = []
361
+ const skills: AgentPackage['skills'] = []
362
+ const servers = new Map<string, McpServer>()
363
+ const omitted = new Set<string>()
364
+ const files = input.files
365
+ const originFormat =
366
+ files.has('plugin.json') &&
367
+ (() => {
368
+ try {
369
+ const raw = parsePluginJson(decoder.decode(files.get('plugin.json')!))
370
+ return isRecord(raw) && own(raw, '$schema')
371
+ } catch {
372
+ return true
373
+ }
374
+ })()
375
+ ? 'agent-plugins'
376
+ : 'legacy'
377
+ const actualSourceDigest = sourceDigest(files)
378
+ const base = {
379
+ contractVersion: 1 as const,
380
+ normalizerVersion: NORMALIZER_VERSION,
381
+ format: 'agent-plugins' as const,
382
+ specVersion: AGENT_PLUGINS_VERSION,
383
+ originFormat,
384
+ sourceDigest: actualSourceDigest,
385
+ skills,
386
+ nonPortable,
387
+ diagnostics,
388
+ }
389
+ const unavailable = (message: string): AgentPackage =>
390
+ AgentPackageSchema.parse({
391
+ ...base,
392
+ status: 'unavailable',
393
+ skills: [],
394
+ files: [],
395
+ mcpServers: {},
396
+ requirements: { skills: false, mcpTransports: [], executables: [], environmentReview: true },
397
+ diagnostics: [...diagnostics, diagnostic('INVALID_PLUGIN', 'plugin', 'plugin.json', message)],
398
+ })
399
+ try {
400
+ if (input.sourceDigest && input.sourceDigest !== actualSourceDigest)
401
+ throw new Error('Source content digest mismatch.')
402
+ if (input.symlinks?.length) throw new Error('Symlinks are excluded by marketplace policy.')
403
+ const paths = new Set<string>()
404
+ const spellings = new Map<string, string>()
405
+ let total = 0
406
+ if (files.size > 4096) throw new Error('Package exceeds file-count policy.')
407
+ for (const [path, bytes] of files) {
408
+ assertPackagePath(path)
409
+ const folded = path.normalize('NFC').toLowerCase()
410
+ if (paths.has(folded)) throw new Error('Case/Unicode-normalization path collision.')
411
+ paths.add(folded)
412
+ for (let component = path; component !== '.'; component = posix.dirname(component)) {
413
+ const key = component.normalize('NFC').toLowerCase()
414
+ if (spellings.has(key) && spellings.get(key) !== component)
415
+ throw new Error('Case/Unicode-normalization directory collision.')
416
+ spellings.set(key, component)
417
+ }
418
+ total += bytes.byteLength
419
+ if (bytes.byteLength > 5 * 1024 * 1024 || total > 50 * 1024 * 1024)
420
+ throw new Error('Package exceeds byte-size policy.')
421
+ for (let parent = posix.dirname(path); parent !== '.'; parent = posix.dirname(parent))
422
+ if (files.has(parent)) throw new Error('File/directory path collision.')
423
+ }
424
+ for (const path of paths)
425
+ for (let parent = posix.dirname(path); parent !== '.'; parent = posix.dirname(parent))
426
+ if (paths.has(parent)) throw new Error('File/directory path collision.')
427
+ } catch (error) {
428
+ return unavailable(error instanceof Error ? error.message : 'Invalid snapshot.')
429
+ }
430
+ let manifest: AgentManifest
431
+ let rawManifest: Record<string, unknown> = {}
432
+ let manifestSourcePath = 'plugin.json'
433
+ try {
434
+ if (originFormat === 'agent-plugins') {
435
+ const raw = parsePluginJson(decoder.decode(files.get('plugin.json')!))
436
+ manifest = nativeManifest(raw, diagnostics)
437
+ rawManifest = isRecord(raw) ? raw : {}
438
+ } else {
439
+ const manifestPath = [
440
+ 'plugin.json',
441
+ '.claude-plugin/plugin.json',
442
+ '.cursor-plugin/plugin.json',
443
+ '.codex-plugin/plugin.json',
444
+ '.agents/plugin.json',
445
+ ].find((path) => files.has(path))
446
+ if (manifestPath) {
447
+ manifestSourcePath = manifestPath
448
+ const raw = parsePluginJson(decoder.decode(files.get(manifestPath)!))
449
+ if (!isRecord(raw)) throw new Error('Legacy manifest is not an object.')
450
+ rawManifest = raw
451
+ }
452
+ manifest = legacyManifest(rawManifest, input.name)
453
+ const declarations = Array.isArray(rawManifest.skills)
454
+ ? rawManifest.skills
455
+ : [rawManifest.skills]
456
+ if (
457
+ rawManifest.skills !== undefined &&
458
+ !declarations.every((path) => path === './skills' || path === './skills/')
459
+ )
460
+ nonPortable.push({
461
+ kind: 'legacy-skill-discovery',
462
+ paths: [manifestSourcePath],
463
+ reason:
464
+ 'Custom skill discovery declarations need review. Only fixed skills/<name>/SKILL.md directories are projected.',
465
+ })
466
+ }
467
+ } catch {
468
+ return unavailable(
469
+ 'Root manifest is malformed, has invalid required metadata, or declares an unsupported schema. No legacy fallback is attempted.'
470
+ )
471
+ }
472
+
473
+ if (files.has('skills')) {
474
+ omitted.add('skills')
475
+ diagnostics.push(
476
+ diagnostic('INVALID_SKILLS_LOCATION', 'skills', 'skills', 'skills must be a directory.')
477
+ )
478
+ }
479
+ for (const [path, bytes] of [...files].toSorted(([a], [b]) => compare(a, b))) {
480
+ const match = /^skills\/([^/]+)\/SKILL\.md$/.exec(path)
481
+ if (!match) continue
482
+ try {
483
+ const text = decoder.decode(bytes)
484
+ const frontmatter = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(
485
+ text
486
+ )?.[1]
487
+ if (frontmatter === undefined) throw new Error('Missing YAML frontmatter.')
488
+ const document = parseDocument(frontmatter, { uniqueKeys: true, strict: true })
489
+ if (document.errors.length || document.warnings.length)
490
+ throw new Error('Invalid or unsupported YAML.')
491
+ const skill = SkillFrontmatterSchema.parse(document.toJS({ maxAliasCount: 100 }))
492
+ if (skill.name !== match[1]) throw new Error('Skill name must match its directory.')
493
+ skills.push({
494
+ name: skill.name,
495
+ path: posix.dirname(path),
496
+ description: skill.description,
497
+ ...(skill.compatibility ? { compatibility: skill.compatibility } : {}),
498
+ ...(skill['allowed-tools'] ? { allowedTools: skill['allowed-tools'] } : {}),
499
+ })
500
+ } catch {
501
+ omitted.add(path)
502
+ diagnostics.push(
503
+ diagnostic(
504
+ 'INVALID_SKILL',
505
+ 'skill',
506
+ path,
507
+ 'Invalid Agent Skills frontmatter; this skill is excluded without disabling other components.'
508
+ )
509
+ )
510
+ }
511
+ }
512
+ // Deeper SKILL.md files are resources, never additional discovered skills.
513
+ let mcpEntries: Record<string, unknown> = {}
514
+ let mcpPath = 'mcp.json'
515
+ try {
516
+ if (originFormat === 'agent-plugins') {
517
+ if ([...files.keys()].some((path) => path.startsWith('mcp.json/')))
518
+ throw new Error('mcp.json is not a file.')
519
+ if (files.has('mcp.json')) {
520
+ const root = parsePluginJson(decoder.decode(files.get('mcp.json')!))
521
+ if (
522
+ !isRecord(root) ||
523
+ root.$schema !== MCP_SCHEMA_ID ||
524
+ !isRecord(root.mcpServers) ||
525
+ Object.keys(root).some((key) => !['$schema', 'mcpServers'].includes(key))
526
+ )
527
+ throw new Error('Invalid MCP root/schema.')
528
+ mcpEntries = root.mcpServers
529
+ }
530
+ } else {
531
+ const candidates = ['mcp.json', '.mcp.json'].filter((path) => files.has(path))
532
+ const declared = rawManifest.mcpServers ?? rawManifest.mcp
533
+ if (typeof declared === 'string') {
534
+ const path = declared.replace(/^\.\//, '')
535
+ assertPackagePath(path)
536
+ if (!files.has(path)) throw new Error('Declared MCP file is missing.')
537
+ if (!candidates.includes(path)) candidates.push(path)
538
+ } else if (declared !== undefined && !isRecord(declared))
539
+ throw new Error('Unsupported legacy MCP declaration.')
540
+ if (candidates.length + (isRecord(declared) ? 1 : 0) > 1)
541
+ throw new Error('Ambiguous legacy MCP sources require an explicit translation.')
542
+ if (candidates.length) {
543
+ mcpPath = candidates[0]!
544
+ const root = parsePluginJson(decoder.decode(files.get(mcpPath)!))
545
+ if (!isRecord(root)) throw new Error('Invalid legacy MCP document.')
546
+ mcpEntries = isRecord(root.mcpServers) ? root.mcpServers : root
547
+ } else if (isRecord(declared)) mcpEntries = declared
548
+ }
549
+ } catch {
550
+ diagnostics.push(
551
+ diagnostic(
552
+ 'INVALID_MCP_CONFIGURATION',
553
+ 'mcp',
554
+ mcpPath,
555
+ 'MCP configuration/schema/location is invalid or ambiguous; other component types remain available.'
556
+ )
557
+ )
558
+ mcpEntries = {}
559
+ }
560
+ for (const [name, inputServer] of Object.entries(mcpEntries).toSorted(([a], [b]) =>
561
+ compare(a, b)
562
+ )) {
563
+ try {
564
+ if (['__proto__', 'constructor', 'prototype'].includes(name) || hasControlCharacters(name))
565
+ throw new Error('Unsafe server identifier under marketplace policy.')
566
+ const server =
567
+ originFormat === 'agent-plugins'
568
+ ? validateServer(inputServer)
569
+ : translateLegacyServer(inputServer)
570
+ if (
571
+ server.type === 'stdio' &&
572
+ server.command.startsWith('./') &&
573
+ !files.has(containedRelative(server.command.slice(2)))
574
+ )
575
+ throw new Error('Bundled executable is missing.')
576
+ servers.set(name, server)
577
+ } catch {
578
+ diagnostics.push(
579
+ diagnostic(
580
+ 'INVALID_MCP_SERVER',
581
+ 'server',
582
+ `${mcpPath}#${name}`,
583
+ 'Server is invalid or needs an explicit legacy transport/credential/path translation. It is excluded; no transport or authorization behavior is guessed.'
584
+ )
585
+ )
586
+ }
587
+ }
588
+ const extras = new Map<string, string[]>()
589
+ for (const path of files.keys()) {
590
+ const top = path.split('/')[0]!
591
+ const lower = path.toLowerCase()
592
+ const kind = [
593
+ 'commands',
594
+ 'agents',
595
+ 'hooks',
596
+ 'rules',
597
+ 'subagents',
598
+ 'schedules',
599
+ 'cron',
600
+ 'ui',
601
+ 'components',
602
+ ].includes(top)
603
+ ? top
604
+ : ['claude.md', 'agents.md'].includes(lower) || lower.startsWith('.cursor/rules/')
605
+ ? 'rules'
606
+ : lower === 'hooks.json'
607
+ ? 'hooks'
608
+ : ['.claude', '.cursor', '.agents'].includes(top) ||
609
+ (extensionName.test(top) && path.includes('/'))
610
+ ? 'client-extension'
611
+ : undefined
612
+ if (kind) extras.set(kind, [...(extras.get(kind) ?? []), path])
613
+ }
614
+ for (const key of ['commands', 'agents', 'hooks', 'rules'])
615
+ if (rawManifest[key] !== undefined)
616
+ extras.set(key, [...(extras.get(key) ?? []), manifestSourcePath])
617
+ for (const key of Object.keys(manifest.extensions ?? {}))
618
+ extras.set('client-extension', [
619
+ ...(extras.get('client-extension') ?? []),
620
+ `plugin.json/extensions/${key}`,
621
+ ])
622
+ for (const [kind, paths] of [...extras].toSorted(([a], [b]) => compare(a, b)))
623
+ nonPortable.push({
624
+ kind,
625
+ paths: [...new Set(paths)].toSorted(compare),
626
+ reason:
627
+ 'Preserved as source content, not activated or presented as portable Agent Plugins behavior.',
628
+ })
629
+
630
+ const operations: PackageFile[] = []
631
+ for (const [path, bytes] of files) {
632
+ if (
633
+ path === 'plugin.json' ||
634
+ path === 'mcp.json' ||
635
+ path.startsWith('plugin.json/') ||
636
+ path.startsWith('mcp.json/') ||
637
+ omitted.has(path)
638
+ )
639
+ continue
640
+ operations.push({
641
+ action: 'copy',
642
+ sourcePath: path,
643
+ targetPath: path,
644
+ digest: sha256(bytes),
645
+ preserveMode: true,
646
+ })
647
+ }
648
+ const mcpServers = Object.fromEntries(servers)
649
+ for (const [targetPath, data] of [
650
+ ['plugin.json', manifest],
651
+ ['mcp.json', { $schema: MCP_SCHEMA_ID, mcpServers }],
652
+ ] as const) {
653
+ const content = stableJson(data) + '\n'
654
+ operations.push({ action: 'write', targetPath, content, digest: sha256(content), mode: '0644' })
655
+ }
656
+ const sortedOperations = operations.toSorted((a, b) => compare(a.targetPath, b.targetPath))
657
+ const values = [...servers.values()]
658
+ const local = values.filter((server) => server.type === 'stdio')
659
+ const sortedDiagnostics = diagnostics.toSorted((a, b) =>
660
+ compare(`${a.path}:${a.code}`, `${b.path}:${b.code}`)
661
+ )
662
+ try {
663
+ return verifyAgentPackage({
664
+ ...base,
665
+ diagnostics: sortedDiagnostics,
666
+ manifest,
667
+ status:
668
+ sortedDiagnostics.some((item) => item.severity === 'error') || nonPortable.length
669
+ ? 'partial'
670
+ : 'portable',
671
+ packageDigest: packageDigest(sortedOperations),
672
+ files: sortedOperations,
673
+ mcpServers,
674
+ requirements: {
675
+ skills: skills.length > 0,
676
+ mcpTransports: [...new Set(values.map((server) => server.type))].toSorted(compare),
677
+ executables: [...new Set(local.map((server) => server.command))].toSorted(compare),
678
+ environmentReview:
679
+ local.length > 0 ||
680
+ skills.some((skill) => !!skill.compatibility || !!skill.allowedTools) ||
681
+ [...files.keys()].some((path) => /(?:^|\/)scripts\//.test(path)),
682
+ },
683
+ })
684
+ } catch {
685
+ return unavailable(
686
+ 'Canonical output contains conflicting paths or inconsistent component metadata.'
687
+ )
688
+ }
689
+ }
690
+
691
+ export function verifyAgentPackage(input: unknown): AgentPackage {
692
+ const pkg = AgentPackageSchema.parse(input)
693
+ if (pkg.status === 'unavailable') {
694
+ if (
695
+ pkg.manifest ||
696
+ pkg.packageDigest ||
697
+ pkg.files.length ||
698
+ pkg.skills.length ||
699
+ Object.keys(pkg.mcpServers).length
700
+ )
701
+ throw new Error('Unavailable package contains activatable content.')
702
+ return pkg
703
+ }
704
+ if (!pkg.manifest || !pkg.packageDigest || packageDigest(pkg.files) !== pkg.packageDigest)
705
+ throw new Error('Canonical package digest/manifest mismatch.')
706
+ const paths = new Set<string>()
707
+ for (const file of pkg.files) {
708
+ assertPackagePath(file.targetPath)
709
+ const key = file.targetPath.normalize('NFC').toLowerCase()
710
+ if (paths.has(key)) throw new Error('Duplicate package target.')
711
+ paths.add(key)
712
+ if (file.action === 'copy') assertPackagePath(file.sourcePath)
713
+ else if (sha256(file.content) !== file.digest)
714
+ throw new Error('Generated file digest mismatch.')
715
+ }
716
+ for (const path of paths)
717
+ for (let parent = posix.dirname(path); parent !== '.'; parent = posix.dirname(parent))
718
+ if (paths.has(parent)) throw new Error('File/directory target collision.')
719
+ const generated = (path: string): unknown => {
720
+ const file = pkg.files.find((entry) => entry.targetPath === path)
721
+ if (!file || file.action !== 'write') throw new Error('Missing canonical control file.')
722
+ return parsePluginJson(file.content)
723
+ }
724
+ if (stableJson(generated('plugin.json')) !== stableJson(pkg.manifest))
725
+ throw new Error('Manifest descriptor differs from package bytes.')
726
+ if (
727
+ stableJson(generated('mcp.json')) !==
728
+ stableJson({ $schema: MCP_SCHEMA_ID, mcpServers: pkg.mcpServers })
729
+ )
730
+ throw new Error('MCP descriptor differs from package bytes.')
731
+ for (const server of Object.values(pkg.mcpServers)) validateServer(server)
732
+ const names = new Set<string>()
733
+ for (const skill of pkg.skills) {
734
+ if (
735
+ skill.path !== `skills/${skill.name}` ||
736
+ names.has(skill.name) ||
737
+ !paths.has(`${skill.path}/SKILL.md`.toLowerCase())
738
+ )
739
+ throw new Error('Invalid skill directory binding.')
740
+ names.add(skill.name)
741
+ }
742
+ const discovered = pkg.files
743
+ .map((file) => /^skills\/([^/]+)\/SKILL\.md$/.exec(file.targetPath)?.[1])
744
+ .filter((name) => name !== undefined)
745
+ if (discovered.length !== names.size || discovered.some((name) => !names.has(name)))
746
+ throw new Error('Skill inventory differs from package discovery.')
747
+ const expectedTransports = [
748
+ ...new Set(Object.values(pkg.mcpServers).map((server) => server.type)),
749
+ ].toSorted(compare)
750
+ const expectedExecutables = [
751
+ ...new Set(
752
+ Object.values(pkg.mcpServers)
753
+ .filter((server) => server.type === 'stdio')
754
+ .map((server) => server.command)
755
+ ),
756
+ ].toSorted(compare)
757
+ if (
758
+ stableJson([...pkg.requirements.executables].toSorted(compare)) !==
759
+ stableJson(expectedExecutables)
760
+ )
761
+ throw new Error('Package executable requirements differ from components.')
762
+ if (
763
+ pkg.requirements.skills !== pkg.skills.length > 0 ||
764
+ stableJson([...pkg.requirements.mcpTransports].toSorted(compare)) !==
765
+ stableJson(expectedTransports)
766
+ )
767
+ throw new Error('Package requirements differ from components.')
768
+ if (
769
+ pkg.status === 'portable' &&
770
+ (pkg.nonPortable.length || pkg.diagnostics.some((item) => item.severity === 'error'))
771
+ )
772
+ throw new Error('Partial package is marked fully portable.')
773
+ return pkg
774
+ }
775
+
776
+ /** Apply a verified recipe in memory. Filesystem installation/approval remains Control Plane-owned. */
777
+ export function materializeAgentPackage(
778
+ input: unknown,
779
+ source: ReadonlyMap<string, Uint8Array>
780
+ ): Map<string, Uint8Array> {
781
+ const pkg = verifyAgentPackage(input)
782
+ if (pkg.status === 'unavailable' || sourceDigest(source) !== pkg.sourceDigest)
783
+ throw new Error('Unavailable package or source digest mismatch.')
784
+ const rebuilt = compileAgentPackage({
785
+ files: source,
786
+ name: pkg.manifest!.name,
787
+ sourceDigest: pkg.sourceDigest,
788
+ })
789
+ if (stableJson(rebuilt) !== stableJson(pkg))
790
+ throw new Error('Recipe differs from deterministic source normalization.')
791
+ const result = new Map<string, Uint8Array>()
792
+ for (const file of pkg.files) {
793
+ const bytes =
794
+ file.action === 'write' ? encoder.encode(file.content) : source.get(file.sourcePath)
795
+ if (!bytes || sha256(bytes) !== file.digest)
796
+ throw new Error('Package file missing or digest mismatch.')
797
+ result.set(file.targetPath, bytes.slice())
798
+ }
799
+ return result
800
+ }