diff --git a/.changeset/claude-manifest-metadata-paths.md b/.changeset/claude-manifest-metadata-paths.md new file mode 100644 index 000000000..1c8503b15 --- /dev/null +++ b/.changeset/claude-manifest-metadata-paths.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Emit validated Claude Code `displayName`, `metadata`, and `defaultEnabled` manifest fields, and pin the documented custom component-path discovery rules as capability evidence. 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 e525e705a..46b61a191 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 @@ -103,8 +103,54 @@ "transports": ["socket", "stdio"], "vendorsServerBinary": false }, + "metadata": { + "defaultEnabled": { + "default": true, + "dependencyRequirementPrecedence": true, + "field": true, + "marketplaceEntryPrecedence": true, + "persistedEnabledPluginsPrecedence": true + }, + "displayName": { + "fallback": "name", + "field": true + }, + "freeform": { + "field": true, + "hostReadsValues": false, + "nonObjectHandling": "warning-and-ignore", + "strictValidation": "failure" + }, + "standardFields": [ + "$schema", + "author", + "description", + "homepage", + "keywords", + "license", + "repository", + "version" + ] + }, "manifest": ".claude-plugin/plugin.json", "marketplace": ".claude-plugin/marketplace.json", + "paths": { + "addsToDefault": ["skills"], + "fieldTypes": ["array", "string"], + "ignoredDefaultFolderWarning": true, + "marketplaceRootSkillsSubdirectoriesReplaceDefault": true, + "relativePrefix": "./", + "replacesDefault": [ + "agents", + "commands", + "experimental.monitors", + "experimental.themes", + "outputStyles", + "workflows" + ], + "skillsRootException": ".", + "skillsRootExceptionSince": "2.1.221" + }, "settings": { "config": "settings.json", "placeholderSubstitution": false, @@ -173,7 +219,14 @@ "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies requires cross-marketplace targets in `allowCrossMarketplaceDependenciesOn` on the root marketplace; only the root allowlist is consulted, trust does not chain, and users may manually install a blocked dependency first.", "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-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-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.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents custom component path fields as string or array values: commands, agents, workflows, outputStyles, experimental.themes, and experimental.monitors replace their default scans, while skills adds to the default scan. Keeping a replaced default requires listing it explicitly, for example `\"commands\": [\"./commands/\", \"./extras/\"]`.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference requires component paths to be relative to the plugin root and start with `./`, except skills also accepts `.` starting in v2.1.221; before that version `.` failed manifest validation. A marketplace-root source that declares specific skills subdirectories replaces the default skills scan.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that a default folder shadowed by a replacing manifest path still allows the plugin to load but warns in `claude plugin list` and the `/plugin` detail view. Its file-locations table defines commands/ as flat Markdown Skill files and recommends skills/ for new plugins; Agent Bundle already emits flat `.md` commands in the canonical commands/ directory and deliberately leaves custom-path discovery to the host.", + "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 containing displayName, object metadata, and defaultEnabled, and separately accepts `\"commands\": \"./custom/deploy.md\"` when that flat Markdown file exists and no default commands/ directory exists (host-adapters.native.test.ts). These positive probes establish acceptance; they do not claim that the CLI checks custom-path existence or contents." ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 285962436..7288f672f 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -184,13 +184,19 @@ export interface ClaudeDependencyConfig { export interface ClaudeHostConfig extends AgentBundleHostConfig { /** Project-authored directory copied to the plugin-root `bin/` executable convention. */ readonly bin?: string; + /** Whether a newly installed plugin starts enabled when no stronger host state exists. */ + readonly defaultEnabled?: boolean; /** * Plugins Claude Code resolves and auto-installs. A bare name uses the * declaring plugin's marketplace; the object form adds a semver range or an * explicitly allowlisted cross-marketplace source. */ readonly dependencies?: readonly (string | ClaudeDependencyConfig)[]; + /** Human-readable plugin name shown in Claude Code UI surfaces. */ + readonly displayName?: string; readonly lspServers?: Readonly>; + /** Free-form catalog or entitlement data that Claude Code preserves but does not interpret. */ + readonly metadata?: Readonly>; readonly settings?: ClaudeSettingsConfig; /** Enable-time options copied into `.claude-plugin/plugin.json`. */ readonly userConfig?: Readonly>; @@ -247,7 +253,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -968,6 +974,77 @@ const planClaudeUserConfig = (model: NormalizedPlugin): ClaudeUserConfigPlan => }; }; +interface ClaudeManifestMetadataPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Readonly>; + readonly sourceInputs: readonly string[]; +} + +const noManifestMetadataPlan: ClaudeManifestMetadataPlan = deepFreeze({ + diagnostics: [], + sourceInputs: [], +}); + +const manifestMetadataDiagnostic = ( + code: string, + message: string, + recovery: string, +): Diagnostic => ({ + ...errorDiagnostic(code, message), + recovery, +}); + +/** + * Validates Claude-only manifest metadata fields from the normalized + * extension envelope. Normalization already guarantees strict finite JSON; + * this boundary additionally requires metadata's top level to be a plain + * object because Claude Code only warns and ignores arrays or null. + */ +const planClaudeManifestMetadata = (model: NormalizedPlugin): ClaudeManifestMetadataPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noManifestMetadataPlan; + const displayName = extension.value['displayName']; + const metadataValue = extension.value['metadata']; + const defaultEnabled = extension.value['defaultEnabled']; + if (displayName === undefined && metadataValue === undefined && defaultEnabled === undefined) { + return noManifestMetadataPlan; + } + + const diagnostics: Diagnostic[] = []; + if (displayName !== undefined && (typeof displayName !== 'string' || displayName.trim().length === 0)) { + diagnostics.push(manifestMetadataDiagnostic( + 'claude.manifest.displayName.invalid', + 'Claude displayName must be a nonempty string after trimming whitespace.', + 'Set claude.displayName to the human-readable plugin name shown in Claude Code, or remove it to fall back to plugin.name.', + )); + } + if (metadataValue !== undefined && !isPlainDataRecord(metadataValue)) { + diagnostics.push(manifestMetadataDiagnostic( + 'claude.manifest.metadata.invalid', + 'Claude metadata must be a plain JSON object; arrays and null are not accepted.', + 'Set claude.metadata to a plain JSON-serializable object, or remove it.', + )); + } + if (defaultEnabled !== undefined && typeof defaultEnabled !== 'boolean') { + diagnostics.push(manifestMetadataDiagnostic( + 'claude.manifest.defaultEnabled.invalid', + 'Claude defaultEnabled must be a boolean.', + 'Set claude.defaultEnabled to true or false, or remove it to use Claude Code\'s default of true.', + )); + } + const inputs = sourceInputs(extension.provenance.sourcePath); + if (diagnostics.length > 0) return { diagnostics, sourceInputs: inputs }; + return { + diagnostics, + document: Object.freeze({ + ...(defaultEnabled === undefined ? {} : { defaultEnabled }), + ...(displayName === undefined ? {} : { displayName }), + ...(metadataValue === undefined ? {} : { metadata: metadataValue }), + }), + sourceInputs: inputs, + }; +}; + interface ClaudeBinPlan { readonly diagnostics: readonly Diagnostic[]; readonly entries: readonly TargetArtifactCopy[]; @@ -1226,6 +1303,8 @@ export const planClaudeArtifacts = ( diagnostics.push(...lsp.diagnostics); const userConfig = planClaudeUserConfig(model); diagnostics.push(...userConfig.diagnostics); + const manifestMetadata = planClaudeManifestMetadata(model); + diagnostics.push(...manifestMetadata.diagnostics); const bin = planClaudeBin(model, targetName); diagnostics.push(...bin.diagnostics); const settings = planClaudeSettings(model); @@ -1244,6 +1323,7 @@ export const planClaudeArtifacts = ( const plugin = { author: { name: model.metadata.name }, + ...manifestMetadata.document, ...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }), description: model.metadata.description ?? model.metadata.name, ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), @@ -1284,7 +1364,11 @@ export const planClaudeArtifacts = ( } const basePlan = standardPluginArtifactPlan({ - additionalPluginSourceInputs: sourceInputs(...userConfig.sourceInputs, ...dependencies.sourceInputs), + additionalPluginSourceInputs: sourceInputs( + ...userConfig.sourceInputs, + ...manifestMetadata.sourceInputs, + ...dependencies.sourceInputs, + ), diagnostics, ...(hostDocuments.length === 0 ? {} : { hostDocuments }), hookDocument, @@ -1357,6 +1441,25 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not document the plugin-root .lsp.json LSP surface.', ), + manifestMetadata: capabilityStateFromSupport( + capabilityTable.plugin.metadata.defaultEnabled.default && + capabilityTable.plugin.metadata.defaultEnabled.field && + capabilityTable.plugin.metadata.displayName.fallback === 'name' && + capabilityTable.plugin.metadata.displayName.field && + capabilityTable.plugin.metadata.freeform.field && + capabilityTable.plugin.metadata.freeform.hostReadsValues === false, + evidence, + 'The pinned Claude plugin contract does not document displayName, metadata, and defaultEnabled manifest fields.', + ), + manifestPaths: capabilityStateFromSupport( + capabilityTable.plugin.paths.addsToDefault.includes('skills') && + capabilityTable.plugin.paths.replacesDefault.includes('commands') && + capabilityTable.plugin.paths.relativePrefix === './' && + capabilityTable.plugin.paths.skillsRootException === '.' && + capabilityTable.plugin.paths.skillsRootExceptionSince === '2.1.221', + evidence, + 'The pinned Claude plugin contract does not document custom component path fields and their replace-versus-add rules.', + ), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index f6c7800bb..a0791ff0c 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -184,7 +184,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.8.0', + adapterRevision: '1.9.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 @@ -594,6 +594,18 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ // Claude supports LSP and Codex has no LSP surface, so this intersection is // honestly unavailable even though the Claude half still emits `.lsp.json`. lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), + manifestMetadata: intersectCapabilityStates( + claudeAdapter.capabilities.manifestMetadata!, + unavailableCapability( + 'The pinned Codex and Cursor plugin contracts do not share Claude manifest metadata fields; displayName, metadata, and defaultEnabled reach Claude Code only.', + ), + ), + manifestPaths: intersectCapabilityStates( + claudeAdapter.capabilities.manifestPaths!, + unavailableCapability( + 'The unified bundle emits canonical default component directories and the pinned Codex and Cursor contracts do not share Claude custom manifest path rules.', + ), + ), mcp: intersectCapabilityStates( intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), cursorAdapter.capabilities.mcp!, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 24670088e..f9be12b9b 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -2,8 +2,7 @@ "observedCliVersion": "2.1.250", "retrievedAt": "2026-09-01", "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.", - + "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, and settings.json layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -26,8 +25,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/mcp" }, "plugin.schema.json": { - "bytes": 5581, - "sha256": "9d69367331f484a8907d8e883f62da4d7b51c64fd843e9f98f2b0b48bf1ef319", + "bytes": 5721, + "sha256": "cd044b6fcf43f1e2f590059b758759127c0fb86c5cad5fbe80965be896350257", "url": "https://code.claude.com/docs/en/plugins-reference" }, "settings.schema.json": { diff --git a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json index e6fa25931..56d540fe3 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json @@ -115,6 +115,7 @@ "required": ["name"], "type": "object" }, + "defaultEnabled": { "type": "boolean" }, "description": { "minLength": 1, "type": "string" }, "dependencies": { "items": { @@ -126,6 +127,7 @@ "minItems": 1, "type": "array" }, + "displayName": { "minLength": 1, "type": "string" }, "hooks": { "const": "./hooks/hooks.json", "type": "string" }, "lspServers": { "anyOf": [ @@ -139,6 +141,7 @@ } ] }, + "metadata": { "type": "object" }, "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, "userConfig": { "additionalProperties": { "$ref": "#/$defs/userConfigOption" }, diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 353ae6cba..ebb9ce932 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -197,6 +197,31 @@ it('reports Claude dependency support and honest unavailable composite coverage' expect(registry.supports('plugin', 'dependencies')).toBe(false); }); +it.each([ + ['manifestMetadata', 'manifest metadata fields'], + ['manifestPaths', 'custom manifest path rules'], +] as const)('reports Claude %s support without inventing shared 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('intersects supported composite capabilities and merges both evidence records', () => { const intersection = intersectCapabilityStates( supportedCapability(evidence('claude')), diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 66c64a788..134f12b72 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.9.0', + adapterRevision: '1.10.0', observedVersion: '2.1.250', schemas: [ { @@ -119,7 +119,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '2.1.250', - sha256: '9d69367331f484a8907d8e883f62da4d7b51c64fd843e9f98f2b0b48bf1ef319', + sha256: 'cd044b6fcf43f1e2f590059b758759127c0fb86c5cad5fbe80965be896350257', }, { name: 'settings', @@ -154,7 +154,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.8.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.9.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 3eb9cd97c..f2de1073b 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -83,6 +83,21 @@ const withClaudeDependencies = (dependencies: unknown): NormalizedPlugin => ({ }, }); +const withClaudeManifestMetadata = ( + manifestMetadata: Readonly>, +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: manifestMetadata, + }, + }, +}); + const writeClaudeArtifact = async ( root: string, planned: NormalizedPlugin, @@ -273,3 +288,52 @@ nativeIt('accepts emitted Claude plugin dependencies under strict native validat await rm(root, { force: true, recursive: true }); } }); + +nativeIt('accepts emitted Claude manifest metadata fields under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-manifest-metadata-')); + + try { + await writeClaudeArtifact(root, withClaudeManifestMetadata({ + defaultEnabled: false, + displayName: 'Review Tools', + metadata: { catalog: 'security', entitlement: { tier: 'team' } }, + })); + const manifest = JSON.parse( + await readFile(join(root, '.claude-plugin', 'plugin.json'), 'utf8'), + ) as Record; + expect(manifest).toMatchObject({ + defaultEnabled: false, + displayName: 'Review Tools', + metadata: { catalog: 'security', entitlement: { tier: 'team' } }, + }); + + const validation = await runClaudeValidation(root, root); + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('accepts a custom flat command path without a default commands directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-custom-command-path-')); + + try { + const written = await writeClaudeArtifact(root, model); + expect(written.some((path) => path.startsWith('commands/'))).toBe(false); + await mkdir(join(root, 'custom'), { recursive: true }); + await writeFile( + join(root, 'custom', 'deploy.md'), + '---\ndescription: Deploy the current project.\n---\nDeploy the current project.\n', + ); + const manifestPath = join(root, '.claude-plugin', 'plugin.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, commands: './custom/deploy.md' })}\n`); + + const validation = await runClaudeValidation(root, root); + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 7e09ea1ac..5559032ba 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -171,6 +171,22 @@ const withClaudeDependencies = ( }, }); +const withClaudeManifestMetadata = ( + model: NormalizedPlugin, + manifestMetadata: Readonly>, +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/manifest-metadata.config.ts' }, + target: 'claude', + value: manifestMetadata, + }, + }, +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -332,6 +348,110 @@ it('lowers Claude commands with documented kebab-case frontmatter and body-only expect(commandFree.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); }); +it('emits validated Claude manifest metadata fields with extension provenance', async () => { + const model = withClaudeManifestMetadata(plugin, { + defaultEnabled: false, + displayName: 'Review Tools', + metadata: { + catalog: 'security', + entitlement: { tier: 'team' }, + }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toEqual([]); + expect(manifest).toMatchObject({ + kind: 'write', + sourceInputs: [ + '/workspace/agent-bundle.config.ts', + '/workspace/skills/review/SKILL.md', + '/workspace/manifest-metadata.config.ts', + ], + }); + if (manifest?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); + expect(JSON.parse(manifest.content)).toMatchObject({ + defaultEnabled: false, + displayName: 'Review Tools', + metadata: { + catalog: 'security', + entitlement: { tier: 'team' }, + }, + }); + await validateDocuments('claude', writeContents(model, 'claude')); +}); + +it.each([ + { + code: 'claude.manifest.displayName.invalid', + declaration: { displayName: ' ' }, + label: 'a whitespace-only displayName', + }, + { + code: 'claude.manifest.metadata.invalid', + declaration: { metadata: null }, + label: 'null metadata', + }, + { + code: 'claude.manifest.metadata.invalid', + declaration: { metadata: [] }, + label: 'array metadata', + }, + { + code: 'claude.manifest.defaultEnabled.invalid', + declaration: { defaultEnabled: 'false' }, + label: 'a non-boolean defaultEnabled', + }, +])('rejects $label without emitting Claude manifest metadata fields', ({ code, declaration }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeManifestMetadata(plugin, declaration)); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + recovery: expect.any(String), + severity: 'error', + target: 'claude', + })); + if (manifest?.kind !== 'write') throw new Error('Expected the base Claude plugin manifest.'); + const document = JSON.parse(manifest.content) as Record; + expect(document).not.toHaveProperty('defaultEnabled'); + expect(document).not.toHaveProperty('displayName'); + expect(document).not.toHaveProperty('metadata'); +}); + +it('pins the closed Claude manifest metadata schema while keeping metadata free-form', async () => { + const schema = (await import('../src/adapters/schemas/claude/plugin.schema.json', { + with: { type: 'json' }, + })).default; + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validate = validator.compile(schema); + const manifest = { + author: { name: 'Agent Bundle' }, + description: 'Claude manifest metadata schema fixture.', + name: 'claude-metadata-fixture', + version: '1.0.0', + }; + + expect(validate({ + ...manifest, + defaultEnabled: false, + displayName: 'Claude Metadata Fixture', + metadata: { catalog: 'security', nested: { rank: 1 }, tags: ['review'] }, + }), JSON.stringify(validate.errors)).toBe(true); + for (const declaration of [ + { displayName: '' }, + { metadata: null }, + { metadata: [] }, + { defaultEnabled: 'false' }, + { homepage: 'https://example.test' }, + // Host-supported custom paths stay outside the generator-owned schema. + { commands: './custom/deploy.md' }, + ]) { + expect(validate({ ...manifest, ...declaration })).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 a0977ece5..799eebef4 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -310,6 +310,44 @@ it('emits Claude userConfig from the unified plugin target only into the Claude .toContain('/workspace/claude.config.ts'); }); +it('emits Claude manifest metadata from the unified target only into the Claude manifest', () => { + const model = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config' as const, sourcePath: '/workspace/claude-metadata.config.ts' }, + target: 'claude', + value: { + defaultEnabled: false, + displayName: 'Bundle Example', + metadata: { catalog: 'internal' }, + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).toMatchObject({ + defaultEnabled: false, + displayName: 'Bundle Example', + metadata: { catalog: 'internal' }, + }); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('defaultEnabled'); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('displayName'); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('metadata'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('defaultEnabled'); + // Cursor owns an independent generic displayName and must not inherit the + // Claude extension's human-readable value. + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toHaveProperty('displayName', 'bundle-example'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('metadata'); + expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) + .toContain('/workspace/claude-metadata.config.ts'); +}); + it('emits the Claude bin directory from the unified plugin target', () => { const model: NormalizedPlugin = { ...bundleModel,