From 323068f0366c53b05a32c04e8106547a2d736f01 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 17:50:35 +0000 Subject: [PATCH] feat(claude): complete marketplace manifest contract Add an authored marketplace overlay with closed validation, capability evidence, and native proof while retaining generated relative plugin sources. --- .changeset/claude-marketplace-manifest.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 42 ++ packages/agent-bundle/src/adapters/claude.ts | 703 +++++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 14 +- .../adapters/schemas/claude/PROVENANCE.json | 10 +- .../schemas/claude/marketplace.schema.json | 282 ++++++- packages/agent-bundle/src/adapters/types.ts | 8 +- .../tests/adapter-capability-states.test.ts | 25 + .../tests/adapter-metadata.test.ts | 9 +- .../tests/artifact-validator.test.ts | 64 ++ .../tests/host-adapters.native.test.ts | 105 +++ .../agent-bundle/tests/host-adapters.test.ts | 236 ++++++ .../agent-bundle/tests/plugin-bundle.test.ts | 46 ++ 13 files changed, 1515 insertions(+), 34 deletions(-) create mode 100644 .changeset/claude-marketplace-manifest.md diff --git a/.changeset/claude-marketplace-manifest.md b/.changeset/claude-marketplace-manifest.md new file mode 100644 index 000000000..6ce962559 --- /dev/null +++ b/.changeset/claude-marketplace-manifest.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add the complete authored Claude marketplace manifest overlay, including catalog metadata, plugin relevance and authentication fields, renames, and cross-marketplace dependency allowlists. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 76ff87484..626227df0 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -299,6 +299,40 @@ }, "manifest": ".claude-plugin/plugin.json", "marketplace": ".claude-plugin/marketplace.json", + "marketplaceManifest": { + "allowCrossMarketplaceDependenciesOn": true, + "entryAuthenticationFields": ["headers", "headersHelper"], + "entryMetadataFields": [ + "$schema", + "author", + "category", + "defaultEnabled", + "description", + "displayName", + "homepage", + "keywords", + "license", + "metadata", + "repository", + "strict", + "tags", + "version" + ], + "entryRelevanceSignals": ["cli", "cwd", "filesRead", "hosts", "manifestDeps"], + "generatedSourceForms": ["relative"], + "renames": true, + "topLevelFields": [ + "$schema", + "allowCrossMarketplaceDependenciesOn", + "description", + "metadata", + "name", + "owner", + "plugins", + "renames", + "version" + ] + }, "paths": { "addsToDefault": ["skills"], "fieldTypes": ["array", "string"], @@ -402,6 +436,14 @@ "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies documents intersection of constraints from multiple dependents, constrained auto-update, transitive enable, disable refusal while depended upon, release of constraints after uninstall, and pruning only auto-installed orphan dependencies.", "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies exposes dependency-unsatisfied, range-conflict, dependency-version-unsatisfied, and no-matching-tag in `claude plugin list --json` errors.", "2026-09-01: Local host proof against the observed Claude Code 2.1.257 binary (newer than the pinned 2.1.250 table): `claude plugin validate --strict` accepts an emitted plugin manifest declaring one bare dependency and one `{name, version}` dependency object and prints \"Validation passed\" (host-adapters.native.test.ts).", + "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents required top-level marketplace name, owner, and plugins plus optional $schema, description, version, metadata.pluginRoot, allowCrossMarketplaceDependenciesOn, and renames; owner requires name and optionally accepts email and url.", + "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents plugin-entry name and source plus standard metadata, category, tags, strict, relevance, defaultEnabled, component configuration, headers, and headersHelper. This slice authors catalog metadata, relevance, and authentication fields while retaining the generated relative `./` source; github, git URL, git-subdir, npm, archive, and command source variants remain the source-matrix follow-up.", + "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-relevance defines relevance topic (maximum 64 characters) and nonempty signals from cwd, cli, hosts, filesRead, and manifestDeps, including the documented item and character limits and bare-hostname rule.", + "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugins-reference confirms marketplace-entry metadata and plugin manifest metadata/component field shapes; marketplace entries may carry plugin manifest fields in addition to marketplace-specific source, category, tags, strict, relevance, headers, and headersHelper.", + "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-dependencies requires cross-marketplace dependency targets in the root marketplace's allowCrossMarketplaceDependenciesOn array; only the root allowlist is consulted and trust does not chain.", + "2026-09-02: Claude Code 2.1.257 `claude plugin validate --strict` accepts the generated relative-source marketplace enriched with owner contacts, metadata.pluginRoot, version, renames, allowCrossMarketplaceDependenciesOn, plugin metadata, relevance, tags, and strict mode.", + "2026-09-02: Claude Code 2.1.257 strict validation accepts an empty string inside allowCrossMarketplaceDependenciesOn without naming the field, so Agent Bundle's kebab-case/nonempty/unique validation is the pre-distribution allowlist guard.", + "2026-09-02: Claude Code 2.1.257 recognizes headers and headersHelper but warns that they have no effect on the generated relative source; --strict promotes that warning to failure. The pinned schema retains the documented fields, but the authored overlay rejects them with claude.marketplace.plugin.headers.inapplicable and claude.marketplace.plugin.headersHelper.inapplicable so the compiler never emits a strict-failing manifest; archive and other URL-capable sources remain the source-matrix follow-up.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents `displayName` as the human-readable UI name that falls back to `name`, `metadata` as a free-form object Claude Code does not read, and `defaultEnabled` as the boolean fallback enabled state whose default is true. The same metadata table documents `$schema`, version, description, author, homepage, repository, license, and keywords.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that an existing `enabledPlugins` user setting and an active dependency requirement both take precedence over plugin.json `defaultEnabled`, while a marketplace entry's `defaultEnabled` takes precedence over the plugin manifest value.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that wrong types make most manifest fields fail plugin loading, but non-object `experimental` and `metadata` values are ignored with a `claude plugin validate` warning; `--strict` promotes warnings to failure. Before v2.1.222, `metadata` was treated as unrecognized.", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 36327bb4b..9209b3c9e 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -203,6 +203,78 @@ export interface ClaudeDependencyConfig { readonly version?: string; } +export interface ClaudeMarketplaceContactConfig { + readonly email?: string; + readonly name?: string; + readonly url?: string; +} + +export interface ClaudeMarketplaceMetadataConfig { + /** Backward-compatible marketplace description accepted by Claude Code. */ + readonly description?: string; + /** Base directory for bare plugin sources; generated `./` sources ignore it. */ + readonly pluginRoot?: string; + /** Backward-compatible marketplace version accepted by Claude Code. */ + readonly version?: string; +} + +export interface ClaudeMarketplaceManifestDependencySignal { + readonly file: string; + readonly pattern: string; +} + +export interface ClaudeMarketplaceRelevanceSignals { + readonly cli?: readonly string[]; + readonly cwd?: readonly string[]; + readonly filesRead?: readonly string[]; + readonly hosts?: readonly string[]; + readonly manifestDeps?: readonly ClaudeMarketplaceManifestDependencySignal[]; +} + +export interface ClaudeMarketplaceRelevanceConfig { + readonly signals: ClaudeMarketplaceRelevanceSignals; + readonly topic?: string; +} + +/** + * Authored fields that enrich the one generated marketplace plugin entry. + * Plugin identity, component paths, and the relative `./` source remain + * generator-owned; URL and command source variants are tracked separately. + */ +export interface ClaudeMarketplacePluginConfig { + readonly author?: ClaudeMarketplaceContactConfig & { readonly name: string }; + readonly category?: string; + readonly defaultEnabled?: boolean; + readonly description?: string; + readonly displayName?: string; + /** Archive-download headers; rejected until URL-capable sources are emitted. */ + readonly headers?: Readonly>; + /** Archive-download header command; rejected until URL-capable sources are emitted. */ + readonly headersHelper?: string; + readonly homepage?: string; + readonly keywords?: readonly string[]; + readonly license?: string; + readonly metadata?: Readonly>; + readonly relevance?: ClaudeMarketplaceRelevanceConfig; + readonly repository?: string; + readonly strict?: boolean; + readonly tags?: readonly string[]; + readonly version?: string; +} + +/** Authored overlay for the generated `.claude-plugin/marketplace.json`. */ +export interface ClaudeMarketplaceConfig { + readonly $schema?: string; + readonly allowCrossMarketplaceDependenciesOn?: readonly string[]; + readonly description?: string; + readonly metadata?: ClaudeMarketplaceMetadataConfig; + readonly name?: string; + readonly owner?: ClaudeMarketplaceContactConfig; + readonly plugin?: ClaudeMarketplacePluginConfig; + readonly renames?: Readonly>; + readonly version?: string; +} + /** * Claude's host config. `lspServers` lives here rather than in a portable * top-level block because no other pinned host contract has an LSP surface; @@ -225,6 +297,8 @@ export interface ClaudeHostConfig extends AgentBundleHostConfig { /** Human-readable plugin name shown in Claude Code UI surfaces. */ readonly displayName?: string; readonly lspServers?: Readonly>; + /** Enriches the generated marketplace and its single relative-source plugin entry. */ + readonly marketplace?: ClaudeMarketplaceConfig; /** Free-form catalog or entitlement data that Claude Code preserves but does not interpret. */ readonly metadata?: Readonly>; /** Experimental session-lifetime background monitors discovered from `monitors/monitors.json`. */ @@ -295,7 +369,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.14.0', + adapterRevision: '1.15.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -658,6 +732,593 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan return { diagnostics, document: Object.freeze(document), sourceInputs: inputs }; }; +interface ClaudeMarketplacePlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Record; + readonly sourceInputs: readonly string[]; +} + +const marketplaceFields: ReadonlySet = new Set([ + '$schema', + 'allowCrossMarketplaceDependenciesOn', + 'description', + 'metadata', + 'name', + 'owner', + 'plugin', + 'renames', + 'version', +]); +const marketplaceMetadataFields: ReadonlySet = new Set(['description', 'pluginRoot', 'version']); +const marketplacePluginFields: ReadonlySet = new Set([ + 'author', + 'category', + 'defaultEnabled', + 'description', + 'displayName', + 'headers', + 'headersHelper', + 'homepage', + 'keywords', + 'license', + 'metadata', + 'relevance', + 'repository', + 'strict', + 'tags', + 'version', +]); +const marketplaceContactFields: ReadonlySet = new Set(['email', 'name', 'url']); +const relevanceFields: ReadonlySet = new Set(['signals', 'topic']); +const relevanceSignalFields: ReadonlySet = new Set(['cli', 'cwd', 'filesRead', 'hosts', 'manifestDeps']); +const manifestDependencySignalFields: ReadonlySet = new Set(['file', 'pattern']); +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; +const hostnamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +const reservedMarketplaceNames: ReadonlySet = new Set([ + 'agent-skills', + 'anthropic-agent-skills', + 'anthropic-marketplace', + 'anthropic-plugins', + 'claude-code-marketplace', + 'claude-code-plugins', + 'claude-community', + 'claude-for-financial-services', + 'claude-for-legal', + 'claude-plugins-community', + 'claude-plugins-official', + 'financial-services-plugins', + 'first-party-plugins', + 'healthcare', + 'knowledge-work-plugins', + 'life-sciences', +]); + +const marketplaceDiagnostic = (code: string, message: string, recovery: string): Diagnostic => ({ + ...errorDiagnostic(code, message), + recovery, +}); + +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +const isHttpUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const planMarketplaceContact = ( + declared: unknown, + kind: 'author' | 'owner', +): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { + const diagnostics: Diagnostic[] = []; + const prefix = kind === 'owner' ? 'claude.marketplace.owner' : 'claude.marketplace.plugin.author'; + const label = kind === 'owner' ? 'marketplace owner' : 'marketplace plugin author'; + if (!isPlainDataRecord(declared)) { + return { + diagnostics: [marketplaceDiagnostic( + `${prefix}.invalid`, + `Claude ${label} must be a plain contact object.`, + `Set ${kind} to an object with ${kind === 'author' ? 'required ' : ''}name and optional email and url, then rebuild.`, + )], + }; + } + for (const field of Object.keys(declared).sort()) { + if (marketplaceContactFields.has(field)) continue; + diagnostics.push(marketplaceDiagnostic( + `${prefix}.field.unknown`, + `Claude ${label} declares unknown field ${JSON.stringify(field)}.`, + `Remove ${kind}.${field}; contact objects support only name, email, and url, then rebuild.`, + )); + } + const name = declared['name']; + if (kind === 'author' && !isNonemptyString(name)) { + diagnostics.push(marketplaceDiagnostic( + `${prefix}.name.invalid`, + 'Claude marketplace plugin author requires a nonempty name.', + 'Set plugin.author.name to the author or team name, then rebuild.', + )); + } else if (name !== undefined && !isNonemptyString(name)) { + diagnostics.push(marketplaceDiagnostic( + `${prefix}.name.invalid`, + 'Claude marketplace owner name must be nonempty when provided.', + 'Set owner.name to the maintainer or team name, or remove it to use the generated owner, then rebuild.', + )); + } + const email = declared['email']; + if (email !== undefined && (typeof email !== 'string' || !emailPattern.test(email))) { + diagnostics.push(marketplaceDiagnostic( + `${prefix}.email.invalid`, + `Claude ${label} email must be a valid nonempty email address.`, + `Set ${kind}.email to a valid contact email or remove it, then rebuild.`, + )); + } + const url = declared['url']; + if (url !== undefined && (typeof url !== 'string' || !isHttpUrl(url))) { + diagnostics.push(marketplaceDiagnostic( + `${prefix}.url.invalid`, + `Claude ${label} url must be an absolute HTTP(S) URL.`, + `Set ${kind}.url to an absolute HTTP(S) URL or remove it, then rebuild.`, + )); + } + if (diagnostics.length > 0) return { diagnostics }; + return { + diagnostics, + value: { + ...(typeof email === 'string' ? { email } : {}), + ...(typeof name === 'string' ? { name } : {}), + ...(typeof url === 'string' ? { url } : {}), + }, + }; +}; + +const validateStringList = ( + value: unknown, + maximumItems: number | undefined, + maximumLength: number | undefined, +): value is readonly string[] => + Array.isArray(value) && + value.length > 0 && + (maximumItems === undefined || value.length <= maximumItems) && + value.every((entry) => + isNonemptyString(entry) && (maximumLength === undefined || entry.length <= maximumLength)); + +const planMarketplaceRelevance = ( + declared: unknown, +): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { + const diagnostics: Diagnostic[] = []; + if (!isPlainDataRecord(declared)) { + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.invalid', + 'Claude marketplace plugin relevance must be a plain object with a nonempty signals object.', + 'Set plugin.relevance to { topic?, signals } with at least one documented signal, then rebuild.', + )], + }; + } + for (const field of Object.keys(declared).sort()) { + if (relevanceFields.has(field)) continue; + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.field.unknown', + `Claude marketplace plugin relevance declares unknown field ${JSON.stringify(field)}.`, + `Remove plugin.relevance.${field}; relevance supports only topic and signals, then rebuild.`, + )); + } + const topic = declared['topic']; + if (topic !== undefined && (!isNonemptyString(topic) || topic.length > 64)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.topic.invalid', + 'Claude marketplace plugin relevance topic must be a nonempty string of at most 64 characters.', + 'Set plugin.relevance.topic to a concise phrase of at most 64 characters or remove it, then rebuild.', + )); + } + const signals = declared['signals']; + if (!isPlainDataRecord(signals) || Object.keys(signals).length === 0) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.invalid', + 'Claude marketplace plugin relevance signals must be a nonempty plain object.', + 'Declare at least one of cwd, cli, hosts, filesRead, or manifestDeps under plugin.relevance.signals, then rebuild.', + )); + return { diagnostics }; + } + for (const field of Object.keys(signals).sort()) { + if (relevanceSignalFields.has(field)) continue; + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.signals.field.unknown', + `Claude marketplace plugin relevance signals declare unknown field ${JSON.stringify(field)}.`, + `Remove plugin.relevance.signals.${field}; use cwd, cli, hosts, filesRead, or manifestDeps, then rebuild.`, + )); + } + for (const field of ['cwd', 'filesRead'] as const) { + if (signals[field] === undefined || validateStringList(signals[field], 10, 256)) continue; + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.plugin.relevance.${field}.invalid`, + `Claude marketplace relevance ${field} must be a nonempty array of at most 10 nonempty glob patterns, each at most 256 characters.`, + `Correct plugin.relevance.signals.${field} or remove it, then rebuild.`, + )); + } + if (signals['cli'] !== undefined && !validateStringList(signals['cli'], 10, 64)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.cli.invalid', + 'Claude marketplace relevance cli must be a nonempty array of at most 10 nonempty command names, each at most 64 characters.', + 'Correct plugin.relevance.signals.cli or remove it, then rebuild.', + )); + } + if ( + signals['hosts'] !== undefined && + ( + !validateStringList(signals['hosts'], 20, 128) || + !(signals['hosts'] as readonly string[]).every((host) => + hostnamePattern.test(host) && !host.includes('..')) + ) + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.hosts.invalid', + 'Claude marketplace relevance hosts must contain at most 20 bare lowercase hostnames without schemes, ports, or paths.', + 'Replace plugin.relevance.signals.hosts entries with bare lowercase hostnames, then rebuild.', + )); + } + const manifestDeps = signals['manifestDeps']; + if (manifestDeps !== undefined) { + if (!Array.isArray(manifestDeps) || manifestDeps.length === 0 || manifestDeps.length > 10) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.manifestDeps.invalid', + 'Claude marketplace relevance manifestDeps must be a nonempty array of at most 10 matcher objects.', + 'Set plugin.relevance.signals.manifestDeps to at most 10 { file, pattern } objects, then rebuild.', + )); + } else { + for (const [index, signal] of manifestDeps.entries()) { + let valid = isPlainDataRecord(signal); + if (valid) { + valid = + Object.keys(signal).every((field) => manifestDependencySignalFields.has(field)) && + isNonemptyString(signal['file']) && + signal['file'].length <= 256 && + isNonemptyString(signal['pattern']) && + signal['pattern'].length <= 256; + if (valid) { + try { + new RegExp(signal['file'] as string, 'iu'); + new RegExp(signal['pattern'] as string, 'u'); + } catch { + valid = false; + } + } + } + if (valid) continue; + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.relevance.manifestDeps.invalid', + `Claude marketplace relevance manifestDeps entry ${index} must be a closed { file, pattern } object containing valid JavaScript regular expressions of at most 256 characters.`, + `Correct plugin.relevance.signals.manifestDeps[${index}] or remove it, then rebuild.`, + )); + } + } + } + if (diagnostics.length > 0) return { diagnostics }; + return { + diagnostics, + value: { + signals, + ...(typeof topic === 'string' ? { topic } : {}), + }, + }; +}; + +const planMarketplacePlugin = ( + declared: unknown, +): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { + const diagnostics: Diagnostic[] = []; + if (!isPlainDataRecord(declared)) { + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.plugin.invalid', + 'Claude marketplace plugin overlay must be a plain object.', + 'Set claude.marketplace.plugin to an object of documented plugin-entry metadata fields, then rebuild.', + )], + }; + } + for (const field of Object.keys(declared).sort()) { + if (marketplacePluginFields.has(field)) continue; + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.field.unknown', + `Claude marketplace plugin overlay declares unknown or generator-owned field ${JSON.stringify(field)}.`, + `Remove plugin.${field}; plugin identity, source, and component paths are generated separately, then rebuild.`, + )); + } + const value: Record = Object.create(null) as Record; + for (const field of ['category', 'description', 'displayName', 'license', 'version'] as const) { + const entry = declared[field]; + if (entry === undefined) continue; + if (!isNonemptyString(entry)) { + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.plugin.${field}.invalid`, + `Claude marketplace plugin ${field} must be a nonempty string.`, + `Set plugin.${field} to a nonempty string or remove it, then rebuild.`, + )); + } else { + value[field] = entry; + } + } + for (const field of ['homepage', 'repository'] as const) { + const entry = declared[field]; + if (entry === undefined) continue; + if (typeof entry !== 'string' || !isHttpUrl(entry)) { + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.plugin.${field}.invalid`, + `Claude marketplace plugin ${field} must be an absolute HTTP(S) URL.`, + `Set plugin.${field} to an absolute HTTP(S) URL or remove it, then rebuild.`, + )); + } else { + value[field] = entry; + } + } + for (const field of ['defaultEnabled', 'strict'] as const) { + const entry = declared[field]; + if (entry === undefined) continue; + if (typeof entry !== 'boolean') { + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.plugin.${field}.invalid`, + `Claude marketplace plugin ${field} must be a boolean.`, + `Set plugin.${field} to true or false or remove it, then rebuild.`, + )); + } else { + value[field] = entry; + } + } + for (const field of ['keywords', 'tags'] as const) { + const entry = declared[field]; + if (entry === undefined) continue; + if (!validateStringList(entry, undefined, undefined)) { + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.plugin.${field}.invalid`, + `Claude marketplace plugin ${field} must be a nonempty array of nonempty strings.`, + `Set plugin.${field} to nonempty discovery labels or remove it, then rebuild.`, + )); + } else { + value[field] = entry; + } + } + const metadataValue = declared['metadata']; + if (metadataValue !== undefined) { + if (!isPlainDataRecord(metadataValue)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.plugin.metadata.invalid', + 'Claude marketplace plugin metadata must be a plain JSON object.', + 'Set plugin.metadata to a plain JSON object or remove it, then rebuild.', + )); + } else { + value['metadata'] = metadataValue; + } + } + // Claude Code 2.1.257 warns that headers/headersHelper only apply to + // archive sources and --strict promotes that warning to failure, so the + // authored overlay rejects them until URL-capable sources are emitted. + for (const field of ['headers', 'headersHelper'] as const) { + if (declared[field] === undefined) continue; + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.plugin.${field}.inapplicable`, + `Claude Code applies marketplace ${field} only to archive sources, and the generated plugin entry uses the relative './' source, so strict native validation rejects it.`, + `Remove plugin.${field} until Agent Bundle emits URL-capable marketplace sources, then rebuild.`, + )); + } + const author = declared['author']; + if (author !== undefined) { + const planned = planMarketplaceContact(author, 'author'); + diagnostics.push(...planned.diagnostics); + if (planned.value !== undefined) value['author'] = planned.value; + } + const relevance = declared['relevance']; + if (relevance !== undefined) { + const planned = planMarketplaceRelevance(relevance); + diagnostics.push(...planned.diagnostics); + if (planned.value !== undefined) value['relevance'] = planned.value; + } + return { + diagnostics, + ...(diagnostics.length === 0 ? { value } : {}), + }; +}; + +const planClaudeMarketplace = (model: NormalizedPlugin): ClaudeMarketplacePlan => { + const extension = model.extensions[claudeName]; + const declared = extension !== undefined && isDataRecord(extension.value) + ? extension.value['marketplace'] + : undefined; + const inputs = declared === undefined || extension === undefined + ? [] + : sourceInputs(extension.provenance.sourcePath); + const basePlugin: Record = { + description: model.metadata.description ?? model.metadata.name, + name: model.metadata.name, + source: './', + version: model.metadata.version, + }; + const generatedName = `${model.metadata.name}-marketplace`; + const base: Record = { + description: model.metadata.description ?? model.metadata.name, + name: generatedName, + owner: { name: model.metadata.name }, + plugins: [basePlugin], + }; + if (declared === undefined) { + if (!reservedMarketplaceNames.has(generatedName)) { + return { diagnostics: [], document: base, sourceInputs: inputs }; + } + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.name.reserved', + `Claude marketplace name ${JSON.stringify(generatedName)} is reserved for official Anthropic use.`, + 'Override claude.marketplace.name with a distinct lowercase kebab-case marketplace identifier, then rebuild.', + )], + sourceInputs: inputs, + }; + } + if (!isPlainDataRecord(declared)) { + return { + diagnostics: [marketplaceDiagnostic( + 'claude.marketplace.declaration.invalid', + 'Claude marketplace must be a plain authored overlay object.', + 'Set claude.marketplace to an object of documented marketplace fields, then rebuild.', + )], + sourceInputs: inputs, + }; + } + + const diagnostics: Diagnostic[] = []; + for (const field of Object.keys(declared).sort()) { + if (marketplaceFields.has(field)) continue; + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.field.unknown', + `Claude marketplace declares unknown or generator-owned field ${JSON.stringify(field)}.`, + `Remove marketplace.${field} or replace it with a documented authored overlay field, then rebuild.`, + )); + } + const document: Record = { ...base }; + for (const field of ['$schema', 'description', 'version'] as const) { + const value = declared[field]; + if (value === undefined) continue; + if (!isNonemptyString(value) || (field === '$schema' && !isHttpUrl(value))) { + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.${field === '$schema' ? 'schema' : field}.invalid`, + field === '$schema' + ? 'Claude marketplace $schema must be an absolute HTTP(S) URL.' + : `Claude marketplace ${field} must be a nonempty string.`, + `Set marketplace.${field} to a valid nonempty value or remove it, then rebuild.`, + )); + } else { + document[field] = value; + } + } + const name = declared['name']; + if (name !== undefined) { + if (typeof name !== 'string' || !pluginNamePattern.test(name)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.name.invalid', + `Claude marketplace name must match the kebab-case pattern ${pluginNamePattern.source}.`, + 'Set marketplace.name to a lowercase kebab-case identifier, then rebuild.', + )); + } else { + document['name'] = name; + if (reservedMarketplaceNames.has(name)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.name.reserved', + `Claude marketplace name ${JSON.stringify(name)} is reserved for official Anthropic use.`, + 'Set marketplace.name to a distinct lowercase kebab-case identifier, then rebuild.', + )); + } + } + } + const owner = declared['owner']; + if (owner !== undefined) { + const planned = planMarketplaceContact(owner, 'owner'); + diagnostics.push(...planned.diagnostics); + if (planned.value !== undefined) { + document['owner'] = { ...(base['owner'] as Record), ...planned.value }; + } + } + const metadataValue = declared['metadata']; + if (metadataValue !== undefined) { + if (!isPlainDataRecord(metadataValue)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.metadata.invalid', + 'Claude marketplace metadata must be a plain object.', + 'Set marketplace.metadata to an object containing pluginRoot, description, or version, then rebuild.', + )); + } else { + const metadataDocument: Record = {}; + for (const field of Object.keys(metadataValue).sort()) { + if (!marketplaceMetadataFields.has(field)) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.metadata.field.unknown', + `Claude marketplace metadata declares unknown field ${JSON.stringify(field)}.`, + `Remove marketplace.metadata.${field}; metadata supports pluginRoot, description, and version, then rebuild.`, + )); + continue; + } + const value = metadataValue[field]; + const pathValid = field !== 'pluginRoot' || + (isNonemptyString(value) && + value.startsWith('./') && + !value.split('/').includes('..')); + if (!isNonemptyString(value) || !pathValid) { + diagnostics.push(marketplaceDiagnostic( + `claude.marketplace.metadata.${field}.invalid`, + field === 'pluginRoot' + ? 'Claude marketplace metadata.pluginRoot must be a nonempty ./-prefixed path that stays inside the marketplace.' + : `Claude marketplace metadata.${field} must be a nonempty string.`, + `Correct marketplace.metadata.${field} or remove it, then rebuild.`, + )); + } else { + metadataDocument[field] = value; + } + } + document['metadata'] = metadataDocument; + } + } + const allowlist = declared['allowCrossMarketplaceDependenciesOn']; + if (allowlist !== undefined) { + if ( + !validateStringList(allowlist, undefined, undefined) || + !allowlist.every((entry) => pluginNamePattern.test(entry)) || + new Set(allowlist).size !== allowlist.length + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.allowCrossMarketplaceDependenciesOn.invalid', + 'Claude allowCrossMarketplaceDependenciesOn must be a nonempty array of unique lowercase kebab-case marketplace names.', + 'Set marketplace.allowCrossMarketplaceDependenciesOn to unique trusted marketplace names, then rebuild.', + )); + } else { + document['allowCrossMarketplaceDependenciesOn'] = allowlist; + } + } + const renames = declared['renames']; + if (renames !== undefined) { + if ( + !isPlainDataRecord(renames) || + Object.keys(renames).length === 0 || + !Object.entries(renames).every(([formerName, replacement]) => + pluginNamePattern.test(formerName) && + (replacement === null || (typeof replacement === 'string' && pluginNamePattern.test(replacement)))) + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.renames.invalid', + 'Claude marketplace renames must be a nonempty map from kebab-case former plugin names to kebab-case replacements or null.', + 'Correct marketplace.renames to map former plugin names to current names or null, then rebuild.', + )); + } else { + document['renames'] = renames; + } + } + const pluginOverlay = declared['plugin']; + if (pluginOverlay !== undefined) { + const planned = planMarketplacePlugin(pluginOverlay); + diagnostics.push(...planned.diagnostics); + if (planned.value !== undefined) { + document['plugins'] = [{ ...basePlugin, ...planned.value }]; + } + } + const effectiveName = document['name']; + if ( + typeof effectiveName === 'string' && + reservedMarketplaceNames.has(effectiveName) && + !diagnostics.some((diagnostic) => diagnostic.code === 'claude.marketplace.name.reserved') + ) { + diagnostics.push(marketplaceDiagnostic( + 'claude.marketplace.name.reserved', + `Claude marketplace name ${JSON.stringify(effectiveName)} is reserved for official Anthropic use.`, + 'Set marketplace.name to a distinct lowercase kebab-case identifier, then rebuild.', + )); + } + return { + diagnostics, + ...(diagnostics.length === 0 ? { document } : {}), + sourceInputs: inputs, + }; +}; + const expandLspToken = (value: unknown): unknown => typeof value === 'string' ? expandClaudeToken(value) : value; @@ -1898,19 +2559,12 @@ export const planClaudeArtifacts = ( }; diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); - const marketplace = { - description: model.metadata.description ?? model.metadata.name, - name: `${model.metadata.name}-marketplace`, - owner: { name: model.metadata.name }, - plugins: [{ - description: model.metadata.description ?? model.metadata.name, - name: model.metadata.name, - source: './', - version: model.metadata.version, - }], - }; - const marketplaceValid = validateMarketplace(marketplace); - diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); + const marketplace = planClaudeMarketplace(model); + diagnostics.push(...marketplace.diagnostics); + const marketplaceValid = marketplace.document !== undefined && validateMarketplace(marketplace.document); + if (marketplace.document !== undefined) { + diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); + } const hostDocuments: StandardPluginHostDocument[] = []; if (lsp.document !== undefined) { @@ -1956,8 +2610,9 @@ export const planClaudeArtifacts = ( hookEntries: generatedHooks.hookEntries, hookManifestPath: hookContract.manifestPath, isSelected, - marketplace, + marketplace: marketplace.document, marketplaceRelativePath: claudeArtifactPaths.marketplace, + marketplaceSourceInputs: marketplace.sourceInputs, marketplaceValid, mcp, mcpValid, @@ -2049,6 +2704,24 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ ), install: supportedCapability(evidence), marketplace: supportedCapability(evidence), + marketplaceManifest: capabilityStateFromSupport( + capabilityTable.plugin.marketplaceManifest.renames && + capabilityTable.plugin.marketplaceManifest.entryAuthenticationFields.includes('headers') && + capabilityTable.plugin.marketplaceManifest.entryAuthenticationFields.includes('headersHelper') && + capabilityTable.plugin.marketplaceManifest.entryMetadataFields.includes('metadata') && + capabilityTable.plugin.marketplaceManifest.entryMetadataFields.includes('strict') && + capabilityTable.plugin.marketplaceManifest.entryRelevanceSignals.includes('manifestDeps') && + capabilityTable.plugin.marketplaceManifest.generatedSourceForms.length === 1 && + capabilityTable.plugin.marketplaceManifest.generatedSourceForms[0] === 'relative', + evidence, + 'The pinned Claude plugin contract does not document the complete marketplace manifest surface.', + ), + allowCrossMarketplaceDependenciesOn: capabilityStateFromSupport( + capabilityTable.plugin.marketplaceManifest.allowCrossMarketplaceDependenciesOn && + capabilityTable.plugin.dependencies.crossMarketplaceAllowlist === 'allowCrossMarketplaceDependenciesOn', + evidence, + 'The pinned Claude plugin contract does not document a root marketplace cross-dependency allowlist.', + ), hooks: supportedCapability(evidence), lsp: capabilityStateFromSupport( capabilityTable.plugin.lsp.config === claudeArtifactPaths.lsp && diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index afc99ee39..70941cc77 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -186,7 +186,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.13.0', + adapterRevision: '1.14.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -657,6 +657,18 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), cursorAdapter.capabilities.marketplace!, ), + marketplaceManifest: intersectCapabilityStates( + claudeAdapter.capabilities.marketplaceManifest!, + unavailableCapability( + 'The unified bundle emits the Claude marketplace overlay, but the pinned Codex and Cursor contracts do not share its completed marketplace manifest surface.', + ), + ), + allowCrossMarketplaceDependenciesOn: intersectCapabilityStates( + claudeAdapter.capabilities.allowCrossMarketplaceDependenciesOn!, + unavailableCapability( + 'The unified bundle emits Claude allowCrossMarketplaceDependenciesOn, but the pinned Codex and Cursor contracts declare no shared cross-marketplace dependency allowlist.', + ), + ), hooks: intersectCapabilityStates( intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), cursorAdapter.capabilities.hooks!, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index b62f277c8..578e0aa37 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -1,8 +1,8 @@ { "observedCliVersion": "2.1.250", - "retrievedAt": "2026-09-01", + "retrievedAt": "2026-09-02", "schemaSource": "https://docs.anthropic.com/en/docs/claude-code/plugins", - "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque. monitors.schema.json and theme.schema.json were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01), which marks both components experimental and warns their manifest schema may change between releases. The monitor schema is a nonempty array of closed entries requiring nonempty unique name, command, and description; optional when admits only always or on-skill-invoke:, and the plan tightens the latter by requiring the named skill to be emitted by this plugin. The schema and plan both reject ${user_config.*} in monitor commands because Claude Code rejects it at shell execution time and supplies no CLAUDE_PLUGIN_OPTION_ variables to monitors. The theme schema is closed, requires a nonempty base, and admits optional nonempty name plus a sparse string-valued overrides map. Agent Bundle further requires a nonempty themes declaration, safe file-stem keys, defaults omitted name to that key, and rejects empty override strings; it deliberately accepts any nonempty color string because the reference shows hex examples but does not mandate hex syntax. Generated themes and monitors use the default locations, so no unstable experimental.* manifest fields are emitted. Local proof against Claude Code 2.1.257 shows strict validation accepts valid emitted themes and monitors but does not inspect either default-location document's contents: malformed themes missing base and carrying non-string overrides pass, as do monitors missing command. The pinned schemas and compiler diagnostics are therefore the content guard. The same strict host rejects the deprecated top-level monitors manifest key, so default-location emission also avoids the warning-to-error migration path. The artifact contract records themes/*.json as a schema family and validates every concrete generated theme path.", + "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque. monitors.schema.json and theme.schema.json were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01), which marks both components experimental and warns their manifest schema may change between releases. The monitor schema is a nonempty array of closed entries requiring nonempty unique name, command, and description; optional when admits only always or on-skill-invoke:, and the plan tightens the latter by requiring the named skill to be emitted by this plugin. The schema and plan both reject ${user_config.*} in monitor commands because Claude Code rejects it at shell execution time and supplies no CLAUDE_PLUGIN_OPTION_ variables to monitors. The theme schema is closed, requires a nonempty base, and admits optional nonempty name plus a sparse string-valued overrides map. Agent Bundle further requires a nonempty themes declaration, safe file-stem keys, defaults omitted name to that key, and rejects empty override strings; it deliberately accepts any nonempty color string because the reference shows hex examples but does not mandate hex syntax. Generated themes and monitors use the default locations, so no unstable experimental.* manifest fields are emitted. Local proof against Claude Code 2.1.257 shows strict validation accepts valid emitted themes and monitors but does not inspect either default-location document's contents: malformed themes missing base and carrying non-string overrides pass, as do monitors missing command. The pinned schemas and compiler diagnostics are therefore the content guard. The same strict host rejects the deprecated top-level monitors manifest key, so default-location emission also avoids the warning-to-error migration path. The artifact contract records themes/*.json as a schema family and validates every concrete generated theme path. marketplace.schema.json was re-pinned from https://code.claude.com/docs/en/plugin-marketplaces, https://code.claude.com/docs/en/plugins-reference, https://code.claude.com/docs/en/plugin-dependencies, and the linked relevance contract (retrieved 2026-09-02). It loosens the old generated-only snapshot by making top-level description and plugin-entry description/version optional, allowing documented owner email/url, top-level $schema/version/metadata/renames/allowCrossMarketplaceDependenciesOn, and every documented plugin-entry metadata, component, relevance, authentication, dependency, channel, userConfig, settings, and experimental field; plugin-entry version is now any nonempty documented version string rather than generated-only semver. It deliberately retains only the relative ./ source form: github, git URL, git-subdir, npm, archive, and command sources are deferred to the source-matrix follow-up. The authored overlay is narrower than the validation schema and does not expose plugin name/source or component routing, because generated identity/layout remains authoritative and the agents component remains gate-deferred. Agent Bundle tightens the prose contract by closing every structured object (free-form metadata uses recursive patternProperties with additionalProperties false), requiring nonempty arrays/maps where an empty declaration has no effect, requiring unique kebab-case cross-marketplace allowlist names and kebab-case rename keys/targets, rejecting the documented exact reserved marketplace names, requiring relative sources and component paths to stay inside the marketplace, limiting owner/author URLs to absolute HTTP(S), requiring metadata.pluginRoot to be an internal ./ path, requiring nonempty header names/values, and requiring explicit strict:false with headersHelper. Relevance enforces the documented topic, item-count, character-count, bare-hostname, and regular-expression bounds; Claude Code 2.1.257 strict validation does not reject an empty allowCrossMarketplaceDependenciesOn entry. The same native validator warns that headers and headersHelper have no effect on the generated relative source and promotes that warning to failure under --strict, so the authored overlay rejects both fields with claude.marketplace.plugin.headers.inapplicable and claude.marketplace.plugin.headersHelper.inapplicable rather than emitting a manifest the host's strict validation refuses; the pinned schema keeps the documented fields, including the strict:false requirement beside headersHelper, for the URL-capable source-matrix follow-up.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -15,9 +15,9 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" }, "marketplace.schema.json": { - "bytes": 1210, - "sha256": "5a08f241f9e856bb59489a265d9bf4db9c905e874d720f46def59fdb6f3ca257", - "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" + "bytes": 9920, + "sha256": "2d01f4965ed671d91e8bd2c26fff6f30d49373f4daf51465a98fc46179600cab", + "url": "https://code.claude.com/docs/en/plugin-marketplaces" }, "mcp.schema.json": { "bytes": 1279, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json index a11dd3818..9609a7354 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json @@ -1,32 +1,298 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/marketplace.schema.json", + "$defs": { + "channel": { + "additionalProperties": false, + "properties": { + "server": { "minLength": 1, "type": "string" }, + "userConfig": { + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { "$ref": "#/$defs/userConfigOption" } + }, + "type": "object" + } + }, + "required": ["server"], + "type": "object" + }, + "componentPath": { + "anyOf": [ + { "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))(?:\\.|\\./.+)$", "type": "string" }, + { + "items": { "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))(?:\\.|\\./.+)$", "type": "string" }, + "minItems": 1, + "type": "array" + } + ] + }, + "contact": { + "additionalProperties": false, + "properties": { + "email": { "format": "email", "type": "string" }, + "name": { "minLength": 1, "type": "string" }, + "url": { "format": "uri", "pattern": "^https?://", "type": "string" } + }, + "required": ["name"], + "type": "object" + }, + "dependency": { + "additionalProperties": false, + "properties": { + "marketplace": { "minLength": 1, "type": "string" }, + "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + "version": { "minLength": 1, "type": "string" } + }, + "required": ["name"], + "type": "object" + }, + "freeformObject": { + "additionalProperties": false, + "patternProperties": { + "^.*$": { "$ref": "#/$defs/jsonValue" } + }, + "type": "object" + }, + "jsonValue": { + "anyOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "number" }, + { "type": "string" }, + { "items": { "$ref": "#/$defs/jsonValue" }, "type": "array" }, + { "$ref": "#/$defs/freeformObject" } + ] + }, + "manifestDependencySignal": { + "additionalProperties": false, + "properties": { + "file": { "maxLength": 256, "minLength": 1, "type": "string" }, + "pattern": { "maxLength": 256, "minLength": 1, "type": "string" } + }, + "required": ["file", "pattern"], + "type": "object" + }, + "nonemptyStrings": { + "items": { "minLength": 1, "type": "string" }, + "minItems": 1, + "type": "array" + }, + "relevance": { + "additionalProperties": false, + "properties": { + "signals": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "cli": { + "items": { "maxLength": 64, "minLength": 1, "type": "string" }, + "maxItems": 10, + "minItems": 1, + "type": "array" + }, + "cwd": { + "items": { "maxLength": 256, "minLength": 1, "type": "string" }, + "maxItems": 10, + "minItems": 1, + "type": "array" + }, + "filesRead": { + "items": { "maxLength": 256, "minLength": 1, "type": "string" }, + "maxItems": 10, + "minItems": 1, + "type": "array" + }, + "hosts": { + "items": { + "maxLength": 128, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "type": "array" + }, + "manifestDeps": { + "items": { "$ref": "#/$defs/manifestDependencySignal" }, + "maxItems": 10, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "topic": { "maxLength": 64, "minLength": 1, "type": "string" } + }, + "required": ["signals"], + "type": "object" + }, + "userConfigOption": { + "additionalProperties": false, + "properties": { + "default": { + "oneOf": [ + { "type": "boolean" }, + { "type": "number" }, + { "type": "string" }, + { "items": { "type": "string" }, "type": "array" } + ] + }, + "description": { "minLength": 1, "type": "string" }, + "max": { "type": "number" }, + "min": { "type": "number" }, + "multiple": { "type": "boolean" }, + "required": { "type": "boolean" }, + "sensitive": { "type": "boolean" }, + "title": { "minLength": 1, "type": "string" }, + "type": { "enum": ["boolean", "directory", "file", "number", "string"] } + }, + "required": ["type", "title", "description"], + "type": "object" + } + }, "additionalProperties": false, "properties": { + "$schema": { "format": "uri", "pattern": "^https?://", "type": "string" }, + "allowCrossMarketplaceDependenciesOn": { + "items": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, "description": { "minLength": 1, "type": "string" }, "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, - "owner": { + "metadata": { "additionalProperties": false, - "properties": { "name": { "minLength": 1, "type": "string" } }, - "required": ["name"], + "properties": { + "description": { "minLength": 1, "type": "string" }, + "pluginRoot": { + "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))\\./.+", + "type": "string" + }, + "version": { "minLength": 1, "type": "string" } + }, "type": "object" }, + "owner": { "$ref": "#/$defs/contact" }, "plugins": { "items": { + "allOf": [ + { + "if": { "required": ["headersHelper"] }, + "then": { + "properties": { "strict": { "const": false } }, + "required": ["strict"] + } + } + ], "additionalProperties": false, "properties": { + "$schema": { "format": "uri", "type": "string" }, + "agents": { "$ref": "#/$defs/componentPath" }, + "author": { "$ref": "#/$defs/contact" }, + "category": { "minLength": 1, "type": "string" }, + "channels": { + "items": { "$ref": "#/$defs/channel" }, + "minItems": 1, + "type": "array" + }, + "commands": { "$ref": "#/$defs/componentPath" }, + "defaultEnabled": { "type": "boolean" }, + "dependencies": { + "items": { + "oneOf": [ + { "minLength": 1, "type": "string" }, + { "$ref": "#/$defs/dependency" } + ] + }, + "minItems": 1, + "type": "array" + }, "description": { "minLength": 1, "type": "string" }, + "displayName": { "minLength": 1, "type": "string" }, + "experimental": { "$ref": "#/$defs/freeformObject" }, + "headers": { + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^.+$": { "minLength": 1, "type": "string" } + }, + "type": "object" + }, + "headersHelper": { "minLength": 1, "type": "string" }, + "homepage": { "format": "uri", "pattern": "^https?://", "type": "string" }, + "hooks": { + "anyOf": [ + { "pattern": "^\\./", "type": "string" }, + { "$ref": "#/$defs/freeformObject" } + ] + }, + "keywords": { "$ref": "#/$defs/nonemptyStrings" }, + "license": { "minLength": 1, "type": "string" }, + "lspServers": { + "anyOf": [ + { "pattern": "^\\./", "type": "string" }, + { + "items": { "pattern": "^\\./", "type": "string" }, + "minItems": 1, + "type": "array" + }, + { "$ref": "#/$defs/freeformObject" } + ] + }, + "mcpServers": { + "anyOf": [ + { "pattern": "^\\./", "type": "string" }, + { "$ref": "#/$defs/freeformObject" } + ] + }, + "metadata": { "$ref": "#/$defs/freeformObject" }, "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, - "source": { "pattern": "^\\./", "type": "string" }, - "version": { "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", "type": "string" } + "outputStyles": { "$ref": "#/$defs/componentPath" }, + "relevance": { "$ref": "#/$defs/relevance" }, + "repository": { "format": "uri", "pattern": "^https?://", "type": "string" }, + "settings": { "$ref": "#/$defs/freeformObject" }, + "skills": { "$ref": "#/$defs/componentPath" }, + "source": { + "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))\\./", + "type": "string" + }, + "strict": { "type": "boolean" }, + "tags": { "$ref": "#/$defs/nonemptyStrings" }, + "userConfig": { + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { "$ref": "#/$defs/userConfigOption" } + }, + "type": "object" + }, + "version": { "minLength": 1, "type": "string" }, + "workflows": { "$ref": "#/$defs/componentPath" } }, - "required": ["name", "source", "description", "version"], + "required": ["name", "source"], "type": "object" }, "minItems": 1, "type": "array" - } + }, + "renames": { + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^[a-z0-9]+(?:-[a-z0-9]+)*$": { + "oneOf": [ + { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + { "type": "null" } + ] + } + }, + "type": "object" + }, + "version": { "minLength": 1, "type": "string" } }, - "required": ["name", "description", "owner", "plugins"], + "required": ["name", "owner", "plugins"], "type": "object" } diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index eb500ed75..c99cb416d 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -187,6 +187,8 @@ export interface StandardPluginArtifactsInput { readonly isSelected: (targets: readonly string[]) => boolean; readonly marketplace?: Record; readonly marketplaceRelativePath: string; + /** Additional authored inputs that select fields in the marketplace document. */ + readonly marketplaceSourceInputs?: readonly string[]; readonly marketplaceValid: boolean; readonly mcp?: Record; /** Artifact-relative path for the MCP document; defaults to the plugin-root `.mcp.json` convention. */ @@ -283,7 +285,11 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): content: `${stableJson(marketplace)}\n`, kind: 'write', relativePath: marketplaceRelativePath, - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), + sourceInputs: sourceInputs( + model.metadata.provenance.sourcePath, + ...targetSourceInputs, + ...(input.marketplaceSourceInputs ?? []), + ), }); } for (const skill of input.sharedCopyEntries === false ? [] : model.skills) { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 5d1a23257..bb8c29d50 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -344,6 +344,31 @@ it('records dated unavailable Claude distribution and policy capability rows', ( ]); }); +it.each([ + ['marketplaceManifest', 'completed marketplace manifest'], + ['allowCrossMarketplaceDependenciesOn', 'cross-marketplace dependency allowlist'], +] as const)('reports Claude %s support and honest unavailable composite coverage', (capability, reason) => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities[capability]).toMatchObject({ + reason: expect.stringContaining(reason), + state: 'unavailable', + }); + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities[capability]).toBeUndefined(); + expect(registry.supports(target, capability)).toBe(false); + } + expect(registry.supports('claude', capability)).toBe(true); + expect(registry.supports('plugin', capability)).toBe(false); +}); + it.each([ ['manifestMetadata', 'manifest metadata fields'], ['manifestPaths', 'custom manifest path rules'], diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 3f1f61845..da9067844 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -93,7 +93,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.14.0', + adapterRevision: '1.15.0', observedVersion: '2.1.250', schemas: [ { @@ -109,7 +109,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'marketplace', revision: '2.1.250', - sha256: '5a08f241f9e856bb59489a265d9bf4db9c905e874d720f46def59fdb6f3ca257', + sha256: '2d01f4965ed671d91e8bd2c26fff6f30d49373f4daf51465a98fc46179600cab', }, { name: 'mcp', @@ -164,7 +164,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.13.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.14.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { @@ -185,7 +185,7 @@ it('records observed capability versions and rehashes schema snapshots against p const capabilityTable = JSON.parse(capability.toString()) as Record; expect((capabilityTable.mcp as Record).sse).toBeUndefined(); const provenance = JSON.parse(provenanceText) as { - readonly schemas: Record; + readonly schemas: Record; readonly [key: string]: unknown; }; @@ -200,6 +200,7 @@ it('records observed capability versions and rehashes schema snapshots against p const content = await readFile(new URL(`../src/adapters/schemas/${target}/${fileName}`, import.meta.url)); expect(schema.sha256).toBe(sha256Hex(content)); expect(schema.sha256).toBe(provenance.schemas[fileName]?.sha256); + expect(content.byteLength).toBe(provenance.schemas[fileName]?.bytes); expect(schema.revision).toBe(metadata.observedVersion); } diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index e8d98daad..96f272148 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1886,6 +1886,70 @@ it('validates Claude plugin artifacts carrying the pinned userConfig contract', } }); +it('validates an enriched Claude marketplace against the full closed pinned contract', async () => { + const registry = createDefaultRegistry(); + const target = targetFromRegistry(registry, 'claude'); + const marketplacePath = 'claude/.claude-plugin/marketplace.json'; + const pluginPath = 'claude/.claude-plugin/plugin.json'; + const plugin = { + author: { name: 'Agent Bundle' }, + description: 'Claude marketplace artifact fixture.', + name: 'claude-marketplace-artifact', + version: '1.0.0', + }; + const marketplace = { + allowCrossMarketplaceDependenciesOn: ['acme-shared'], + name: 'claude-marketplace-artifacts', + owner: { + email: 'plugins@example.test', + name: 'Agent Bundle', + url: 'https://example.test/plugins', + }, + plugins: [{ + metadata: { catalogId: 'claude-marketplace-artifact' }, + name: 'claude-marketplace-artifact', + relevance: { signals: { hosts: ['api.example.test'] } }, + source: './', + strict: true, + }], + renames: { 'legacy-marketplace-artifact': 'claude-marketplace-artifact' }, + }; + const validFiles = [ + { contents: '# Install claude-marketplace-artifact\n', kind: 'generated' as const, path: 'claude/INSTALL.md' }, + { contents: `${JSON.stringify(marketplace)}\n`, kind: 'generated' as const, path: marketplacePath }, + { contents: `${JSON.stringify(plugin)}\n`, kind: 'generated' as const, path: pluginPath }, + ]; + const root = await writeArtifact(validFiles, true, [target]); + + try { + expect(await validateArtifact({ artifactRoot: root, registry })).toEqual([]); + + const invalidFiles = [ + validFiles[0]!, + { + contents: `${JSON.stringify({ + ...marketplace, + plugins: [{ ...marketplace.plugins[0], source: { repo: 'acme/plugin', source: 'github' } }], + })}\n`, + kind: 'generated' as const, + path: marketplacePath, + }, + validFiles[2]!, + ]; + await writeFile(join(root, marketplacePath), invalidFiles[1]!.contents); + await writeFile( + join(root, 'agent-bundle.manifest.json'), + assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + ); + + expect(await validateArtifact({ artifactRoot: root, registry })).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6012', generatedPath: marketplacePath, target: 'claude' }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('validates a canonically rehashed Codex marketplace at its emitted path', async () => { const registry = createDefaultRegistry(); const target = targetFromRegistry(registry, 'codex'); diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 15365fdee..fa2bf541e 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -108,6 +108,19 @@ const withClaudeDependencies = (dependencies: unknown): NormalizedPlugin => ({ }, }); +const withClaudeMarketplace = (marketplace: unknown): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: { marketplace }, + }, + }, +}); + const withClaudeManifestMetadata = ( manifestMetadata: Readonly>, ): NormalizedPlugin => ({ @@ -273,6 +286,98 @@ nativeIt('accepts the emitted Claude marketplace under strict native validation' } }); +nativeIt('accepts the enriched Claude marketplace under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-enriched-')); + + try { + await writeClaudeArtifact(root, withClaudeMarketplace({ + allowCrossMarketplaceDependenciesOn: ['acme-shared'], + metadata: { pluginRoot: './plugins' }, + owner: { + email: 'plugins@example.test', + url: 'https://example.test/plugins', + }, + plugin: { + author: { + email: 'review-tools@example.test', + name: 'Review Tools Team', + url: 'https://example.test/review-tools', + }, + category: 'Developer Tools', + defaultEnabled: false, + displayName: 'Review Tools', + metadata: { catalogId: 'review-tools' }, + relevance: { + signals: { + cli: ['git'], + hosts: ['api.example.test'], + }, + topic: 'Code review', + }, + strict: true, + tags: ['review'], + }, + renames: { 'legacy-review-tools': 'review-tools' }, + version: '2', + })); + const validation = await runClaudeValidation( + root, + join(root, '.claude-plugin', 'marketplace.json'), + ); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records whether strict native validation enforces marketplace allowlist entry names', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-allowlist-invalid-')); + + try { + await writeClaudeArtifact(root, model); + const marketplacePath = join(root, '.claude-plugin', 'marketplace.json'); + const marketplace = JSON.parse(await readFile(marketplacePath, 'utf8')) as Record; + marketplace['allowCrossMarketplaceDependenciesOn'] = ['']; + await writeFile(marketplacePath, `${JSON.stringify(marketplace)}\n`); + const validation = await runClaudeValidation(root, marketplacePath); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + expect(validation.output).not.toContain('allowCrossMarketplaceDependenciesOn'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records that strict native validation rejects archive authentication on a relative source', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-relative-auth-')); + + try { + await writeClaudeArtifact(root, model); + const marketplacePath = join(root, '.claude-plugin', 'marketplace.json'); + const marketplace = JSON.parse(await readFile(marketplacePath, 'utf8')) as { + plugins: Record[]; + }; + marketplace.plugins[0]!['headers'] = { Authorization: 'Bearer test-token' }; + marketplace.plugins[0]!['headersHelper'] = 'printf \'{}\''; + marketplace.plugins[0]!['strict'] = false; + await writeFile(marketplacePath, `${JSON.stringify(marketplace)}\n`); + const validation = await runClaudeValidation(root, marketplacePath); + + // The fields are documented only for archive downloads. The host reports + // their inapplicability as a warning, and --strict promotes that warning + // to failure; URL-capable sources remain a separate follow-up. + expect(validation.code).not.toBe(0); + expect(validation.output).toContain('headersHelper'); + expect(validation.output).toContain('only apply to "archive" sources'); + expect(validation.output).toContain('--strict treats warnings as errors'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + nativeIt('accepts an emitted Claude artifact whose plugin root carries settings.json', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-settings-')); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index d95541700..d91f2d804 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -241,6 +241,22 @@ const withClaudeManifestMetadata = ( }, }); +const withClaudeMarketplace = ( + model: NormalizedPlugin, + marketplace: unknown, +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/marketplace.config.ts' }, + target: 'claude', + value: { marketplace }, + }, + }, +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -506,6 +522,226 @@ it('pins the closed Claude manifest metadata schema while keeping metadata free- } }); +it('enriches the generated Claude marketplace with the complete authored catalog overlay', async () => { + const model = withClaudeMarketplace(plugin, { + $schema: 'https://example.test/claude-marketplace.schema.json', + allowCrossMarketplaceDependenciesOn: ['acme-shared'], + description: 'Acme review plugins.', + metadata: { + description: 'Legacy catalog description.', + pluginRoot: './plugins', + version: 'catalog-v2', + }, + name: 'acme-review-tools', + owner: { + email: 'plugins@example.test', + name: 'Acme Developer Experience', + url: 'https://example.test/developer-experience', + }, + plugin: { + author: { + email: 'review-tools@example.test', + name: 'Review Tools Team', + url: 'https://example.test/review-tools', + }, + category: 'Developer Tools', + defaultEnabled: false, + description: 'Acme-specific code review automation.', + displayName: 'Acme Review Tools', + homepage: 'https://example.test/review-tools', + keywords: ['review', 'security'], + license: 'MIT', + metadata: { catalogId: 'review-tools', tier: 'team' }, + relevance: { + signals: { + cli: ['git'], + cwd: ['packages/review'], + filesRead: ['**/*.ts'], + hosts: ['api.example.test'], + manifestDeps: [{ + file: '[/\\\\]package\\.json$', + pattern: '"agent-bundle"\\s*:', + }], + }, + topic: 'Code review', + }, + repository: 'https://github.com/acme/review-tools', + strict: false, + tags: ['code-quality'], + version: 'catalog-v2', + }, + renames: { + 'legacy-review-tools': 'review-tools', + 'removed-review-tools': null, + }, + version: '2', + }); + const plan = createDefaultRegistry().get('claude').plan(model); + const marketplace = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/marketplace.json'); + + expect(plan.diagnostics).toEqual([]); + expect(marketplace).toMatchObject({ + kind: 'write', + sourceInputs: ['/workspace/agent-bundle.config.ts', '/workspace/marketplace.config.ts'], + }); + if (marketplace?.kind !== 'write') throw new Error('Expected an emitted Claude marketplace manifest.'); + expect(JSON.parse(marketplace.content)).toEqual({ + $schema: 'https://example.test/claude-marketplace.schema.json', + allowCrossMarketplaceDependenciesOn: ['acme-shared'], + description: 'Acme review plugins.', + metadata: { + description: 'Legacy catalog description.', + pluginRoot: './plugins', + version: 'catalog-v2', + }, + name: 'acme-review-tools', + owner: { + email: 'plugins@example.test', + name: 'Acme Developer Experience', + url: 'https://example.test/developer-experience', + }, + plugins: [{ + author: { + email: 'review-tools@example.test', + name: 'Review Tools Team', + url: 'https://example.test/review-tools', + }, + category: 'Developer Tools', + defaultEnabled: false, + description: 'Acme-specific code review automation.', + displayName: 'Acme Review Tools', + homepage: 'https://example.test/review-tools', + keywords: ['review', 'security'], + license: 'MIT', + metadata: { catalogId: 'review-tools', tier: 'team' }, + name: 'review-tools', + relevance: { + signals: { + cli: ['git'], + cwd: ['packages/review'], + filesRead: ['**/*.ts'], + hosts: ['api.example.test'], + manifestDeps: [{ + file: '[/\\\\]package\\.json$', + pattern: '"agent-bundle"\\s*:', + }], + }, + topic: 'Code review', + }, + repository: 'https://github.com/acme/review-tools', + source: './', + strict: false, + tags: ['code-quality'], + version: 'catalog-v2', + }], + renames: { + 'legacy-review-tools': 'review-tools', + 'removed-review-tools': null, + }, + version: '2', + }); + await validateDocuments('claude', writeContents(model, 'claude')); +}); + +it.each([ + { + code: 'claude.marketplace.declaration.invalid', + marketplace: [], + }, + { + code: 'claude.marketplace.field.unknown', + marketplace: { unknown: true }, + }, + { + code: 'claude.marketplace.name.invalid', + marketplace: { name: 'Not Valid' }, + }, + { + code: 'claude.marketplace.name.reserved', + marketplace: { name: 'claude-plugins-official' }, + }, + { + code: 'claude.marketplace.owner.invalid', + marketplace: { owner: [] }, + }, + { + code: 'claude.marketplace.owner.email.invalid', + marketplace: { owner: { email: 'not-an-email' } }, + }, + { + code: 'claude.marketplace.allowCrossMarketplaceDependenciesOn.invalid', + marketplace: { allowCrossMarketplaceDependenciesOn: [] }, + }, + { + code: 'claude.marketplace.renames.invalid', + marketplace: { renames: { 'Legacy Plugin': 'review-tools' } }, + }, + { + code: 'claude.marketplace.plugin.field.unknown', + marketplace: { plugin: { source: { source: 'github', repo: 'acme/review-tools' } } }, + }, + { + code: 'claude.marketplace.plugin.headers.inapplicable', + marketplace: { plugin: { headers: { Authorization: 'Bearer catalog-token' } } }, + }, + { + code: 'claude.marketplace.plugin.headersHelper.inapplicable', + marketplace: { plugin: { headersHelper: './scripts/headers.sh', strict: false } }, + }, + { + code: 'claude.marketplace.plugin.relevance.invalid', + marketplace: { plugin: { relevance: { signals: {} } } }, + }, + { + code: 'claude.marketplace.plugin.relevance.hosts.invalid', + marketplace: { plugin: { relevance: { signals: { hosts: ['https://api.example.test/path'] } } } }, + }, +])('rejects malformed authored Claude marketplace input with $code', ({ code, marketplace }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeMarketplace(plugin, marketplace)); + const document = writeContents(withClaudeMarketplace(plugin, marketplace), 'claude'); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + recovery: expect.stringMatching(/then rebuild\.$/u), + severity: 'error', + target: 'claude', + })); + expect(document['.claude-plugin/marketplace.json']).toBeUndefined(); +}); + +it('pins the full closed Claude marketplace schema while retaining relative sources', async () => { + const schema = (await import('../src/adapters/schemas/claude/marketplace.schema.json', { + with: { type: 'json' }, + })).default; + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validate = validator.compile(schema); + const manifest = { + name: 'review-tools-marketplace', + owner: { email: 'plugins@example.test', name: 'Review Tools', url: 'https://example.test' }, + plugins: [{ + headersHelper: './scripts/headers.sh', + name: 'review-tools', + relevance: { signals: { hosts: ['api.example.test'] }, topic: 'Review' }, + source: './', + strict: false, + }], + }; + + expect(validate(manifest), JSON.stringify(validate.errors)).toBe(true); + for (const invalid of [ + { ...manifest, unknown: true }, + { ...manifest, owner: { ...manifest.owner, unknown: true } }, + { ...manifest, plugins: [{ ...manifest.plugins[0], unknown: true }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: 'review-tools' }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: './../outside' }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'github', repo: 'acme/review-tools' } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], relevance: { signals: { unknown: ['value'] } } }] }, + ]) { + expect(validate(invalid)).toBe(false); + } +}); + it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 39be717ae..d5d71145c 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -169,6 +169,52 @@ it('lays both host manifests over one shared bundle root', () => { }); }); +it('keeps the Claude marketplace overlay host-specific in the unified bundle', () => { + const model: NormalizedPlugin = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: configPath }, + target: 'claude', + value: { + marketplace: { + allowCrossMarketplaceDependenciesOn: ['acme-shared'], + owner: { email: 'plugins@example.test' }, + plugin: { + relevance: { signals: { hosts: ['api.example.test'] } }, + strict: true, + }, + renames: { 'legacy-bundle-example': 'bundle-example' }, + }, + }, + }, + }, + }; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['.claude-plugin/marketplace.json']!)).toMatchObject({ + allowCrossMarketplaceDependenciesOn: ['acme-shared'], + owner: { email: 'plugins@example.test', name: 'bundle-example' }, + plugins: [{ + name: 'bundle-example', + relevance: { signals: { hosts: ['api.example.test'] } }, + source: './', + strict: true, + }], + renames: { 'legacy-bundle-example': 'bundle-example' }, + }); + expect(JSON.parse(documents['.cursor-plugin/marketplace.json']!)).not.toHaveProperty( + 'allowCrossMarketplaceDependenciesOn', + ); + expect(JSON.parse(documents['.agents/plugins/marketplace.json']!)).not.toHaveProperty( + 'allowCrossMarketplaceDependenciesOn', + ); +}); + it('emits Cursor logo and omits it from Claude and Codex manifests', () => { const model: NormalizedPlugin = { ...bundleModel,