From e3172ecde394a55efd2da776eea37b6d2a48d895 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 05:46:23 +0000 Subject: [PATCH] feat(claude): emit user config manifest options Validate Claude enable-time options before adding them to native and unified plugin manifests, with pinned schema and host evidence. --- .changeset/claude-plugin-user-config.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 22 +- packages/agent-bundle/src/adapters/claude.ts | 275 +++++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 5 +- .../adapters/schemas/claude/PROVENANCE.json | 8 +- .../schemas/claude/plugin.schema.json | 75 +++++ .../tests/adapter-capability-states.test.ts | 21 ++ .../tests/adapter-metadata.test.ts | 6 +- packages/agent-bundle/tests/api.test.ts | 39 +++ .../tests/artifact-validator.test.ts | 73 +++++ .../tests/host-adapters.native.test.ts | 65 +++++ .../agent-bundle/tests/host-adapters.test.ts | 208 +++++++++++++ .../agent-bundle/tests/plugin-bundle.test.ts | 42 +++ 13 files changed, 833 insertions(+), 11 deletions(-) create mode 100644 .changeset/claude-plugin-user-config.md diff --git a/.changeset/claude-plugin-user-config.md b/.changeset/claude-plugin-user-config.md new file mode 100644 index 000000000..cd76d568f --- /dev/null +++ b/.changeset/claude-plugin-user-config.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add validated Claude Code plugin `userConfig` declarations and emit them in Claude plugin manifests, including unified plugin bundles. 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 2274af98a..58e12973b 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 @@ -95,7 +95,19 @@ "supportedKeys": ["agent", "subagentStatusLine"], "unknownKeysIgnored": true }, - "skills": true + "skills": true, + "userConfig": { + "envVarPrefix": "CLAUDE_PLUGIN_OPTION_", + "installConfigFlag": true, + "optionalFields": ["default", "max", "min", "multiple", "required", "sensitive"], + "projectSettingsIgnored": true, + "requiredFields": ["type", "title", "description"], + "sensitiveStorage": true, + "settingsSources": ["managed", "--settings", "user"], + "shellSubstitutionRejected": ["hookShellCommands", "monitorCommands", "mcpHeadersHelper"], + "substitutionToken": "${user_config.KEY}", + "types": ["boolean", "directory", "file", "number", "string"] + } }, "tokens": { "pluginData": "${CLAUDE_PLUGIN_DATA}", @@ -131,7 +143,13 @@ "2026-09-01: \"Setting `agent` activates one of the plugin's custom agents as the main thread, applying its system prompt, tool restrictions, and model\"; the documented example value \"security-reviewer\" names an agent in the plugin's agents/ directory. The plugin agents/ component remains deferred by the #100 stage-2 G5 gate recorded in merged PR #220, so this compiler emits no agents/ tree: a declared `agent` resolves only when the author ships that agent by other means, such as the prebuilt payload surface, and the compiler emits the claude.settings.agent.deferred warning to keep the dangling-reference risk visible instead of implying an agents component exists.", "2026-09-01: https://code.claude.com/docs/en/statusline documents subagentStatusLine as a command object - {\"type\": \"command\", \"command\": \"~/.claude/subagent-statusline.sh\"} - whose command \"renders a custom row body for each subagent shown in the agent panel below the prompt\", and records the plugin policy: \"Plugins can ship a default `subagentStatusLine` in their `settings.json`, but unlike hooks, plugin values don't run under `allowManagedHooksOnly` even when the plugin is force-enabled in managed settings `enabledPlugins`.\" The pinned schema admits only the two fields the subagentStatusLine examples show; statusLine's optional `padding` is documented for the user status line alone and is deliberately not admitted.", "2026-09-01: The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") enumerates Skill and agent content, hook and monitor commands, MCP stdio and remote fields, and LSP servers - settings.json appears nowhere - so an Agent Bundle path token in a settings value is rejected (claude.settings.token.unsupported) rather than emitted as a placeholder Claude Code never resolves.", - "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 claude artifact whose plugin root carries settings.json with both supported keys, for both the marketplace-manifest and plugin-manifest validation modes, printing only the validated manifest path and \"Validation passed\". The same command also passes for a deliberately malformed settings.json (empty `agent`, a user-scope `statusLine` key, a stray `padding`) and never names the file, so the host validator does not inspect plugin settings.json at all: the compiler's claude.settings.* diagnostics are the only guard an author gets before the plugin is enabled (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 claude artifact whose plugin root carries settings.json with both supported keys, for both the marketplace-manifest and plugin-manifest validation modes, printing only the validated manifest path and \"Validation passed\". The same command also passes for a deliberately malformed settings.json (empty `agent`, a user-scope `statusLine` key, a stray `padding`) and never names the file, so the host validator does not inspect plugin settings.json at all: the compiler's claude.settings.* diagnostics are the only guard an author gets before the plugin is enabled (host-adapters.native.test.ts).", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents manifest userConfig as a nonempty option map whose keys are valid identifiers; each option requires type (string, number, boolean, directory, or file), title, and description, and may declare sensitive, required, default, multiple for string arrays, and min/max for numbers.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents `${user_config.KEY}` substitution in MCP and LSP server configs and hook commands, plus non-sensitive substitution in skill and agent content; every option is exported to hook processes as `CLAUDE_PLUGIN_OPTION_` with the key uppercased.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference rejects `${user_config.*}` in shell-form hook commands (use exec form with args or `CLAUDE_PLUGIN_OPTION_`), monitor commands (read a config file), and MCP `headersHelper` (read a config file); before Claude Code v2.1.207 those fields performed substitution.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference stores non-sensitive options under `pluginConfigs[].options` in user settings and sensitive options in macOS Keychain with credentials-file fallback, or `~/.claude/.credentials.json` without a supported keychain; Keychain storage is shared with OAuth tokens and has an approximately 2 KB total limit. pluginConfigs precedence is managed settings, then `--settings`, then user settings; project and local settings are ignored for pluginConfigs (but not enabledPlugins), while before v2.1.207 they were read.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents repeatable `claude plugin install --config key=value` for setting declared userConfig options.", + "2026-09-01: Claude Code 2.1.257 `claude plugin validate --strict` accepts an emitted plugin manifest declaring userConfig with a sensitive string option and a bounded number option." ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 27075129b..6877feab4 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -91,6 +91,33 @@ export interface ClaudeLspServerConfig { readonly workspaceFolder?: string; } +export type ClaudeUserConfigOptionType = 'boolean' | 'directory' | 'file' | 'number' | 'string'; + +/** + * One enable-time option declared in a Claude Code plugin manifest. + * + * Sensitive values are masked and stored in secure storage rather than + * settings.json. On macOS that means Keychain with credentials-file fallback; + * the Keychain is shared with OAuth tokens and has an approximately 2 KB total + * budget, so sensitive values must stay small. + * + * Do not place `${user_config.*}` in shell-form hook commands, monitor + * commands, or MCP `headersHelper`: Claude Code rejects those shell execution + * fields. Use exec-form hook args, `CLAUDE_PLUGIN_OPTION_`, or a config + * file as appropriate. + */ +export interface ClaudeUserConfigOption { + readonly default?: string | number | boolean | readonly string[]; + readonly description: string; + readonly max?: number; + readonly min?: number; + readonly multiple?: boolean; + readonly required?: boolean; + readonly sensitive?: boolean; + readonly title: string; + readonly type: ClaudeUserConfigOptionType; +} + /** * One Claude Code subagent status line: the command object documented for * `subagentStatusLine`, which renders a custom row body for each subagent in @@ -140,6 +167,8 @@ export interface ClaudeHostConfig extends AgentBundleHostConfig { readonly bin?: string; readonly lspServers?: Readonly>; readonly settings?: ClaudeSettingsConfig; + /** Enable-time options copied into `.claude-plugin/plugin.json`. */ + readonly userConfig?: Readonly>; } export interface ClaudeConfigExtension { @@ -193,7 +222,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.7.0', + adapterRevision: '1.8.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -351,6 +380,9 @@ const lspServerFields: ReadonlySet = new Set([ const isDataRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); +const isPlainDataRecord = (value: unknown): value is Readonly> => + isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value)); + const expandLspToken = (value: unknown): unknown => typeof value === 'string' ? expandClaudeToken(value) : value; @@ -484,6 +516,236 @@ export const planClaudeLsp = (model: NormalizedPlugin): ClaudeLspPlan => { return { diagnostics, ...(valid ? { document: servers } : {}), sourceInputs: inputs }; }; +const userConfigOptionFields: readonly (keyof ClaudeUserConfigOption)[] = Object.freeze([ + 'default', + 'description', + 'max', + 'min', + 'multiple', + 'required', + 'sensitive', + 'title', + 'type', +]); +const userConfigOptionFieldSet: ReadonlySet = new Set(userConfigOptionFields); +const userConfigOptionTypes: ReadonlySet = new Set([ + 'boolean', + 'directory', + 'file', + 'number', + 'string', +]); +const userConfigIdentifier = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +const isUserConfigOptionType = (value: unknown): value is ClaudeUserConfigOptionType => + typeof value === 'string' && userConfigOptionTypes.has(value); + +const userConfigDiagnostic = (code: string, message: string, recovery: string): Diagnostic => ({ + ...errorDiagnostic(code, message), + recovery, +}); + +interface ClaudeUserConfigOptionPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly value?: Record; +} + +/** + * Validates and allowlist-copies one option independently so the same closed + * declaration contract can be reused by a later channels.userConfig slice. + */ +const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserConfigOptionPlan => { + const diagnostics: Diagnostic[] = []; + if (!isPlainDataRecord(declared)) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.option.invalid', + `Claude userConfig option "${key}" must be an option declaration object.`, + `Replace userConfig.${key} with an object containing type, title, and description, then rebuild.`, + )); + return { diagnostics }; + } + + for (const field of Object.keys(declared).sort()) { + if (userConfigOptionFieldSet.has(field)) continue; + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.field.unknown', + `Claude userConfig option "${key}" declares unknown field "${field}".`, + `Remove userConfig.${key}.${field} or replace it with a documented option field, then rebuild.`, + )); + } + + const type = declared['type']; + if (!isUserConfigOptionType(type)) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.type.invalid', + `Claude userConfig option "${key}" requires type "string", "number", "boolean", "directory", or "file".`, + `Set userConfig.${key}.type to one of the five documented option types, then rebuild.`, + )); + } + for (const field of ['title', 'description'] as const) { + if (typeof declared[field] === 'string' && declared[field].length > 0) continue; + diagnostics.push(userConfigDiagnostic( + `claude.userConfig.${field}.required`, + `Claude userConfig option "${key}" requires a nonempty ${field}.`, + `Set userConfig.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`, + )); + } + for (const field of ['sensitive', 'required'] as const) { + if (declared[field] === undefined || typeof declared[field] === 'boolean') continue; + diagnostics.push(userConfigDiagnostic( + `claude.userConfig.${field}.invalid`, + `Claude userConfig option "${key}" field "${field}" must be a boolean when provided.`, + `Set userConfig.${key}.${field} to true or false, or remove it, then rebuild.`, + )); + } + + const multiple = declared['multiple']; + if ( + multiple !== undefined && + (typeof multiple !== 'boolean' || (isUserConfigOptionType(type) && type !== 'string')) + ) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.multiple.invalid', + `Claude userConfig option "${key}" may declare boolean field "multiple" only for type "string".`, + `Remove userConfig.${key}.multiple or change the option type to "string", then rebuild.`, + )); + } + + const bounds: Partial> = {}; + for (const field of ['min', 'max'] as const) { + const bound = declared[field]; + if (bound === undefined) continue; + if (typeof bound !== 'number' || !Number.isFinite(bound) || (isUserConfigOptionType(type) && type !== 'number')) { + diagnostics.push(userConfigDiagnostic( + `claude.userConfig.${field}.invalid`, + `Claude userConfig option "${key}" may declare finite numeric field "${field}" only for type "number".`, + `Remove userConfig.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`, + )); + continue; + } + bounds[field] = bound; + } + if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.bounds.invalid', + `Claude userConfig option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`, + `Set userConfig.${key}.min less than or equal to userConfig.${key}.max, then rebuild.`, + )); + } + + const defaultValue = declared['default']; + if (defaultValue !== undefined && isUserConfigOptionType(type)) { + let validDefault: boolean; + switch (type) { + case 'string': + validDefault = multiple === true + ? Array.isArray(defaultValue) && defaultValue.every((entry) => typeof entry === 'string') + : typeof defaultValue === 'string'; + break; + case 'number': + validDefault = + typeof defaultValue === 'number' && + Number.isFinite(defaultValue) && + (bounds.min === undefined || defaultValue >= bounds.min) && + (bounds.max === undefined || defaultValue <= bounds.max); + break; + case 'boolean': + validDefault = typeof defaultValue === 'boolean'; + break; + case 'directory': + case 'file': + validDefault = typeof defaultValue === 'string'; + break; + default: { + const exhaustive: never = type; + return exhaustive; + } + } + if (!validDefault) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.default.invalid', + `Claude userConfig option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`, + `Set userConfig.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`, + )); + } + } + if (declared['sensitive'] === true && defaultValue !== undefined) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.sensitive.default', + `Claude userConfig option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`, + `Remove userConfig.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`, + )); + } + + if (diagnostics.length > 0) return { diagnostics }; + const value: Record = Object.create(null) as Record; + for (const field of userConfigOptionFields) { + if (declared[field] !== undefined) value[field] = declared[field]; + } + return { diagnostics, value }; +}; + +interface ClaudeUserConfigPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Record>; + readonly sourceInputs: readonly string[]; +} + +const noUserConfigPlan: ClaudeUserConfigPlan = deepFreeze({ + diagnostics: [], + sourceInputs: [], +}); + +const planClaudeUserConfig = (model: NormalizedPlugin): ClaudeUserConfigPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noUserConfigPlan; + const declared = extension.value['userConfig']; + if (declared === undefined) return noUserConfigPlan; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!isPlainDataRecord(declared) || Object.keys(declared).length === 0) { + return { + diagnostics: [userConfigDiagnostic( + 'claude.userConfig.declaration.invalid', + 'Claude userConfig must be a nonempty plain record of option key to option declaration.', + 'Set claude.userConfig to a nonempty object whose values declare type, title, and description, then rebuild.', + )], + sourceInputs: inputs, + }; + } + + const diagnostics: Diagnostic[] = []; + const options: Record> = Object.create(null) as Record>; + const environmentOwners = new Map(); + for (const key of Object.keys(declared).sort()) { + if (!userConfigIdentifier.test(key)) { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.key.invalid', + `Claude userConfig option key "${key}" must match ^[A-Za-z_][A-Za-z0-9_]*$.`, + `Rename userConfig option "${key}" to a valid identifier containing only letters, digits, and underscores and not starting with a digit, then rebuild.`, + )); + } + const environmentKey = key.toUpperCase(); + const owner = environmentOwners.get(environmentKey); + if (owner === undefined) { + environmentOwners.set(environmentKey, key); + } else { + diagnostics.push(userConfigDiagnostic( + 'claude.userConfig.key.collision', + `Claude userConfig option keys "${owner}" and "${key}" both export as CLAUDE_PLUGIN_OPTION_${environmentKey}.`, + `Rename one option so every key remains unique after uppercasing, then rebuild.`, + )); + } + const optionPlan = planClaudeUserConfigOption(key, declared[key]); + diagnostics.push(...optionPlan.diagnostics); + if (optionPlan.value !== undefined) options[key] = optionPlan.value; + } + return { + diagnostics, + ...(diagnostics.length === 0 ? { document: options } : {}), + sourceInputs: inputs, + }; +}; + interface ClaudeBinPlan { readonly diagnostics: readonly Diagnostic[]; readonly entries: readonly TargetArtifactCopy[]; @@ -740,6 +1002,8 @@ export const planClaudeArtifacts = ( if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); const lsp = planClaudeLsp(model); diagnostics.push(...lsp.diagnostics); + const userConfig = planClaudeUserConfig(model); + diagnostics.push(...userConfig.diagnostics); const bin = planClaudeBin(model, targetName); diagnostics.push(...bin.diagnostics); const settings = planClaudeSettings(model); @@ -759,6 +1023,7 @@ export const planClaudeArtifacts = ( description: model.metadata.description ?? model.metadata.name, ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), name: model.metadata.name, + ...(userConfig.document === undefined ? {} : { userConfig: userConfig.document }), version: model.metadata.version, }; diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); @@ -794,6 +1059,7 @@ export const planClaudeArtifacts = ( } const basePlan = standardPluginArtifactPlan({ + additionalPluginSourceInputs: userConfig.sourceInputs, diagnostics, ...(hostDocuments.length === 0 ? {} : { hostDocuments }), hookDocument, @@ -878,6 +1144,13 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not support skills.', ), + userConfig: capabilityStateFromSupport( + capabilityTable.plugin.userConfig.sensitiveStorage && + capabilityTable.plugin.userConfig.projectSettingsIgnored && + capabilityTable.plugin.userConfig.installConfigFlag, + evidence, + 'The pinned Claude plugin contract does not document enable-time userConfig options.', + ), }), configExtension: Object.freeze({ key: claudeName }), hookContract, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 813968cef..3de8714e4 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.6.0', + adapterRevision: '1.7.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 @@ -610,6 +610,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), cursorAdapter.capabilities.skills!, ), + userConfig: unavailableCapability( + 'The unified bundle emits the Claude-only userConfig manifest field, but the pinned Codex and Cursor contracts declare no shared enable-time option surface.', + ), }), componentCapabilities, hookContract: bundleHookContract, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 1e9abc1fe..3af54c8ce 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -2,7 +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.", + "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.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -25,9 +25,9 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/mcp" }, "plugin.schema.json": { - "bytes": 2279, - "sha256": "f0c503ec8bc11c2ebeade8e8feed37a6c920525b0534438c37210cc50aa66a62", - "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" + "bytes": 4980, + "sha256": "6c8630118fbac739961d18eb2912c784db6e0dd015bb03f07bb80974f7bc9ebb", + "url": "https://code.claude.com/docs/en/plugins-reference" }, "settings.schema.json": { "bytes": 554, 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 9c7861337..88a4d34f8 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json @@ -26,6 +26,75 @@ }, "required": ["command", "extensionToLanguage"], "type": "object" + }, + "userConfigOption": { + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "type": { "const": "string" } }, "required": ["type"] }, + "then": { + "oneOf": [ + { + "properties": { + "default": { "items": { "type": "string" }, "type": "array" }, + "multiple": { "const": true } + }, + "required": ["multiple"] + }, + { + "properties": { + "default": { "type": "string" }, + "multiple": { "const": false } + } + } + ] + } + }, + { + "if": { "properties": { "type": { "const": "number" } }, "required": ["type"] }, + "then": { "properties": { "default": { "type": "number" } } }, + "else": { "not": { "anyOf": [{ "required": ["min"] }, { "required": ["max"] }] } } + }, + { + "if": { "properties": { "type": { "const": "boolean" } }, "required": ["type"] }, + "then": { "properties": { "default": { "type": "boolean" } } } + }, + { + "if": { + "properties": { "type": { "enum": ["directory", "file"] } }, + "required": ["type"] + }, + "then": { "properties": { "default": { "type": "string" } } } + }, + { + "if": { "properties": { "type": { "const": "string" } }, "required": ["type"] }, + "else": { "not": { "required": ["multiple"] } } + }, + { + "if": { "properties": { "sensitive": { "const": true } }, "required": ["sensitive"] }, + "then": { "not": { "required": ["default"] } } + } + ], + "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, @@ -51,6 +120,12 @@ ] }, "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + "userConfig": { + "additionalProperties": { "$ref": "#/$defs/userConfigOption" }, + "minProperties": 1, + "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, + "type": "object" + }, "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" } }, "required": ["name", "version", "description", "author"], diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index a4fcaeb2d..3f1b799ac 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -154,6 +154,27 @@ it('reports Claude plugin settings support and honest unavailable composite cove expect(registry.supports('plugin', 'settings')).toBe(false); }); +it('reports Claude userConfig support and honest unavailable composite coverage', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities.userConfig).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities.userConfig).toMatchObject({ + reason: expect.stringContaining('Claude-only userConfig'), + state: 'unavailable', + }); + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities.userConfig).toBeUndefined(); + } + expect(registry.supports('claude', 'userConfig')).toBe(true); + expect(registry.supports('plugin', 'userConfig')).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 6ff830150..dc8fec4fb 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.7.0', + adapterRevision: '1.8.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: 'f0c503ec8bc11c2ebeade8e8feed37a6c920525b0534438c37210cc50aa66a62', + sha256: '6c8630118fbac739961d18eb2912c784db6e0dd015bb03f07bb80974f7bc9ebb', }, { name: 'settings', @@ -154,7 +154,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.6.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.7.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index b815162d4..99b403118 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -165,6 +165,45 @@ it('prepares and inspects a target owned only by the supplied advanced registry' } }); +it('accepts claude.userConfig through the public inspection and build APIs', async () => { + const root = await createProject(); + const artifact = join(root, 'artifact'); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'user-config-api', version: '1.0.0' },", + " targets: ['claude'],", + ' claude: {', + ' userConfig: {', + " api_token: { type: 'string', title: 'API token', description: 'Authentication token.', sensitive: true },", + ' },', + ' },', + '};', + '', + ].join('\n')); + + const inspection = await readyInspection({ root }); + expect(inspection.model.extensions.claude?.value).toMatchObject({ + userConfig: { + api_token: { + description: 'Authentication token.', + sensitive: true, + title: 'API token', + type: 'string', + }, + }, + }); + + await build({ output: artifact, root }); + const manifest = JSON.parse( + await readFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), 'utf8'), + ) as Record; + expect(manifest).toHaveProperty('userConfig.api_token.sensitive', true); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + it('returns a frozen invalid inspection for opaque source failures', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-api-invalid-inspection-')); try { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 5a813648e..0c9539f98 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1752,6 +1752,79 @@ it.each([ } }); +it('validates Claude plugin artifacts carrying the pinned userConfig contract', async () => { + const registry = createDefaultRegistry(); + const target = targetFromRegistry(registry, 'claude'); + const pluginPath = 'claude/.claude-plugin/plugin.json'; + const basePlugin = { + author: { name: 'Agent Bundle' }, + description: 'Claude userConfig artifact fixture.', + name: 'claude-user-config-artifact', + version: '1.0.0', + }; + const validFiles = [ + { contents: '# Install claude-user-config-artifact\n', kind: 'generated' as const, path: 'claude/INSTALL.md' }, + { + contents: `${JSON.stringify({ + ...basePlugin, + userConfig: { + api_token: { + description: 'API authentication token.', + sensitive: true, + title: 'API token', + type: 'string', + }, + retries: { + default: 3, + description: 'Retry count.', + max: 5, + min: 0, + title: 'Retries', + type: 'number', + }, + }, + })}\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({ + ...basePlugin, + userConfig: { + api_token: { + description: 'API authentication token.', + title: 'API token', + type: 'string', + unknown: true, + }, + }, + })}\n`, + kind: 'generated' as const, + path: pluginPath, + }, + ]; + await writeFile(join(root, pluginPath), 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: pluginPath, 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 e8c091aaf..b22394f78 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -178,3 +178,68 @@ nativeIt('accepts an emitted Claude plugin with bin under strict native validati await rm(root, { force: true, recursive: true }); } }); + +nativeIt('accepts emitted Claude userConfig under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-user-config-')); + const outputRoot = join(root, 'plugin'); + const model: NormalizedPlugin = { + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: { + userConfig: { + api_token: { + description: 'API authentication token.', + sensitive: true, + title: 'API token', + type: 'string', + }, + retries: { + default: 3, + description: 'Maximum retry count.', + max: 5, + min: 0, + title: 'Retries', + type: 'number', + }, + }, + }, + }, + }, + hooks: [], + marketplace: true, + mcpServers: [], + metadata: { + description: 'Validate Claude user configuration.', + id: 'plugin:user-config-proof', + name: 'user-config-proof', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + version: '1.0.0', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: [{ + id: 'target:claude', + name: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], + }; + + try { + const plan = claudeAdapter.plan(model); + expect(plan.diagnostics).toEqual([]); + await emitPlanEntries({ entries: plan.entries, root: outputRoot }); + const result = await runClaudeValidation( + outputRoot, + join(outputRoot, '.claude-plugin', 'marketplace.json'), + ); + expect(result.code, result.output).toBe(0); + expect(result.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 050cfb4e5..3861f36bc 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -101,6 +101,23 @@ const withClaudeLsp = ( }, }); +const withClaudeUserConfig = ( + model: NormalizedPlugin, + userConfig: unknown, + target = 'claude', +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/claude.config.ts' }, + target, + value: { userConfig }, + }, + }, +}); + const withClaudeBin = ( model: NormalizedPlugin, files: NonNullable[number]['files'], @@ -520,6 +537,197 @@ it('emits Claude LSP configuration and expands only the four documented token fi expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); }); +it('emits sorted, allowlisted Claude userConfig declarations with config provenance', () => { + const model = withClaudeUserConfig(plugin, { + z_count: { + default: 3, + description: 'Maximum findings.', + max: 10, + min: 1, + required: true, + title: 'Finding limit', + type: 'number', + }, + api_token: { + description: 'API authentication token.', + sensitive: true, + title: 'API token', + type: 'string', + }, + }); + 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/claude.config.ts'], + }); + if (manifest?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); + expect(JSON.parse(manifest.content).userConfig).toEqual({ + api_token: { + description: 'API authentication token.', + sensitive: true, + title: 'API token', + type: 'string', + }, + z_count: { + default: 3, + description: 'Maximum findings.', + max: 10, + min: 1, + required: true, + title: 'Finding limit', + type: 'number', + }, + }); + expect(manifest.content.indexOf('"api_token"')).toBeLessThan(manifest.content.indexOf('"z_count"')); +}); + +it.each([ + { + code: 'claude.userConfig.declaration.invalid', + label: 'an empty declaration', + userConfig: {}, + }, + { + code: 'claude.userConfig.option.invalid', + label: 'a non-object option', + userConfig: { token: 'string' }, + }, + { + code: 'claude.userConfig.key.invalid', + label: 'an invalid identifier', + userConfig: { 'api-token': { description: 'Token.', title: 'Token', type: 'string' } }, + }, + { + code: 'claude.userConfig.key.collision', + label: 'environment-variable keys that collide after uppercasing', + userConfig: { + ApiKey: { description: 'First.', title: 'First', type: 'string' }, + APIKEY: { description: 'Second.', title: 'Second', type: 'string' }, + }, + }, + { + code: 'claude.userConfig.field.unknown', + label: 'an unknown option field', + userConfig: { token: { description: 'Token.', title: 'Token', type: 'string', typo: true } }, + }, + { + code: 'claude.userConfig.type.invalid', + label: 'an unsupported type', + userConfig: { token: { description: 'Token.', title: 'Token', type: 'secret' } }, + }, + { + code: 'claude.userConfig.title.required', + label: 'an empty title', + userConfig: { token: { description: 'Token.', title: '', type: 'string' } }, + }, + { + code: 'claude.userConfig.description.required', + label: 'a missing description', + userConfig: { token: { title: 'Token', type: 'string' } }, + }, + { + code: 'claude.userConfig.sensitive.invalid', + label: 'a non-boolean sensitive flag', + userConfig: { token: { description: 'Token.', sensitive: 'yes', title: 'Token', type: 'string' } }, + }, + { + code: 'claude.userConfig.required.invalid', + label: 'a non-boolean required flag', + userConfig: { token: { description: 'Token.', required: 1, title: 'Token', type: 'string' } }, + }, + { + code: 'claude.userConfig.multiple.invalid', + label: 'multiple on a non-string option', + userConfig: { count: { description: 'Count.', multiple: true, title: 'Count', type: 'number' } }, + }, + { + code: 'claude.userConfig.multiple.invalid', + label: 'a non-boolean multiple flag', + userConfig: { token: { description: 'Token.', multiple: 'yes', title: 'Token', type: 'string' } }, + }, + { + code: 'claude.userConfig.min.invalid', + label: 'min on a non-number option', + userConfig: { token: { description: 'Token.', min: 1, title: 'Token', type: 'string' } }, + }, + { + code: 'claude.userConfig.max.invalid', + label: 'a non-number max bound', + userConfig: { count: { description: 'Count.', max: 'ten', title: 'Count', type: 'number' } }, + }, + { + code: 'claude.userConfig.bounds.invalid', + label: 'inverted numeric bounds', + userConfig: { count: { description: 'Count.', max: 1, min: 2, title: 'Count', type: 'number' } }, + }, + { + code: 'claude.userConfig.default.invalid', + label: 'a string-array default without multiple', + userConfig: { tags: { default: ['one'], description: 'Tags.', title: 'Tags', type: 'string' } }, + }, + { + code: 'claude.userConfig.default.invalid', + label: 'a scalar string default with multiple', + userConfig: { tags: { default: 'one', description: 'Tags.', multiple: true, title: 'Tags', type: 'string' } }, + }, + { + code: 'claude.userConfig.default.invalid', + label: 'a numeric default outside bounds', + userConfig: { count: { default: 11, description: 'Count.', max: 10, title: 'Count', type: 'number' } }, + }, + { + code: 'claude.userConfig.default.invalid', + label: 'a mismatched file default', + userConfig: { file: { default: false, description: 'File.', title: 'File', type: 'file' } }, + }, + { + code: 'claude.userConfig.sensitive.default', + label: 'a sensitive option with a manifest default', + userConfig: { token: { default: 'secret', description: 'Token.', sensitive: true, title: 'Token', type: 'string' } }, + }, +])('rejects $label without emitting userConfig', ({ code, userConfig }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeUserConfig(plugin, userConfig)); + 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', + })); + if (manifest?.kind !== 'write') throw new Error('Expected the base Claude plugin manifest.'); + expect(JSON.parse(manifest.content)).not.toHaveProperty('userConfig'); +}); + +it('pins the closed Claude userConfig manifest schema', 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 userConfig schema fixture.', + name: 'claude-user-config-fixture', + version: '1.0.0', + }; + const option = { description: 'API token.', title: 'API token', type: 'string' }; + + expect(validate({ ...manifest, userConfig: { api_token: option } })).toBe(true); + for (const userConfig of [ + {}, + { 'api-token': option }, + { token: { ...option, unknown: true } }, + { token: { description: 'Missing title.', type: 'string' } }, + { token: { ...option, type: 'secret' } }, + ]) { + expect(validate({ ...manifest, userConfig })).toBe(false); + } +}); + it('plans Claude bin files as byte-faithful prebuilt copies with complete provenance', () => { const model = withClaudeBin(plugin, [ { diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 4c9188530..a3cb91590 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -268,6 +268,48 @@ it('emits Claude-only LSP configuration at the shared composite root', () => { expect(documents['AGENTS.md']).toContain('claude --debug'); }); +it('emits Claude userConfig from the unified plugin target only into the Claude manifest', () => { + const model = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config' as const, sourcePath: '/workspace/claude.config.ts' }, + target: 'claude', + value: { + userConfig: { + workspace: { + description: 'Workspace directory.', + required: true, + title: 'Workspace', + type: 'directory', + }, + }, + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).toMatchObject({ + userConfig: { + workspace: { + description: 'Workspace directory.', + required: true, + title: 'Workspace', + type: 'directory', + }, + }, + }); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('userConfig'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('userConfig'); + expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) + .toContain('/workspace/claude.config.ts'); +}); + it('emits the Claude bin directory from the unified plugin target', () => { const model: NormalizedPlugin = { ...bundleModel,