From 04d1480a1ba8bb21bdb678bae107d071d286c3e0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 05:42:53 +0000 Subject: [PATCH] feat(claude): emit plugin-root settings.json defaults Add a `claude.settings` host config surface that lowers into the plugin-root `settings.json` Claude Code applies when a plugin is enabled, pinned to the two documented keys (`agent`, `subagentStatusLine`) by a new closed schema. The host silently ignores unknown keys and its own `plugin validate --strict` never inspects the file, so the compiler is the only guard an author gets: unknown keys, malformed values, empty declarations, and path tokens (absent from the documented placeholder table) are build errors. Declaring `agent` still emits, with a warning that the plugin agents component stays deferred (#100 stage 2 G5, PR #220) so the referenced agent must ship another way. --- .changeset/claude-plugin-settings.md | 5 + README.md | 2 + .../adapters/capabilities/claude-2.1.250.json | 16 +- packages/agent-bundle/src/adapters/claude.ts | 219 +++++++++++++++++- .../agent-bundle/src/adapters/diagnostics.ts | 11 + packages/agent-bundle/src/adapters/plugin.ts | 20 +- .../adapters/schemas/claude/PROVENANCE.json | 7 +- .../schemas/claude/settings.schema.json | 19 ++ .../tests/adapter-capability-states.test.ts | 24 ++ .../tests/adapter-metadata.test.ts | 9 +- .../tests/artifact-validator.test.ts | 48 ++++ .../tests/host-adapters.native.test.ts | 161 ++++++++++--- .../agent-bundle/tests/host-adapters.test.ts | 169 ++++++++++++++ .../agent-bundle/tests/plugin-bundle.test.ts | 29 +++ 14 files changed, 695 insertions(+), 44 deletions(-) create mode 100644 .changeset/claude-plugin-settings.md create mode 100644 packages/agent-bundle/src/adapters/schemas/claude/settings.schema.json diff --git a/.changeset/claude-plugin-settings.md b/.changeset/claude-plugin-settings.md new file mode 100644 index 000000000..7a827e89d --- /dev/null +++ b/.changeset/claude-plugin-settings.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add host-scoped Claude Code plugin defaults under `claude.settings` and emit a validated plugin-root `settings.json` for Claude targets, pinned to the documented `agent` and `subagentStatusLine` keys. Declaring `agent` warns that the plugin agents component is still deferred, so the referenced agent must reach the plugin root another way. diff --git a/README.md b/README.md index 78dccb8fb..55c6d6118 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ npx agent-bundle dev --root . # local workbench with live rebu Claude Code language servers are declared under `claude.lspServers`; the `claude` target and the Claude half of `plugin` emit the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. +Claude Code plugin defaults are declared under `claude.settings` and emitted as plugin-root `settings.json`, which Claude Code applies when the plugin is enabled. The pinned contract supports only `agent` and `subagentStatusLine`; Agent Bundle rejects any other key rather than shipping a default Claude Code would silently ignore, and it expands no path tokens here because `settings.json` is absent from the host's placeholder-substitution table. Because the plugin `agents/` component is still deferred, declaring `agent` also raises a warning: the referenced agent has to reach the plugin root some other way, such as a prebuilt payload. + The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the host artifacts; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. ## Commands 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 dc5154750..2274af98a 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 @@ -87,6 +87,14 @@ }, "manifest": ".claude-plugin/plugin.json", "marketplace": ".claude-plugin/marketplace.json", + "settings": { + "config": "settings.json", + "placeholderSubstitution": false, + "precedesManifestSettings": true, + "subagentStatusLineFields": ["command", "type"], + "supportedKeys": ["agent", "subagentStatusLine"], + "unknownKeysIgnored": true + }, "skills": true }, "tokens": { @@ -117,7 +125,13 @@ "SubagentStart adds agent_id and agent_type to common hook fields. It cannot block subagent creation; hookSpecificOutput.additionalContext injects context before the first subagent prompt, and exit-2 stderr is only a non-blocking notice in the subagent transcript.", "SubagentStop adds stop_hook_active, agent_id, agent_type, agent_transcript_path, and last_assistant_message. decision:block plus reason or exit 2 keeps the subagent running; hookSpecificOutput.additionalContext provides non-error feedback that also continues it.", "The hooks reference states prompt_id requires Claude Code 2.1.196 or later; the pinned 2.1.250 release covers that input field without a version bump.", - "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it." + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it.", + "2026-09-01: https://code.claude.com/docs/en/plugins \"Ship default settings with your plugin\": a plugin may include settings.json at the plugin root \"to apply default configuration when the plugin is enabled. Currently, only the `agent` and `subagentStatusLine` keys are supported.\" The file-locations table of https://code.claude.com/docs/en/plugins-reference repeats the same bound: \"Settings | settings.json | Default configuration applied when the plugin is enabled. Only the agent and subagentStatusLine keys are supported\".", + "2026-09-01: The same plugins section fixes precedence and host tolerance: \"Settings from `settings.json` take priority over `settings` declared in `plugin.json`. Unknown keys are silently ignored.\" Agent Bundle tightens the silent ignore into the build error claude.settings.field.unknown, the same way an unknown LSP server field is rejected, so a documented component an author asked for is never dropped without a diagnostic; an empty settings object is rejected too, because it declares no default configuration.", + "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)." ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 7f756d036..27075129b 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -1,5 +1,5 @@ import { createTargetDiagnostics } from './diagnostics.ts'; -import type { Diagnostic } from '../core/diagnostics.ts'; +import { hasErrors, type Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { pathTokens, @@ -40,6 +40,7 @@ import lspSchema from './schemas/claude/lsp.schema.json' with { type: 'json' }; import marketplaceSchema from './schemas/claude/marketplace.schema.json' with { type: 'json' }; import mcpSchema from './schemas/claude/mcp.schema.json' with { type: 'json' }; import pluginSchema from './schemas/claude/plugin.schema.json' with { type: 'json' }; +import settingsSchema from './schemas/claude/settings.schema.json' with { type: 'json' }; import { stringify as stringifyYaml } from 'yaml'; import { commandWriteEntries, @@ -53,6 +54,7 @@ import { validateJsonSchemaDocument, validateModernMcpDocument, withPluginRootEnvAnchor, + type StandardPluginHostDocument, type TargetAdapter, type TargetArtifactCopy, type TargetArtifactLayout, @@ -89,15 +91,55 @@ export interface ClaudeLspServerConfig { readonly workspaceFolder?: string; } +/** + * One Claude Code subagent status line: the command object documented for + * `subagentStatusLine`, which renders a custom row body for each subagent in + * the agent panel. Only `type` and `command` are admitted; `statusLine`'s + * optional `padding` is documented for the user status line, not for the + * plugin default, so it is not part of this pinned shape. + */ +export interface ClaudeSubagentStatusLineConfig { + /** A path to an executable or an inline command; Claude Code runs it once per refresh tick. */ + readonly command: string; + readonly type: 'command'; +} + +/** + * Default configuration Claude Code applies when the plugin is enabled, + * emitted as `settings.json` at the plugin root. The pinned 2.1.250 contract + * supports exactly two keys, and `settings.json` takes priority over + * `settings` declared in the manifest. + * + * `agent` activates one of the plugin's own agents as the main thread. The + * plugin `agents/` component is deferred by the #100 stage-2 G5 gate (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 a + * prebuilt payload. Declaring it raises the `claude.settings.agent.deferred` + * warning rather than silently emitting a dangling reference. + * + * Claude Code substitutes `${CLAUDE_PLUGIN_ROOT}` and its siblings only in + * the components its placeholder table names (Skill and agent content, hook + * and monitor commands, MCP servers, LSP servers). `settings.json` is absent + * from that table, so Agent Bundle path tokens are rejected here instead of + * being emitted as placeholders the host never resolves. + */ +export interface ClaudeSettingsConfig { + /** Name of a plugin agent to activate as the main thread. */ + readonly agent?: string; + readonly subagentStatusLine?: ClaudeSubagentStatusLineConfig; +} + /** * 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; - * the portable LSP component kind stays deferred. + * the portable LSP component kind stays deferred. `settings` is host-scoped + * for the same reason: no other pinned contract ships plugin defaults. */ export interface ClaudeHostConfig extends AgentBundleHostConfig { /** Project-authored directory copied to the plugin-root `bin/` executable convention. */ readonly bin?: string; readonly lspServers?: Readonly>; + readonly settings?: ClaudeSettingsConfig; } export interface ClaudeConfigExtension { @@ -119,6 +161,7 @@ export const claudeArtifactPaths = Object.freeze({ marketplace: '.claude-plugin/marketplace.json', mcp: '.mcp.json', plugin: '.claude-plugin/plugin.json', + settings: 'settings.json', }); const validator = createAdapterValidator(); const validatePlugin = validator.compile(pluginSchema); @@ -126,6 +169,7 @@ const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); const validateHooks = validator.compile(hooksSchema); const validateLsp = validator.compile(lspSchema); +const validateSettings = validator.compile(settingsSchema); /** The pinned Claude hooks validator, shared with the unified bundle adapter. */ export const claudeHooksValidator = validateHooks; @@ -149,7 +193,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -162,6 +206,7 @@ const artifactValidation = deepFreeze({ Object.freeze({ path: '.claude-plugin/marketplace.json', required: false, schema: 'marketplace' }), Object.freeze({ path: '.mcp.json', required: false, schema: 'mcp' }), Object.freeze({ path: '.claude-plugin/plugin.json', required: true, schema: 'plugin' }), + Object.freeze({ path: claudeArtifactPaths.settings, required: false, schema: 'settings' }), ], schemas: [ Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }), @@ -169,6 +214,7 @@ const artifactValidation = deepFreeze({ Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }), Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }), Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }), + Object.freeze({ name: 'settings', validate: validateJsonSchemaDocument(validateSettings) }), ], }); @@ -187,7 +233,7 @@ const mcpRuntime = createTargetMcpRuntime({ }), }); -const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(claudeName, 'Claude'); +const { errorDiagnostic, schemaDiagnostics, warningDiagnostic } = createTargetDiagnostics(claudeName, 'Claude'); const claudeCommandMarkdown = ( command: NonNullable[number], @@ -538,6 +584,138 @@ const planClaudeBin = (model: NormalizedPlugin, targetName: string): ClaudeBinPl return deepFreeze({ diagnostics, entries }); }; +/** + * Every key the pinned plugin `settings.json` contract documents. The emitted + * document copies this allowlist rather than the declared object, so a + * misspelled key is a build diagnostic instead of a key Claude Code silently + * ignores at runtime. + */ +const settingsFields: ReadonlySet = new Set(['agent', 'subagentStatusLine']); + +/** The two fields the documented `subagentStatusLine` examples carry. */ +const subagentStatusLineFields: ReadonlySet = new Set(['command', 'type']); + +const settingsTokenDiagnostic = (field: string): Diagnostic => errorDiagnostic( + 'claude.settings.token.unsupported', + `Claude settings key "${field}" cannot use a path token: the pinned placeholder table substitutes \${CLAUDE_PLUGIN_ROOT} and its siblings in Skill and agent content, hook and monitor commands, MCP servers, and LSP servers only, never in settings.json.`, +); + +const planSubagentStatusLine = ( + declared: unknown, +): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { + const diagnostics: Diagnostic[] = []; + if (!isDataRecord(declared)) { + diagnostics.push(errorDiagnostic( + 'claude.settings.statusline.invalid', + 'Claude settings subagentStatusLine must be a command object of the form { "type": "command", "command": "" }.', + )); + return { diagnostics }; + } + for (const field of Object.keys(declared).sort()) { + if (subagentStatusLineFields.has(field)) continue; + diagnostics.push(errorDiagnostic( + 'claude.settings.statusline.field.unknown', + `Claude settings subagentStatusLine declares unknown field "${field}"; the documented subagent status line carries only "type" and "command". The optional "padding" field is documented for the user statusLine, not for a plugin default.`, + )); + } + if (declared['type'] !== 'command') { + diagnostics.push(errorDiagnostic( + 'claude.settings.statusline.type.invalid', + 'Claude settings subagentStatusLine requires type "command"; the pinned contract documents no other subagent status line type.', + )); + } + const command = declared['command']; + if (typeof command !== 'string' || command.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.settings.statusline.command.required', + 'Claude settings subagentStatusLine requires a nonempty command; Claude Code runs it once per refresh tick to render the subagent rows.', + )); + return { diagnostics }; + } + if (hasPathToken(command)) { + diagnostics.push(settingsTokenDiagnostic('subagentStatusLine.command')); + return { diagnostics }; + } + return { diagnostics, value: { command, type: 'command' } }; +}; + +interface ClaudeSettingsPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Record; + readonly sourceInputs: readonly string[]; +} + +const noSettingsPlan: ClaudeSettingsPlan = deepFreeze({ + diagnostics: [], + sourceInputs: [], +}); + +/** + * Lowers `claude.settings` into the plugin-root `settings.json` document + * Claude Code applies as default configuration when the plugin is enabled. + * The host tolerates unknown keys by ignoring them silently; this compiler + * rejects them instead, so a default an author asked for never disappears + * between the config and the running session. + */ +export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noSettingsPlan; + const declared = extension.value['settings']; + if (declared === undefined) return noSettingsPlan; + const diagnostics: Diagnostic[] = []; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!isDataRecord(declared) || Object.keys(declared).length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.settings.declaration.invalid', + 'Claude settings must be a nonempty object declaring agent, subagentStatusLine, or both; an empty settings.json applies no default configuration.', + )); + return { diagnostics, sourceInputs: inputs }; + } + for (const field of Object.keys(declared).sort()) { + if (settingsFields.has(field)) continue; + diagnostics.push(errorDiagnostic( + 'claude.settings.field.unknown', + `Claude settings declares unknown key "${field}"; the pinned contract supports only "agent" and "subagentStatusLine". Claude Code ignores unknown keys silently, so the bundle refuses the declaration instead of shipping a default that never applies.`, + )); + } + + const document: Record = Object.create(null) as Record; + const agent = declared['agent']; + if (agent !== undefined) { + if (typeof agent !== 'string' || agent.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.settings.agent.invalid', + 'Claude settings agent must be a nonempty plugin agent name; it activates that agent as the main thread.', + )); + } else if (hasPathToken(agent)) { + diagnostics.push(settingsTokenDiagnostic('agent')); + } else { + document['agent'] = agent; + } + } + const subagentStatusLine = declared['subagentStatusLine']; + if (subagentStatusLine !== undefined) { + const statusLinePlan = planSubagentStatusLine(subagentStatusLine); + diagnostics.push(...statusLinePlan.diagnostics); + if (statusLinePlan.value !== undefined) document['subagentStatusLine'] = statusLinePlan.value; + } + if (hasErrors(diagnostics)) return { diagnostics, sourceInputs: inputs }; + + const valid = validateSettings(document); + diagnostics.push(...schemaDiagnostics('settings', valid, validateSettings.errors)); + if (!valid) return { diagnostics, sourceInputs: inputs }; + // The setting itself is shippable host configuration, so the deferred + // plugin-agents component is reported alongside the emitted document + // rather than in place of it: the agent can still arrive by other means. + if (typeof document['agent'] === 'string') { + diagnostics.push(warningDiagnostic( + 'claude.settings.agent.deferred', + `Claude settings activates plugin agent "${document['agent']}" as the main thread, but the plugin agents component stays deferred (#100 stage 2 G5, PR #220), so this bundle emits no agents/ directory. Ship the agent another way, for example a prebuilt payload that lands agents/${document['agent']}.md at the plugin root, or the setting dangles at runtime.`, + )); + } + return { diagnostics, document, sourceInputs: inputs }; +}; + export interface ClaudeArtifactPlanOptions { /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ readonly targetName?: string; @@ -564,6 +742,8 @@ export const planClaudeArtifacts = ( diagnostics.push(...lsp.diagnostics); const bin = planClaudeBin(model, targetName); diagnostics.push(...bin.diagnostics); + const settings = planClaudeSettings(model); + diagnostics.push(...settings.diagnostics); const generatedHooks = planHooks(model, targetName, hookContract); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { @@ -597,15 +777,25 @@ export const planClaudeArtifacts = ( const marketplaceValid = validateMarketplace(marketplace); diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); + const hostDocuments: StandardPluginHostDocument[] = []; + if (lsp.document !== undefined) { + hostDocuments.push({ + document: lsp.document, + relativePath: claudeArtifactPaths.lsp, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...lsp.sourceInputs), + }); + } + if (settings.document !== undefined) { + hostDocuments.push({ + document: settings.document, + relativePath: claudeArtifactPaths.settings, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...settings.sourceInputs), + }); + } + const basePlan = standardPluginArtifactPlan({ diagnostics, - ...(lsp.document === undefined ? {} : { - hostDocuments: [{ - document: lsp.document, - relativePath: claudeArtifactPaths.lsp, - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...lsp.sourceInputs), - }], - }), + ...(hostDocuments.length === 0 ? {} : { hostDocuments }), hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, @@ -676,6 +866,13 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ rules: unavailableCapability( 'The pinned Claude Code plugin contract (2.1.250) defines no rules component; project guidance ships through CLAUDE.md memory, not a rules directory.', ), + settings: capabilityStateFromSupport( + capabilityTable.plugin.settings.config === claudeArtifactPaths.settings && + capabilityTable.plugin.settings.supportedKeys.length === settingsFields.size && + capabilityTable.plugin.settings.supportedKeys.every((key) => settingsFields.has(key)), + evidence, + 'The pinned Claude plugin contract does not document the plugin-root settings.json defaults surface.', + ), skills: capabilityStateFromSupport( capabilityTable.plugin.skills, evidence, diff --git a/packages/agent-bundle/src/adapters/diagnostics.ts b/packages/agent-bundle/src/adapters/diagnostics.ts index d75e6063a..765fa1a99 100644 --- a/packages/agent-bundle/src/adapters/diagnostics.ts +++ b/packages/agent-bundle/src/adapters/diagnostics.ts @@ -5,6 +5,11 @@ import type { Diagnostic } from '../core/diagnostics.ts'; export interface TargetDiagnosticHelpers { errorDiagnostic(code: string, message: string): Diagnostic; schemaDiagnostics(document: string, valid: boolean, errors: readonly ErrorObject[] | null | undefined): Diagnostic[]; + /** + * A host fact the bundle still emits for: the document is shippable, but a + * declared value depends on something this compiler does not emit. + */ + warningDiagnostic(code: string, message: string): Diagnostic; } /** Host adapters share one diagnostic shape, differing only in target slug and display label. */ @@ -25,5 +30,11 @@ export const createTargetDiagnostics = (target: string, label: string): TargetDi .map((error) => `${error.instancePath || '/'}: ${error.message ?? 'schema validation failed'}`) .join('; ') || 'schema validation failed'}.`, )], + warningDiagnostic: (code, message) => ({ + code, + message, + severity: 'warning', + target, + }), }; }; diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 232c21923..813968cef 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -161,6 +161,7 @@ const artifactValidation = deepFreeze({ Object.freeze({ path: claudeArtifactPaths.marketplace, required: false, schema: 'claude-marketplace' }), Object.freeze({ path: claudeArtifactPaths.mcp, required: false, schema: 'claude-mcp' }), Object.freeze({ path: claudeArtifactPaths.plugin, required: true, schema: 'claude-plugin' }), + Object.freeze({ path: claudeArtifactPaths.settings, required: false, schema: 'claude-settings' }), Object.freeze({ path: codexArtifactPaths.marketplace, required: false, schema: 'codex-marketplace' }), Object.freeze({ path: codexBundleMcpPath, required: false, schema: 'codex-mcp' }), Object.freeze({ path: codexArtifactPaths.plugin, required: true, schema: 'codex-plugin' }), @@ -183,7 +184,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.5.0', + adapterRevision: '1.6.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 @@ -234,6 +235,8 @@ interface AgentsDocumentOptions { readonly lsp: boolean; /** True when the Cursor half emitted conventional `.mdc` rules. */ readonly rules: boolean; + /** True when the Claude half of this bundle emitted `settings.json`. */ + readonly settings: boolean; } const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions): string => { @@ -265,6 +268,11 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '- `.lsp.json` — Claude Code language-server configuration (plugin-root convention). Claude Code only; Codex and Cursor have no LSP surface.', ] : []), + ...(options.settings + ? [ + '- `settings.json` — Claude Code default configuration applied when the plugin is enabled (plugin-root convention). Claude Code only; Codex and Cursor have no plugin settings surface.', + ] + : []), ...(options.commands ? [ '- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.', @@ -490,6 +498,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { commands: selectedCommands.length > 0, lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), rules: selectedRules.length > 0, + settings: entries.some((entry) => entry.relativePath === claudeArtifactPaths.settings), }), kind: 'write', relativePath: 'AGENTS.md', @@ -588,6 +597,15 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), cursorAdapter.capabilities.rules!, ), + // Neither pinned non-Claude contract declares a plugin settings-defaults + // surface at all, so this intersection is honestly unavailable even + // though the Claude half still emits `settings.json`. + settings: intersectCapabilityStates( + claudeAdapter.capabilities.settings!, + unavailableCapability( + 'The pinned Codex and Cursor plugin contracts publish no plugin settings-defaults surface; plugin-root settings.json reaches Claude Code only.', + ), + ), skills: intersectCapabilityStates( intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), cursorAdapter.capabilities.skills!, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 9ea03b134..1e9abc1fe 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.", + "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.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -28,6 +28,11 @@ "bytes": 2279, "sha256": "f0c503ec8bc11c2ebeade8e8feed37a6c920525b0534438c37210cc50aa66a62", "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" + }, + "settings.schema.json": { + "bytes": 554, + "sha256": "9e86d8c5e4053e8de0e468d349e2c3dde5834d22d6769372b88570e301700073", + "url": "https://code.claude.com/docs/en/plugins" } }, "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Artifact validation can additionally invoke `claude plugin validate --strict`; Agent Bundle preserves host warnings unless its own strict option is enabled, and reports an explicit unavailable diagnostic when the CLI is absent.", diff --git a/packages/agent-bundle/src/adapters/schemas/claude/settings.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/settings.schema.json new file mode 100644 index 000000000..342bc13c6 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/claude/settings.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/settings.schema.json", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "agent": { "minLength": 1, "type": "string" }, + "subagentStatusLine": { + "additionalProperties": false, + "properties": { + "command": { "minLength": 1, "type": "string" }, + "type": { "const": "command" } + }, + "required": ["command", "type"], + "type": "object" + } + }, + "type": "object" +} diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 21859350d..a4fcaeb2d 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -130,6 +130,30 @@ it('reports Claude bin support without inventing coverage on other native hosts' expect(registry.supports('plugin', 'bin')).toBe(false); }); +it('reports Claude plugin settings support and honest unavailable composite coverage', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities.settings).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities.settings).toMatchObject({ + reason: expect.stringContaining('no plugin settings-defaults surface'), + state: 'unavailable', + }); + // Codex and Cursor declare no settings row at all, so an absent capability + // stays an honest "not declared" rather than an inferred support claim. + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities.settings).toBeUndefined(); + expect(registry.supports(target, 'settings')).toBe(false); + } + expect(registry.supports('claude', 'settings')).toBe(true); + expect(registry.supports('plugin', 'settings')).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 2beeae7ed..6ff830150 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.6.0', + adapterRevision: '1.7.0', observedVersion: '2.1.250', schemas: [ { @@ -121,6 +121,11 @@ it('records exact immutable metadata for every built-in target', () => { revision: '2.1.250', sha256: 'f0c503ec8bc11c2ebeade8e8feed37a6c920525b0534438c37210cc50aa66a62', }, + { + name: 'settings', + revision: '2.1.250', + sha256: '9e86d8c5e4053e8de0e468d349e2c3dde5834d22d6769372b88570e301700073', + }, ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ @@ -149,7 +154,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.5.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.6.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index a62fe0f3c..5a813648e 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1993,6 +1993,54 @@ it.each(['claude', 'codex'] as const)( }, ); +const claudeSettingsFiles = async (settings: string): Promise => { + const registry = createDefaultRegistry(); + const model: NormalizedPlugin = { + ...installSurfaceModel('claude'), + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + target: 'claude', + value: { settings: { agent: 'security-reviewer' } }, + }, + }, + }; + const files = registry.get('claude').plan(model).entries + .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') + .map((entry) => ({ + contents: entry.relativePath === 'settings.json' ? settings : entry.content, + kind: 'generated' as const, + path: `claude/${entry.relativePath}`, + })); + expect(files.some((file) => file.path === 'claude/settings.json')).toBe(true); + return writeArtifact(files, true, [targetFromRegistry(registry, 'claude')]); +}; + +it('accepts an emitted Claude settings document against its pinned schema', async () => { + const root = await claudeSettingsFiles('{"agent":"security-reviewer"}\n'); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('rejects a rehashed Claude settings document that carries an unsupported key', async () => { + const root = await claudeSettingsFiles('{"agent":"security-reviewer","statusLine":{"type":"command","command":"rows.sh"}}\n'); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([expect.objectContaining({ + code: 'AB6012', + generatedPath: 'claude/settings.json', + message: expect.stringContaining('"settings"'), + target: 'claude', + })]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ ...installSurfaceModel(target), metadata: { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 350748453..e8c091aaf 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -11,24 +11,146 @@ import type { NormalizedPlugin } from '../src/core/types.ts'; const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip; -const runClaudeValidation = async (cwd: string, marketplace: string): Promise => +interface ClaudeValidation { + readonly code: number | null; + readonly output: string; +} + +const runClaudeValidation = async (cwd: string, target: string): Promise => new Promise((resolvePromise, reject) => { - const child = spawn('claude', ['plugin', 'validate', '--strict', marketplace], { + const child = spawn('claude', ['plugin', 'validate', '--strict', target], { cwd, - stdio: ['ignore', 'ignore', 'ignore'], + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + output += chunk; + }); + child.stderr.on('data', (chunk: string) => { + output += chunk; }); child.once('error', reject); - child.once('close', resolvePromise); + child.once('close', (code) => resolvePromise({ code, output })); }); -nativeIt('accepts an emitted Claude plugin with bin under strict native validation', async () => { +const model: NormalizedPlugin = { + extensions: {}, + hooks: [], + marketplace: true, + mcpServers: [], + metadata: { + description: 'Review code and explain findings.', + id: 'plugin:review-tools', + name: 'review-tools', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + version: '1.2.3', + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: [{ + id: 'target:claude', + name: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], +}; + +const withClaudeSettings = (settings: unknown): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: { settings }, + }, + }, +}); + +const writeClaudeArtifact = async ( + root: string, + planned: NormalizedPlugin, +): Promise => { + const written: string[] = []; + for (const entry of claudeAdapter.plan(planned).entries) { + if (entry.kind !== 'write') continue; + const output = join(root, entry.relativePath); + await mkdir(join(output, '..'), { recursive: true }); + await writeFile(output, entry.content); + written.push(entry.relativePath); + } + return written; +}; + +nativeIt('accepts the emitted Claude marketplace under strict native validation', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-')); + + try { + await writeClaudeArtifact(root, model); + const validation = await runClaudeValidation(root, join(root, '.claude-plugin', 'marketplace.json')); + expect(validation.code, validation.output).toBe(0); + } 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-')); + + try { + const written = await writeClaudeArtifact(root, withClaudeSettings({ + agent: 'security-reviewer', + subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, + })); + expect(written).toContain('settings.json'); + + // Both documented validation entry points: the marketplace manifest and + // the plugin directory itself. + for (const target of [join(root, '.claude-plugin', 'marketplace.json'), root]) { + const validation = await runClaudeValidation(root, target); + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records that strict native validation never inspects plugin settings.json', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-settings-invalid-')); + + try { + await writeClaudeArtifact(root, model); + // Deliberately malformed against the documented two-key contract: an + // empty `agent`, the user-scope-only `statusLine` key, and `padding`, + // which is documented for `statusLine` alone. + await writeFile( + join(root, 'settings.json'), + '{"agent":"","statusLine":{"type":"command","command":"rows.sh"},"padding":3}\n', + ); + const validation = await runClaudeValidation(root, root); + + // The host validator ignores settings.json even under --strict, so the + // compiler's own claude.settings.* diagnostics are the only guard an + // author gets before the plugin is enabled in a session. + expect(validation.code, validation.output).toBe(0); + expect(validation.output).not.toContain('settings.json'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('accepts an emitted Claude plugin with bin under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-bin-')); const sourceRoot = join(root, 'authored-bin'); const source = join(sourceRoot, 'review-tool'); const outputRoot = join(root, 'plugin'); const executable = '#!/usr/bin/env sh\nprintf "reviewed\\n"\n'; - const model: NormalizedPlugin = { - extensions: {}, + const binModel: NormalizedPlugin = { + ...model, hostBins: [{ files: [{ bytes: Buffer.byteLength(executable), @@ -40,35 +162,18 @@ nativeIt('accepts an emitted Claude plugin with bin under strict native validati source: sourceRoot, target: 'claude', }], - hooks: [], - marketplace: true, - mcpServers: [], - metadata: { - description: 'Review code and explain findings.', - id: 'plugin:review-tools', - name: 'review-tools', - provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, - version: '1.2.3', - }, - runtime: { node: '22.12.0' }, - scripts: [], - skills: [], - targets: [{ - id: 'target:claude', - name: 'claude', - provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, - }], }; try { await mkdir(sourceRoot, { recursive: true }); await writeFile(source, executable); await chmod(source, 0o751); - await emitPlanEntries({ entries: claudeAdapter.plan(model).entries, root: outputRoot }); - await expect(runClaudeValidation( + await emitPlanEntries({ entries: claudeAdapter.plan(binModel).entries, root: outputRoot }); + const validation = await runClaudeValidation( outputRoot, join(outputRoot, '.claude-plugin', 'marketplace.json'), - )).resolves.toBe(0); + ); + expect(validation.code, validation.output).toBe(0); } 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 91ba0ab06..050cfb4e5 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -120,6 +120,22 @@ const withClaudeBin = ( }], }); +const withClaudeSettings = ( + model: NormalizedPlugin, + settings: unknown, +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: { settings }, + }, + }, +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -730,6 +746,159 @@ it('withholds Claude LSP configuration when two servers claim one extension', () expect(plan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); }); +it('emits Claude plugin default settings at the plugin root with the declaring config as its input', () => { + const model = withClaudeSettings(plugin, { + agent: 'security-reviewer', + subagentStatusLine: { command: '~/.claude/subagent-statusline.sh', type: 'command' }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + const documents = writeContents(model, 'claude'); + const entry = plan.entries.find((candidate) => candidate.relativePath === 'settings.json'); + + expect(JSON.parse(documents['settings.json']!)).toEqual({ + agent: 'security-reviewer', + subagentStatusLine: { command: '~/.claude/subagent-statusline.sh', type: 'command' }, + }); + expect(entry?.sourceInputs).toEqual(['/workspace/agent-bundle.config.ts']); + // The manifest keeps no `settings` pointer: settings.json is discovered by + // convention and takes priority over manifest `settings` anyway. + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('settings'); + // Declaring `agent` is shippable host configuration, so the deferred + // plugin-agents component is a warning rather than a build failure. + expect(plan.diagnostics).toEqual([{ + code: 'claude.settings.agent.deferred', + message: expect.stringContaining('agents component stays deferred'), + severity: 'warning', + target: 'claude', + }]); +}); + +it('emits a Claude subagent status line alone without any diagnostic', () => { + const model = withClaudeSettings(plugin, { + subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(writeContents(model, 'claude')['settings.json']!)).toEqual({ + subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, + }); +}); + +it('emits no Claude settings document when the host config declares none', () => { + const plan = createDefaultRegistry().get('claude').plan(plugin); + + expect(plan.entries.some((entry) => entry.relativePath === 'settings.json')).toBe(false); +}); + +it.each([ + { + code: 'claude.settings.declaration.invalid', + label: 'a non-object declaration', + settings: './settings.json', + }, + { + code: 'claude.settings.declaration.invalid', + label: 'an empty declaration', + settings: {}, + }, + { + code: 'claude.settings.field.unknown', + label: 'an unknown settings key', + settings: { agent: 'security-reviewer', statusLine: { command: 'row.sh', type: 'command' } }, + }, + { + code: 'claude.settings.agent.invalid', + label: 'an empty agent name', + settings: { agent: '' }, + }, + { + code: 'claude.settings.agent.invalid', + label: 'a non-string agent', + settings: { agent: 7 }, + }, + { + code: 'claude.settings.token.unsupported', + label: 'a tokenized agent name', + settings: { agent: `${pathTokens.pluginRoot}/agents/reviewer.md` }, + }, + { + code: 'claude.settings.statusline.invalid', + label: 'a non-object subagent status line', + settings: { subagentStatusLine: './rows.sh' }, + }, + { + code: 'claude.settings.statusline.field.unknown', + label: 'the statusLine-only padding field', + settings: { subagentStatusLine: { command: 'rows.sh', padding: 0, type: 'command' } }, + }, + { + code: 'claude.settings.statusline.type.invalid', + label: 'an undocumented subagent status line type', + settings: { subagentStatusLine: { command: 'rows.sh', type: 'inline' } }, + }, + { + code: 'claude.settings.statusline.command.required', + label: 'a missing subagent status line command', + settings: { subagentStatusLine: { type: 'command' } }, + }, + { + code: 'claude.settings.token.unsupported', + label: 'a tokenized subagent status line command', + settings: { subagentStatusLine: { command: `${pathTokens.pluginRoot}/rows.sh`, type: 'command' } }, + }, +])('rejects $label without emitting Claude settings', ({ code, settings }) => { + const model = withClaudeSettings(plugin, settings); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); + expect(plan.diagnostics.every((diagnostic) => diagnostic.severity === 'error')).toBe(true); + expect(plan.entries.some((entry) => entry.relativePath === 'settings.json')).toBe(false); +}); + +it('pins the closed Claude plugin settings schema to the two documented keys', async () => { + const schema = (await import('../src/adapters/schemas/claude/settings.schema.json', { + with: { type: 'json' }, + })).default; + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validate = validator.compile(schema); + + for (const settings of [ + { agent: 'security-reviewer' }, + { subagentStatusLine: { command: '~/.claude/subagent-statusline.sh', type: 'command' } }, + { agent: 'security-reviewer', subagentStatusLine: { command: 'rows.sh', type: 'command' } }, + ]) { + expect(validate(settings), JSON.stringify(validate.errors)).toBe(true); + } + for (const settings of [ + // An empty document declares no default configuration at all. + {}, + { agent: '' }, + { agent: 7 }, + { statusLine: { command: 'rows.sh', type: 'command' } }, + { subagentStatusLine: 'rows.sh' }, + { subagentStatusLine: { type: 'command' } }, + { subagentStatusLine: { command: '', type: 'command' } }, + { subagentStatusLine: { command: 'rows.sh', type: 'inline' } }, + // `padding` is documented for the user statusLine, not for a plugin default. + { subagentStatusLine: { command: 'rows.sh', padding: 0, type: 'command' } }, + ]) { + expect(validate(settings)).toBe(false); + } +}); + +it('registers the Claude settings document against its pinned schema contract', () => { + const validation = createDefaultRegistry().artifactValidation('claude'); + const settingsSchema = validation.schemas.find((schema) => schema.name === 'settings'); + + expect(validation.documents).toContainEqual({ path: 'settings.json', required: false, schema: 'settings' }); + expect(settingsSchema?.validate({ agent: 'security-reviewer' })).toEqual([]); + expect(settingsSchema?.validate({ agent: 'security-reviewer', statusLine: {} })).toEqual([ + expect.objectContaining({ instancePath: '' }), + ]); +}); + it.each(['codex', 'claude'] as const)( 'keeps a user-declared plugin-root env anchor over the injected %s value', (target) => { diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 8e0f7e138..4c9188530 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -297,6 +297,35 @@ it('emits the Claude bin directory from the unified plugin target', () => { expect(writeContents(model)['AGENTS.md']).toContain('`bin/`'); }); +it('emits Claude-only plugin default settings at the shared composite root', () => { + const model = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config' as const, sourcePath: configPath }, + target: 'claude', + value: { + settings: { + subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, + }, + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['settings.json']!)).toEqual({ + subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, + }); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('settings'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('settings'); + expect(documents['AGENTS.md']).toContain('- `settings.json` — Claude Code default configuration'); +}); + it('emits each shared surface exactly once with no duplicate artifact paths', () => { const plan = planBundle(bundleModel); const paths = plan.entries.map((entry) => entry.relativePath);