From 723d043e17750053f234fc203a9f20bb45be812e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 07:14:02 +0000 Subject: [PATCH] feat(claude): emit experimental themes and monitors (#187) --- .../claude-experimental-themes-monitors.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 25 ++ packages/agent-bundle/src/adapters/claude.ts | 319 +++++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 10 +- .../adapters/schemas/claude/PROVENANCE.json | 12 +- .../schemas/claude/monitors.schema.json | 24 ++ .../adapters/schemas/claude/theme.schema.json | 15 + packages/agent-bundle/src/adapters/types.ts | 2 +- .../src/build/validate-artifact.ts | 88 +++-- .../tests/adapter-capability-states.test.ts | 25 ++ .../tests/adapter-metadata.test.ts | 14 +- .../tests/artifact-validator.test.ts | 61 ++++ .../tests/host-adapters.native.test.ts | 104 ++++++ .../agent-bundle/tests/host-adapters.test.ts | 142 ++++++++ .../agent-bundle/tests/plugin-bundle.test.ts | 50 +++ 15 files changed, 857 insertions(+), 39 deletions(-) create mode 100644 .changeset/claude-experimental-themes-monitors.md create mode 100644 packages/agent-bundle/src/adapters/schemas/claude/monitors.schema.json create mode 100644 packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json diff --git a/.changeset/claude-experimental-themes-monitors.md b/.changeset/claude-experimental-themes-monitors.md new file mode 100644 index 000000000..8e3d70cc6 --- /dev/null +++ b/.changeset/claude-experimental-themes-monitors.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add validated Claude Code experimental theme and monitor declarations, including default-location emission, monitor trigger checks, and host-availability warnings. 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 b40bbffb7..e5795b014 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 @@ -77,6 +77,12 @@ "semverRanges": true, "tagConvention": "{name}--v{version}" }, + "experimentalThemes": { + "defaultDirectory": "themes", + "experimental": true, + "manifestField": "experimental.themes", + "readOnly": true + }, "devtools": { "details": true, "listJson": true, @@ -136,6 +142,18 @@ "version" ] }, + "monitors": { + "commandTokens": ["${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PLUGIN_DATA}", "${CLAUDE_PROJECT_DIR}", "${ENV_VAR}"], + "config": "monitors/monitors.json", + "defaultWhen": "always", + "experimental": true, + "interactiveCliOnly": true, + "manifestField": "experimental.monitors", + "monitorToolRequired": true, + "projectScopeSkillsDirectoryPlugins": false, + "userConfigSubstitution": false, + "unsandboxed": true + }, "manifest": ".claude-plugin/plugin.json", "marketplace": ".claude-plugin/marketplace.json", "paths": { @@ -245,6 +263,13 @@ "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that an existing `enabledPlugins` user setting and an active dependency requirement both take precedence over plugin.json `defaultEnabled`, while a marketplace entry's `defaultEnabled` takes precedence over the plugin manifest value.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that wrong types make most manifest fields fail plugin loading, but non-object `experimental` and `metadata` values are ignored with a `claude plugin validate` warning; `--strict` promotes warnings to failure. Before v2.1.222, `metadata` was treated as unrecognized.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents custom component path fields as string or array values: commands, agents, workflows, outputStyles, experimental.themes, and experimental.monitors replace their default scans, while skills adds to the default scan. Keeping a replaced default requires listing it explicitly, for example `\"commands\": [\"./commands/\", \"./extras/\"]`.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents experimental plugin themes as JSON files in the default themes/ directory, each with a required base preset and sparse overrides map; the documented example also names the theme. Selected plugin themes persist as custom:: and are read-only until Ctrl+E copies one into ~/.claude/themes/ for local editing.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents experimental plugin monitors at monitors/monitors.json as an array whose entries require unique name, persistent shell command, and description; optional when defaults to always and also accepts on-skill-invoke:.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents monitor command substitution for ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, ${CLAUDE_PROJECT_DIR}, and environment variables, but explicitly rejects ${user_config.*} because monitor commands run through a shell; monitor processes also receive no CLAUDE_PLUGIN_OPTION_ environment variables.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that monitors run only in interactive CLI sessions, unsandboxed at hook trust level, are skipped when the Monitor tool is unavailable, do not load for project-scope skills-directory plugins, survive mid-session plugin disable until session end, and require a session restart after plugin update.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference marks themes and monitors experimental and warns their manifest schema may change between releases. Top-level themes and monitors still work with a validation warning, while a future release will require experimental.themes and experimental.monitors; Agent Bundle emits neither manifest field because the generated documents use the default locations.", + "2026-09-01: Local host proof against Claude Code 2.1.257 shows `claude plugin validate --strict` accepts emitted default-location themes and monitors, but also accepts a malformed theme missing base with a non-string override and a malformed monitor missing command without naming either file. The compiler's claude.themes.* and claude.monitors.* diagnostics and pinned schemas are therefore the content-validation guard.", + "2026-09-01: The same Claude Code 2.1.257 strict probe rejects a deprecated top-level `monitors` manifest key, confirming that the documented migration warning is promoted to failure by `--strict`; default-location emission avoids that unstable manifest key entirely.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference requires component paths to be relative to the plugin root and start with `./`, except skills also accepts `.` starting in v2.1.221; before that version `.` failed manifest validation. A marketplace-root source that declares specific skills subdirectories replaces the default skills scan.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that a default folder shadowed by a replacing manifest path still allows the plugin to load but warns in `claude plugin list` and the `/plugin` detail view. Its file-locations table defines commands/ as flat Markdown Skill files and recommends skills/ for new plugins; Agent Bundle already emits flat `.md` commands in the canonical commands/ directory and deliberately leaves custom-path discovery to the host.", "2026-09-01: Local host proof against the observed Claude Code 2.1.257 binary (newer than the pinned 2.1.250 table): `claude plugin validate --strict` accepts an emitted plugin manifest containing displayName, object metadata, and defaultEnabled, and separately accepts `\"commands\": \"./custom/deploy.md\"` when that flat Markdown file exists and no default commands/ directory exists (host-adapters.native.test.ts). These positive probes establish acceptance; they do not claim that the CLI checks custom-path existence or contents.", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 6110819ed..32eba1b0b 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -40,8 +40,10 @@ import hooksSchema from './schemas/claude/hooks.schema.json' with { type: 'json' 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 monitorsSchema from './schemas/claude/monitors.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 themeSchema from './schemas/claude/theme.schema.json' with { type: 'json' }; import { stringify as stringifyYaml } from 'yaml'; import { commandWriteEntries, @@ -163,6 +165,25 @@ export interface ClaudeSettingsConfig { readonly subagentStatusLine?: ClaudeSubagentStatusLineConfig; } +/** One experimental Claude Code color theme emitted under the plugin-root `themes/` directory. */ +export interface ClaudeThemeConfig { + /** Built-in preset inherited before sparse token overrides are applied. */ + readonly base: string; + /** Display name shown in `/theme`; defaults to the declaration key. */ + readonly name?: string; + /** Sparse color-token overrides. Values stay host-defined strings rather than being narrowed to hex colors. */ + readonly overrides?: Readonly>; +} + +/** One experimental Claude Code background monitor emitted in `monitors/monitors.json`. */ +export interface ClaudeMonitorConfig { + readonly command: string; + readonly description: string; + /** Identifier unique within this plugin. */ + readonly name: string; + readonly when?: string; +} + /** * One Claude Code plugin dependency. Without `marketplace`, Claude resolves * the name in the declaring plugin's marketplace. Cross-marketplace @@ -206,9 +227,13 @@ export interface ClaudeHostConfig extends AgentBundleHostConfig { readonly lspServers?: Readonly>; /** Free-form catalog or entitlement data that Claude Code preserves but does not interpret. */ readonly metadata?: Readonly>; + /** Experimental session-lifetime background monitors discovered from `monitors/monitors.json`. */ + readonly monitors?: readonly ClaudeMonitorConfig[]; /** Project-authored Markdown files copied to the plugin-root `output-styles/` convention. */ readonly outputStyles?: string; readonly settings?: ClaudeSettingsConfig; + /** Experimental color themes emitted one file per declaration key under `themes/`. */ + readonly themes?: Readonly>; /** Enable-time options copied into `.claude-plugin/plugin.json`. */ readonly userConfig?: Readonly>; /** Project-authored script files copied to the plugin-root `workflows/` convention. */ @@ -233,8 +258,10 @@ export const claudeArtifactPaths = Object.freeze({ lsp: '.lsp.json', marketplace: '.claude-plugin/marketplace.json', mcp: '.mcp.json', + monitors: 'monitors/monitors.json', plugin: '.claude-plugin/plugin.json', settings: 'settings.json', + themes: 'themes/*.json', }); const validator = createAdapterValidator(); const validatePlugin = validator.compile(pluginSchema); @@ -242,7 +269,9 @@ const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); const validateHooks = validator.compile(hooksSchema); const validateLsp = validator.compile(lspSchema); +const validateMonitors = validator.compile(monitorsSchema); const validateSettings = validator.compile(settingsSchema); +const validateTheme = validator.compile(themeSchema); /** The pinned Claude hooks validator, shared with the unified bundle adapter. */ export const claudeHooksValidator = validateHooks; @@ -266,7 +295,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.12.0', + adapterRevision: '1.13.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -278,16 +307,20 @@ const artifactValidation = deepFreeze({ Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'lsp' }), Object.freeze({ path: '.claude-plugin/marketplace.json', required: false, schema: 'marketplace' }), Object.freeze({ path: '.mcp.json', required: false, schema: 'mcp' }), + Object.freeze({ path: claudeArtifactPaths.monitors, required: false, schema: 'monitors' }), Object.freeze({ path: '.claude-plugin/plugin.json', required: true, schema: 'plugin' }), Object.freeze({ path: claudeArtifactPaths.settings, required: false, schema: 'settings' }), + Object.freeze({ path: claudeArtifactPaths.themes, required: false, schema: 'theme' }), ], schemas: [ Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }), Object.freeze({ name: 'lsp', validate: validateJsonSchemaDocument(validateLsp) }), Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }), Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }), + Object.freeze({ name: 'monitors', validate: validateJsonSchemaDocument(validateMonitors) }), Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }), Object.freeze({ name: 'settings', validate: validateJsonSchemaDocument(validateSettings) }), + Object.freeze({ name: 'theme', validate: validateJsonSchemaDocument(validateTheme) }), ], }); @@ -1543,6 +1576,248 @@ export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan return { diagnostics, document, sourceInputs: inputs }; }; +const themeFields: ReadonlySet = new Set(['base', 'name', 'overrides']); +const themeKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; + +interface ClaudeThemeDocument { + readonly document: Record; + readonly relativePath: string; +} + +interface ClaudeThemesPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly documents: readonly ClaudeThemeDocument[]; + readonly sourceInputs: readonly string[]; +} + +const noThemesPlan: ClaudeThemesPlan = deepFreeze({ + diagnostics: [], + documents: [], + sourceInputs: [], +}); + +/** Lowers `claude.themes` to one closed JSON document per declaration key. */ +export const planClaudeThemes = (model: NormalizedPlugin): ClaudeThemesPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noThemesPlan; + const declared = extension.value['themes']; + if (declared === undefined) return noThemesPlan; + const diagnostics: Diagnostic[] = []; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!isDataRecord(declared) || Object.keys(declared).length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.themes.declaration.invalid', + 'Claude themes must be a nonempty object mapping safe theme file stems to theme declarations.', + )); + return { diagnostics, documents: [], sourceInputs: inputs }; + } + + const documents: ClaudeThemeDocument[] = []; + for (const key of Object.keys(declared).sort()) { + if (!themeKeyPattern.test(key)) { + diagnostics.push(errorDiagnostic( + 'claude.themes.key.invalid', + `Claude theme key "${key}" must be a safe file stem beginning with an ASCII letter or digit and containing only letters, digits, dots, underscores, or hyphens.`, + )); + continue; + } + const theme = declared[key]; + if (!isDataRecord(theme)) { + diagnostics.push(errorDiagnostic( + 'claude.themes.entry.invalid', + `Claude theme "${key}" must be an object declaring base and optional name and overrides fields.`, + )); + continue; + } + for (const field of Object.keys(theme).sort()) { + if (themeFields.has(field)) continue; + diagnostics.push(errorDiagnostic( + 'claude.themes.field.unknown', + `Claude theme "${key}" declares unknown field "${field}"; the pinned experimental contract admits only base, name, and overrides.`, + )); + } + + const document: Record = Object.create(null) as Record; + const base = theme['base']; + if (typeof base !== 'string' || base.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.themes.base.required', + `Claude theme "${key}" requires a nonempty base preset.`, + )); + } else { + document['base'] = base; + } + const name = theme['name']; + if (name !== undefined && (typeof name !== 'string' || name.length === 0)) { + diagnostics.push(errorDiagnostic( + 'claude.themes.name.invalid', + `Claude theme "${key}" name must be a nonempty string when declared.`, + )); + } else { + document['name'] = name ?? key; + } + const overrides = theme['overrides']; + if (overrides !== undefined) { + if (!isDataRecord(overrides)) { + diagnostics.push(errorDiagnostic( + 'claude.themes.overrides.invalid', + `Claude theme "${key}" overrides must be an object mapping color tokens to strings.`, + )); + } else { + const plannedOverrides: Record = Object.create(null) as Record; + for (const token of Object.keys(overrides).sort()) { + const value = overrides[token]; + if (typeof value !== 'string' || value.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.themes.overrides.value.invalid', + `Claude theme "${key}" override "${token}" must be a nonempty string; the host documentation does not restrict values to hexadecimal colors.`, + )); + continue; + } + plannedOverrides[token] = value; + } + document['overrides'] = plannedOverrides; + } + } + const valid = validateTheme(document); + diagnostics.push(...schemaDiagnostics('theme', valid, validateTheme.errors)); + if (valid) documents.push({ document, relativePath: `themes/${key}.json` }); + } + if (hasErrors(diagnostics)) return { diagnostics, documents: [], sourceInputs: inputs }; + return { diagnostics, documents, sourceInputs: inputs }; +}; + +const monitorFields: ReadonlySet = new Set(['command', 'description', 'name', 'when']); +const monitorSkillPrefix = 'on-skill-invoke:'; + +interface ClaudeMonitorsPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: readonly Record[]; + readonly sourceInputs: readonly string[]; +} + +const noMonitorsPlan: ClaudeMonitorsPlan = deepFreeze({ + diagnostics: [], + sourceInputs: [], +}); + +/** Lowers `claude.monitors` to the default plugin-root monitor array document. */ +export const planClaudeMonitors = ( + model: NormalizedPlugin, + targetName: string, +): ClaudeMonitorsPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noMonitorsPlan; + const declared = extension.value['monitors']; + if (declared === undefined) return noMonitorsPlan; + const diagnostics: Diagnostic[] = []; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!Array.isArray(declared) || declared.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.declaration.invalid', + 'Claude monitors must be a nonempty array of background monitor declarations.', + )); + return { diagnostics, sourceInputs: inputs }; + } + + const availableSkills = new Set(model.skills + .filter((skill) => skill.targets.includes(targetName)) + .map((skill) => skill.name)); + const names = new Set(); + const document: Record[] = []; + for (const [index, monitor] of declared.entries()) { + if (!isDataRecord(monitor)) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.entry.invalid', + `Claude monitors[${index}] must be an object declaring name, command, and description.`, + )); + continue; + } + for (const field of Object.keys(monitor).sort()) { + if (monitorFields.has(field)) continue; + diagnostics.push(errorDiagnostic( + 'claude.monitors.field.unknown', + `Claude monitors[${index}] declares unknown field "${field}"; the pinned experimental contract admits only name, command, description, and when.`, + )); + } + + const planned: Record = Object.create(null) as Record; + const name = monitor['name']; + if (typeof name !== 'string' || name.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.name.required', + `Claude monitors[${index}] requires a nonempty name unique within the plugin.`, + )); + } else if (names.has(name)) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.name.duplicate', + `Claude monitor name "${name}" is declared more than once; names prevent duplicate processes when the plugin reloads or a skill is invoked again.`, + )); + } else { + names.add(name); + planned['name'] = name; + } + + const command = monitor['command']; + if (typeof command !== 'string' || command.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.command.required', + `Claude monitors[${index}] requires a nonempty persistent shell command.`, + )); + } else if (command.includes('${user_config.')) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.command.userConfig', + `Claude monitors[${index}] command cannot reference \`\${user_config.*}\`; Claude Code runs monitor commands through a shell and rejects them instead of substituting the value, and monitor processes do not receive CLAUDE_PLUGIN_OPTION_ environment variables.`, + )); + } else { + planned['command'] = command; + } + + const description = monitor['description']; + if (typeof description !== 'string' || description.length === 0) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.description.required', + `Claude monitors[${index}] requires a nonempty description shown in the task panel and notification summaries.`, + )); + } else { + planned['description'] = description; + } + + const when = monitor['when']; + if (when !== undefined) { + if (when === 'always') { + planned['when'] = when; + } else if (typeof when === 'string' && when.startsWith(monitorSkillPrefix)) { + const skill = when.slice(monitorSkillPrefix.length); + if (skill.length === 0 || !availableSkills.has(skill)) { + diagnostics.push(errorDiagnostic( + 'claude.monitors.when.invalid', + `Claude monitors[${index}] when must name a skill emitted by this plugin after "${monitorSkillPrefix}"; no selected skill "${skill}" exists.`, + )); + } else { + planned['when'] = when; + } + } else { + diagnostics.push(errorDiagnostic( + 'claude.monitors.when.invalid', + `Claude monitors[${index}] when must be "always" or "${monitorSkillPrefix}".`, + )); + } + } + document.push(planned); + } + if (hasErrors(diagnostics)) return { diagnostics, sourceInputs: inputs }; + + const valid = validateMonitors(document); + diagnostics.push(...schemaDiagnostics('monitors', valid, validateMonitors.errors)); + if (!valid) return { diagnostics, sourceInputs: inputs }; + diagnostics.push(warningDiagnostic( + 'claude.monitors.availability', + 'Claude plugin monitors run only in interactive CLI sessions, unsandboxed at the same trust level as hooks; hosts without the Monitor tool skip them, and project-scope skills-directory plugins do not load them. Disabling a plugin does not stop monitors already running, and plugin updates require a session restart before monitor changes apply.', + )); + return { diagnostics, document, sourceInputs: inputs }; +}; + export interface ClaudeArtifactPlanOptions { /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ readonly targetName?: string; @@ -1593,6 +1868,10 @@ export const planClaudeArtifacts = ( diagnostics.push(...workflows.diagnostics); const settings = planClaudeSettings(model); diagnostics.push(...settings.diagnostics); + const themes = planClaudeThemes(model); + diagnostics.push(...themes.diagnostics); + const monitors = planClaudeMonitors(model, targetName); + diagnostics.push(...monitors.diagnostics); const dependencies = planClaudeDependencies(model); diagnostics.push(...dependencies.diagnostics); const generatedHooks = planHooks(model, targetName, hookContract); @@ -1647,6 +1926,20 @@ export const planClaudeArtifacts = ( sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...settings.sourceInputs), }); } + for (const theme of themes.documents) { + hostDocuments.push({ + document: theme.document, + relativePath: theme.relativePath, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...themes.sourceInputs), + }); + } + if (monitors.document !== undefined) { + hostDocuments.push({ + document: monitors.document, + relativePath: claudeArtifactPaths.monitors, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...monitors.sourceInputs), + }); + } const basePlan = standardPluginArtifactPlan({ additionalPluginSourceInputs: sourceInputs( @@ -1764,6 +2057,22 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude contract does not support both required modern MCP transports.', ), + monitors: capabilityStateFromSupport( + capabilityTable.plugin.monitors.commandTokens.length === 4 && + ['${CLAUDE_PLUGIN_ROOT}', '${CLAUDE_PLUGIN_DATA}', '${CLAUDE_PROJECT_DIR}', '${ENV_VAR}'] + .every((token) => capabilityTable.plugin.monitors.commandTokens.includes(token)) && + capabilityTable.plugin.monitors.config === claudeArtifactPaths.monitors && + capabilityTable.plugin.monitors.defaultWhen === 'always' && + capabilityTable.plugin.monitors.experimental && + capabilityTable.plugin.monitors.interactiveCliOnly && + capabilityTable.plugin.monitors.manifestField === 'experimental.monitors' && + capabilityTable.plugin.monitors.monitorToolRequired && + capabilityTable.plugin.monitors.projectScopeSkillsDirectoryPlugins === false && + capabilityTable.plugin.monitors.userConfigSubstitution === false && + capabilityTable.plugin.monitors.unsandboxed, + evidence, + 'The pinned Claude plugin contract does not document experimental background monitors.', + ), outputStyles: capabilityStateFromSupport( capabilityTable.plugin.outputStyles.directory === 'output-styles' && capabilityTable.plugin.outputStyles.manifestField === 'outputStyles' && @@ -1787,6 +2096,14 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not support skills.', ), + themes: capabilityStateFromSupport( + capabilityTable.plugin.experimentalThemes.defaultDirectory === 'themes' && + capabilityTable.plugin.experimentalThemes.experimental && + capabilityTable.plugin.experimentalThemes.manifestField === 'experimental.themes' && + capabilityTable.plugin.experimentalThemes.readOnly, + evidence, + 'The pinned Claude plugin contract does not document experimental themes.', + ), userConfig: capabilityStateFromSupport( capabilityTable.plugin.userConfig.sensitiveStorage && capabilityTable.plugin.userConfig.projectSettingsIgnored && diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 9188d314d..e10cfff59 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -160,8 +160,10 @@ const artifactValidation = deepFreeze({ Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'claude-lsp' }), Object.freeze({ path: claudeArtifactPaths.marketplace, required: false, schema: 'claude-marketplace' }), Object.freeze({ path: claudeArtifactPaths.mcp, required: false, schema: 'claude-mcp' }), + Object.freeze({ path: claudeArtifactPaths.monitors, required: false, schema: 'claude-monitors' }), Object.freeze({ path: claudeArtifactPaths.plugin, required: true, schema: 'claude-plugin' }), Object.freeze({ path: claudeArtifactPaths.settings, required: false, schema: 'claude-settings' }), + Object.freeze({ path: claudeArtifactPaths.themes, required: false, schema: 'claude-theme' }), 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' }), @@ -184,7 +186,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.11.0', + adapterRevision: '1.12.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 @@ -631,6 +633,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), cursorAdapter.capabilities.mcp!, ), + monitors: unavailableCapability( + 'The unified bundle emits Claude-only experimental background monitors, but the pinned Codex and Cursor contracts declare no shared monitor surface.', + ), outputStyles: unavailableCapability( 'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.', ), @@ -654,6 +659,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), cursorAdapter.capabilities.skills!, ), + themes: unavailableCapability( + 'The unified bundle emits Claude-only experimental themes, but the pinned Codex and Cursor contracts declare no shared theme surface.', + ), 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.', ), diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 41963a2e4..b62f277c8 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. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque.", + "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque. monitors.schema.json and theme.schema.json were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01), which marks both components experimental and warns their manifest schema may change between releases. The monitor schema is a nonempty array of closed entries requiring nonempty unique name, command, and description; optional when admits only always or on-skill-invoke:, and the plan tightens the latter by requiring the named skill to be emitted by this plugin. The schema and plan both reject ${user_config.*} in monitor commands because Claude Code rejects it at shell execution time and supplies no CLAUDE_PLUGIN_OPTION_ variables to monitors. The theme schema is closed, requires a nonempty base, and admits optional nonempty name plus a sparse string-valued overrides map. Agent Bundle further requires a nonempty themes declaration, safe file-stem keys, defaults omitted name to that key, and rejects empty override strings; it deliberately accepts any nonempty color string because the reference shows hex examples but does not mandate hex syntax. Generated themes and monitors use the default locations, so no unstable experimental.* manifest fields are emitted. Local proof against Claude Code 2.1.257 shows strict validation accepts valid emitted themes and monitors but does not inspect either default-location document's contents: malformed themes missing base and carrying non-string overrides pass, as do monitors missing command. The pinned schemas and compiler diagnostics are therefore the content guard. The same strict host rejects the deprecated top-level monitors manifest key, so default-location emission also avoids the warning-to-error migration path. The artifact contract records themes/*.json as a schema family and validates every concrete generated theme path.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -24,6 +24,11 @@ "sha256": "76ccf02c7bfe2d57945ba18e84da8d655529bd68b4d692f72bce28238c99067e", "url": "https://docs.anthropic.com/en/docs/claude-code/mcp" }, + "monitors.schema.json": { + "bytes": 679, + "sha256": "d45abdf7561e4316ba217ce9c2ac84f32e1049622b74abb081693b479a54b48d", + "url": "https://code.claude.com/docs/en/plugins-reference" + }, "plugin.schema.json": { "bytes": 6271, "sha256": "3c9943237da86fd08ae82f698cde36dbef2ecf742098c6961cedf481fe435c12", @@ -33,6 +38,11 @@ "bytes": 554, "sha256": "9e86d8c5e4053e8de0e468d349e2c3dde5834d22d6769372b88570e301700073", "url": "https://code.claude.com/docs/en/plugins" + }, + "theme.schema.json": { + "bytes": 458, + "sha256": "1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089", + "url": "https://code.claude.com/docs/en/plugins-reference" } }, "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/monitors.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/monitors.schema.json new file mode 100644 index 000000000..2ca7d04a1 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/claude/monitors.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/monitors.schema.json", + "items": { + "additionalProperties": false, + "properties": { + "command": { + "minLength": 1, + "not": { "pattern": "\\$\\{user_config\\." }, + "type": "string" + }, + "description": { "minLength": 1, "type": "string" }, + "name": { "minLength": 1, "type": "string" }, + "when": { + "pattern": "^(?:always|on-skill-invoke:.+)$", + "type": "string" + } + }, + "required": ["name", "command", "description"], + "type": "object" + }, + "minItems": 1, + "type": "array" +} diff --git a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json new file mode 100644 index 000000000..708e29366 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/theme.schema.json", + "additionalProperties": false, + "properties": { + "base": { "minLength": 1, "type": "string" }, + "name": { "minLength": 1, "type": "string" }, + "overrides": { + "additionalProperties": { "minLength": 1, "type": "string" }, + "type": "object" + } + }, + "required": ["base"], + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index ae24537e1..eb500ed75 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -164,7 +164,7 @@ export const ruleWriteEntries = ( /** One already-validated host-native document beyond the shared plugin set. */ export interface StandardPluginHostDocument { - readonly document: Record; + readonly document: unknown; readonly relativePath: string; readonly sourceInputs: readonly string[]; } diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index e800648c5..2e3f1b8cd 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -322,6 +322,17 @@ const validateSchemaDocument = ( } }; +const matchesArtifactDocumentPath = (contractPath: string, relativePath: string): boolean => { + const wildcard = contractPath.indexOf('*'); + if (wildcard === -1) return contractPath === relativePath; + if (contractPath.indexOf('*', wildcard + 1) !== -1) return false; + const prefix = contractPath.slice(0, wildcard); + const suffix = contractPath.slice(wildcard + 1); + if (!relativePath.startsWith(prefix) || !relativePath.endsWith(suffix)) return false; + const matched = relativePath.slice(prefix.length, relativePath.length - suffix.length); + return matched.length > 0 && !matched.includes('/'); +}; + const validateTargetContracts = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; @@ -365,47 +376,57 @@ const validateTargetContracts = async (options: { const validation = options.registry.artifactValidation(target.name); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { - const generatedPath = `${target.name}/${document.path}`; - if (!files.has(generatedPath)) { + const targetPrefix = `${target.name}/`; + const generatedPaths = document.path.includes('*') + ? [...files] + .filter((path) => + path.startsWith(targetPrefix) && + matchesArtifactDocumentPath(document.path, path.slice(targetPrefix.length))) + .sort((left, right) => left.localeCompare(right)) + : [`${targetPrefix}${document.path}`].filter((path) => files.has(path)); + if (generatedPaths.length === 0) { if (document.required) { diagnostics.push(diagnostic( 'AB6011', `Target ${JSON.stringify(target.name)} is missing required document ${JSON.stringify(document.path)}.`, - generatedPath, + `${targetPrefix}${document.path}`, target.name, )); } continue; } - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(resolve(options.artifactRoot, generatedPath), 'utf8')) as unknown; - } catch { - continue; - } - const validate = validators.get(document.schema); - if (validate === undefined) continue; - const issues = validateSchemaDocument(validate, parsed); - const issue = issues[0]; - if (issue !== undefined) { - diagnostics.push(diagnostic( - 'AB6012', - `Target ${JSON.stringify(target.name)} document ${JSON.stringify(document.path)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, - generatedPath, - target.name, - )); - } - if ( - document.path.endsWith('plugin.json') && - isRecord(parsed) && - typeof parsed.logo === 'string' - ) { - diagnostics.push(...manifestLogoPathDiagnostics({ - files, - generatedPath, - logo: parsed.logo, - target: target.name, - })); + for (const generatedPath of generatedPaths) { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(resolve(options.artifactRoot, generatedPath), 'utf8')) as unknown; + } catch { + continue; + } + const validate = validators.get(document.schema); + if (validate === undefined) continue; + const issues = validateSchemaDocument(validate, parsed); + const issue = issues[0]; + if (issue !== undefined) { + const relativePath = generatedPath.slice(targetPrefix.length); + diagnostics.push(diagnostic( + 'AB6012', + `Target ${JSON.stringify(target.name)} document ${JSON.stringify(relativePath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + generatedPath, + target.name, + )); + } + if ( + document.path.endsWith('plugin.json') && + isRecord(parsed) && + typeof parsed.logo === 'string' + ) { + diagnostics.push(...manifestLogoPathDiagnostics({ + files, + generatedPath, + logo: parsed.logo, + target: target.name, + })); + } } } } @@ -454,7 +475,8 @@ const isTargetArtifactPath = ( isAdapterRootDocument(relativePath, layout.rootDocuments) || relativePath === hookContract?.manifestPath || relativePath === mcpRuntime?.manifestPath || - registry.artifactValidation(target).documents.some((document) => document.path === relativePath); + registry.artifactValidation(target).documents.some((document) => + matchesArtifactDocumentPath(document.path, relativePath)); }; const validateArtifactOwnership = (options: { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index fbb843d8e..be71cd31a 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -221,6 +221,31 @@ it('reports Claude channels support and honest unavailable composite coverage', expect(registry.supports('plugin', 'channels')).toBe(false); }); +it.each([ + ['themes', 'experimental themes'], + ['monitors', 'background monitors'], +] as const)('reports Claude %s support without inventing shared composite coverage', (capability, reason) => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities[capability]).toMatchObject({ + reason: expect.stringContaining(reason), + state: 'unavailable', + }); + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities[capability]).toBeUndefined(); + expect(registry.supports(target, capability)).toBe(false); + } + expect(registry.supports('claude', capability)).toBe(true); + expect(registry.supports('plugin', capability)).toBe(false); +}); + it('reports Claude dependency support and honest unavailable composite coverage', () => { const registry = createDefaultRegistry(); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d8ba98c03..9645edc53 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.12.0', + adapterRevision: '1.13.0', observedVersion: '2.1.250', schemas: [ { @@ -116,6 +116,11 @@ it('records exact immutable metadata for every built-in target', () => { revision: '2.1.250', sha256: '76ccf02c7bfe2d57945ba18e84da8d655529bd68b4d692f72bce28238c99067e', }, + { + name: 'monitors', + revision: '2.1.250', + sha256: 'd45abdf7561e4316ba217ce9c2ac84f32e1049622b74abb081693b479a54b48d', + }, { name: 'plugin', revision: '2.1.250', @@ -126,6 +131,11 @@ it('records exact immutable metadata for every built-in target', () => { revision: '2.1.250', sha256: '9e86d8c5e4053e8de0e468d349e2c3dde5834d22d6769372b88570e301700073', }, + { + name: 'theme', + revision: '2.1.250', + sha256: '1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089', + }, ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ @@ -154,7 +164,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.11.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.12.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 2d0e4e7bb..e8d98daad 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -195,6 +195,38 @@ const customRegistry = (validate = validateCustomDocument): TargetRegistry => ne plan: () => ({ diagnostics: [], entries: [] }), } satisfies TargetAdapter); +const wildcardMetadata = Object.freeze({ + ...customMetadata, + schemas: Object.freeze([ + customMetadata.schemas[0]!, + Object.freeze({ name: 'theme', revision: 'custom-schema-v1', sha256: 'c'.repeat(64) }), + ]), +}); + +const wildcardRegistry = (): TargetRegistry => new TargetRegistry().register({ + artifactValidation: { + documents: [ + { path: 'document.json', required: true, schema: 'document' }, + { path: 'themes/*.json', required: false, schema: 'theme' }, + ], + schemas: [ + { name: 'document', validate: validateCustomDocument }, + { + name: 'theme', + validate: (document) => + typeof document === 'object' && document !== null && + typeof (document as { readonly base?: unknown }).base === 'string' + ? [] + : [{ instancePath: '/base', message: 'must be string' }], + }, + ], + }, + capabilities: {}, + metadata: wildcardMetadata, + name: customTarget, + plan: () => ({ diagnostics: [], entries: [] }), +} satisfies TargetAdapter); + const targetFromRegistry = (registry: TargetRegistry, name: string): ArtifactManifest['targets'][number] => { const metadata = registry.metadata(name); return { @@ -204,6 +236,35 @@ const targetFromRegistry = (registry: TargetRegistry, name: string): ArtifactMan }; }; +it('validates and owns every concrete document matched by an optional schema family path', async () => { + const registry = wildcardRegistry(); + const target = targetFromRegistry(registry, customTarget); + const validRoot = await writeArtifact([ + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"base":"dark"}\n', kind: 'generated', path: 'custom/themes/dracula.json' }, + ], true, [target]); + const invalidRoot = await writeArtifact([ + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"name":"Missing base"}\n', kind: 'generated', path: 'custom/themes/invalid.json' }, + ], true, [target]); + + try { + expect(await validateArtifact({ artifactRoot: validRoot, registry })).toEqual([]); + expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( + expect.objectContaining({ + code: 'AB6012', + generatedPath: 'custom/themes/invalid.json', + target: customTarget, + }), + ); + } finally { + await Promise.all([ + rm(validRoot, { force: true, recursive: true }), + rm(invalidRoot, { force: true, recursive: true }), + ]); + } +}); + const skillMarkdown = (name: string, body: string): string => [ '---', `name: ${name}`, diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index d653d07bf..f21dae457 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -70,6 +70,21 @@ const withClaudeSettings = (settings: unknown): NormalizedPlugin => ({ }, }); +const withClaudeExperimental = ( + experimental: Readonly<{ readonly monitors?: unknown; readonly themes?: unknown }>, +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: experimental, + }, + }, +}); + const withClaudeDependencies = (dependencies: unknown): NormalizedPlugin => ({ ...model, extensions: { @@ -204,6 +219,95 @@ nativeIt('records that strict native validation never inspects plugin settings.j } }); +nativeIt('accepts emitted Claude experimental themes and monitors under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-experimental-')); + + try { + const written = await writeClaudeArtifact(root, withClaudeExperimental({ + monitors: [{ + command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + description: 'Watch the review queue.', + name: 'review-queue', + when: 'always', + }], + themes: { + dracula: { + base: 'dark', + overrides: { claude: '#bd93f9', error: '#ff5555' }, + }, + }, + })); + expect(written).toEqual(expect.arrayContaining([ + 'monitors/monitors.json', + 'themes/dracula.json', + ])); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records whether strict native validation inspects monitors/monitors.json contents', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-monitors-invalid-')); + + try { + await writeClaudeArtifact(root, model); + await mkdir(join(root, 'monitors'), { recursive: true }); + await writeFile( + join(root, 'monitors', 'monitors.json'), + '[{"name":"missing-command","description":"Missing its required command."}]\n', + ); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + expect(validation.output).not.toContain('monitors.json'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records whether strict native validation inspects plugin theme contents', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-theme-invalid-')); + + try { + await writeClaudeArtifact(root, model); + await mkdir(join(root, 'themes'), { recursive: true }); + await writeFile( + join(root, 'themes', 'invalid.json'), + '{"name":"Missing base","overrides":{"error":7},"typo":true}\n', + ); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + expect(validation.output).not.toContain('invalid.json'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records that strict native validation rejects the deprecated top-level monitors key', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-monitors-top-level-')); + + try { + await writeClaudeArtifact(root, model); + const manifestPath = join(root, '.claude-plugin', 'plugin.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; + manifest['monitors'] = './monitors/monitors.json'; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const validation = await runClaudeValidation(root, root); + + expect(validation.code).not.toBe(0); + expect(validation.output).toContain('monitors'); + } 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'); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 4844d4bf8..d95541700 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -191,6 +191,23 @@ const withClaudeSettings = ( }, }); +const withClaudeExperimental = ( + model: NormalizedPlugin, + experimental: Readonly<{ readonly monitors?: unknown; readonly themes?: unknown }>, + target = 'claude', +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/experimental.config.ts' }, + target, + value: experimental, + }, + }, +}); + const withClaudeDependencies = ( model: NormalizedPlugin, dependencies: unknown, @@ -1487,6 +1504,131 @@ it('emits no Claude settings document when the host config declares none', () => expect(plan.entries.some((entry) => entry.relativePath === 'settings.json')).toBe(false); }); +it('emits validated Claude themes and monitors at their default experimental locations', () => { + const model = withClaudeExperimental(plugin, { + monitors: [{ + command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + description: 'Watch the review queue.', + name: 'review-queue', + when: 'on-skill-invoke:review', + }], + themes: { + dracula: { + base: 'dark', + overrides: { + claude: '#bd93f9', + error: '#ff5555', + success: '#50fa7b', + }, + }, + paper: { base: 'light', name: 'Paper' }, + }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + const documents = writeContents(model, 'claude'); + + expect(JSON.parse(documents['themes/dracula.json']!)).toEqual({ + base: 'dark', + name: 'dracula', + overrides: { + claude: '#bd93f9', + error: '#ff5555', + success: '#50fa7b', + }, + }); + expect(JSON.parse(documents['themes/paper.json']!)).toEqual({ base: 'light', name: 'Paper' }); + expect(JSON.parse(documents['monitors/monitors.json']!)).toEqual([{ + command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + description: 'Watch the review queue.', + name: 'review-queue', + when: 'on-skill-invoke:review', + }]); + for (const path of ['themes/dracula.json', 'themes/paper.json', 'monitors/monitors.json']) { + expect(plan.entries.find((entry) => entry.relativePath === path)?.sourceInputs) + .toEqual(['/workspace/agent-bundle.config.ts', '/workspace/experimental.config.ts']); + } + const manifest = JSON.parse(documents['.claude-plugin/plugin.json']!); + expect(manifest).not.toHaveProperty('themes'); + expect(manifest).not.toHaveProperty('monitors'); + expect(manifest).not.toHaveProperty('experimental'); + expect(plan.diagnostics).toEqual([{ + code: 'claude.monitors.availability', + message: expect.stringContaining('interactive CLI sessions'), + severity: 'warning', + target: 'claude', + }]); +}); + +it.each([ + { code: 'claude.themes.declaration.invalid', label: 'a non-object themes declaration', themes: [] }, + { code: 'claude.themes.declaration.invalid', label: 'an empty themes declaration', themes: {} }, + { code: 'claude.themes.key.invalid', label: 'an unsafe theme file stem', themes: { '../escape': { base: 'dark' } } }, + { code: 'claude.themes.entry.invalid', label: 'a non-object theme declaration', themes: { dark: 'dark' } }, + { code: 'claude.themes.field.unknown', label: 'an unknown theme field', themes: { dark: { base: 'dark', typo: true } } }, + { code: 'claude.themes.base.required', label: 'a missing theme base', themes: { dark: { name: 'Dark' } } }, + { code: 'claude.themes.base.required', label: 'an empty theme base', themes: { dark: { base: '' } } }, + { code: 'claude.themes.name.invalid', label: 'an empty theme display name', themes: { dark: { base: 'dark', name: '' } } }, + { code: 'claude.themes.overrides.invalid', label: 'a non-object overrides map', themes: { dark: { base: 'dark', overrides: [] } } }, + { code: 'claude.themes.overrides.value.invalid', label: 'a non-string override', themes: { dark: { base: 'dark', overrides: { error: 7 } } } }, + { code: 'claude.themes.overrides.value.invalid', label: 'an empty override', themes: { dark: { base: 'dark', overrides: { error: '' } } } }, +])('rejects $label without emitting Claude themes', ({ code, themes }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeExperimental(plugin, { themes })); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); + expect(plan.entries.some((entry) => entry.relativePath.startsWith('themes/'))).toBe(false); +}); + +it.each([ + { code: 'claude.monitors.declaration.invalid', label: 'a non-array monitors declaration', monitors: {} }, + { code: 'claude.monitors.declaration.invalid', label: 'an empty monitors declaration', monitors: [] }, + { code: 'claude.monitors.entry.invalid', label: 'a non-object monitor entry', monitors: ['watch'] }, + { code: 'claude.monitors.field.unknown', label: 'an unknown monitor field', monitors: [{ command: 'watch', description: 'Watch.', name: 'watch', typo: true }] }, + { code: 'claude.monitors.name.required', label: 'a missing monitor name', monitors: [{ command: 'watch', description: 'Watch.' }] }, + { code: 'claude.monitors.name.duplicate', label: 'a duplicate monitor name', monitors: [ + { command: 'watch-a', description: 'Watch A.', name: 'watch' }, + { command: 'watch-b', description: 'Watch B.', name: 'watch' }, + ] }, + { code: 'claude.monitors.command.required', label: 'an empty monitor command', monitors: [{ command: '', description: 'Watch.', name: 'watch' }] }, + { code: 'claude.monitors.command.userConfig', label: 'a user config substitution', monitors: [{ command: 'watch ${user_config.token}', description: 'Watch.', name: 'watch' }] }, + { code: 'claude.monitors.description.required', label: 'an empty monitor description', monitors: [{ command: 'watch', description: '', name: 'watch' }] }, + { code: 'claude.monitors.when.invalid', label: 'an unsupported monitor trigger', monitors: [{ command: 'watch', description: 'Watch.', name: 'watch', when: 'session-start' }] }, + { code: 'claude.monitors.when.invalid', label: 'an empty skill trigger', monitors: [{ command: 'watch', description: 'Watch.', name: 'watch', when: 'on-skill-invoke:' }] }, + { code: 'claude.monitors.when.invalid', label: 'a trigger for a missing plugin skill', monitors: [{ command: 'watch', description: 'Watch.', name: 'watch', when: 'on-skill-invoke:missing' }] }, +])('rejects $label without emitting Claude monitors', ({ code, monitors }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeExperimental(plugin, { monitors })); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); + expect(plan.entries.some((entry) => entry.relativePath === 'monitors/monitors.json')).toBe(false); +}); + +it('pins and registers the closed Claude theme and monitor schemas', async () => { + const [themeModule, monitorsModule] = await Promise.all([ + import('../src/adapters/schemas/claude/theme.schema.json', { with: { type: 'json' } }), + import('../src/adapters/schemas/claude/monitors.schema.json', { with: { type: 'json' } }), + ]); + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validateTheme = validator.compile(themeModule.default); + const validateMonitors = validator.compile(monitorsModule.default); + + expect(validateTheme({ base: 'dark', name: 'Dracula', overrides: { claude: '#bd93f9' } })).toBe(true); + expect(validateTheme({ base: 'dark', typo: true })).toBe(false); + expect(validateTheme({ name: 'No base' })).toBe(false); + expect(validateTheme({ base: 'dark', overrides: { error: 7 } })).toBe(false); + expect(validateMonitors([{ command: 'watch', description: 'Watch.', name: 'watch' }])).toBe(true); + expect(validateMonitors([])).toBe(false); + expect(validateMonitors([{ description: 'Watch.', name: 'watch' }])).toBe(false); + expect(validateMonitors([{ command: 'watch', description: 'Watch.', name: 'watch', when: 'later' }])).toBe(false); + + const validation = createDefaultRegistry().artifactValidation('claude'); + expect(validation.documents).toContainEqual({ path: 'themes/*.json', required: false, schema: 'theme' }); + expect(validation.documents).toContainEqual({ path: 'monitors/monitors.json', required: false, schema: 'monitors' }); + expect(validation.schemas.find((schema) => schema.name === 'theme') + ?.validate({ base: 'dark', name: 'Dracula' })).toEqual([]); + expect(validation.schemas.find((schema) => schema.name === 'monitors') + ?.validate([{ command: 'watch', description: 'Watch.', name: 'watch' }])).toEqual([]); +}); + it('emits Claude plugin dependencies in authored order with closed object keys and extension provenance', async () => { const model = withClaudeDependencies(plugin, [ 'audit-logger', diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index add920747..39be717ae 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -505,6 +505,56 @@ it('emits Claude-only plugin default settings at the shared composite root', () expect(documents['AGENTS.md']).toContain('- `settings.json` — Claude Code default configuration'); }); +it('emits Claude-only experimental themes and monitors 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: { + monitors: [{ + command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + description: 'Watch the review queue.', + name: 'review-queue', + when: 'on-skill-invoke:review', + }], + themes: { + dracula: { + base: 'dark', + overrides: { claude: '#bd93f9', error: '#ff5555' }, + }, + }, + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([expect.objectContaining({ + code: 'claude.monitors.availability', + severity: 'warning', + target: 'claude', + })]); + expect(JSON.parse(documents['themes/dracula.json']!)).toEqual({ + base: 'dark', + name: 'dracula', + overrides: { claude: '#bd93f9', error: '#ff5555' }, + }); + expect(JSON.parse(documents['monitors/monitors.json']!)).toEqual([{ + command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + description: 'Watch the review queue.', + name: 'review-queue', + when: 'on-skill-invoke:review', + }]); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('experimental'); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('experimental'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('experimental'); +}); + it('emits Claude-only dependencies from the unified plugin target', () => { const model = { ...bundleModel,