From 20ecfe25dca080da0faec4e0def00f3893bc9043 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 07:10:43 +0000 Subject: [PATCH] feat(claude): emit plugin workflows and output styles (#187) Keep bin compatibility by mirroring its source hooks and normalized payload fields per surface while sharing file enumeration and realpath containment. Emit only Claude's canonical workflows/ and output-styles/ directories, leaving manifest path fields and the plugin schema pin unchanged; workflows remain opaque, while output styles tighten to the documented .md format and native proof records that strict validation does not inspect frontmatter. --- .../claude-plugin-workflows-output-styles.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 19 +- packages/agent-bundle/src/adapters/claude.ts | 167 +++++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 28 ++- .../agent-bundle/src/adapters/registry.ts | 84 +++++++++ .../adapters/schemas/claude/PROVENANCE.json | 2 +- packages/agent-bundle/src/adapters/types.ts | 4 + packages/agent-bundle/src/config/index.ts | 2 + packages/agent-bundle/src/config/normalize.ts | 86 +++++++-- packages/agent-bundle/src/config/validate.ts | 12 ++ .../agent-bundle/src/core/project-context.ts | 34 ++++ packages/agent-bundle/src/core/types.ts | 21 ++- .../agent-bundle/src/dev/project-service.ts | 11 +- .../tests/adapter-capability-states.test.ts | 25 +++ .../tests/adapter-metadata.test.ts | 4 +- .../tests/host-adapters.native.test.ts | 74 ++++++++ .../agent-bundle/tests/host-adapters.test.ts | 132 ++++++++++++++ .../agent-bundle/tests/normalization.test.ts | 140 ++++++++++++++- .../agent-bundle/tests/plugin-bundle.test.ts | 52 ++++++ .../tests/prebuilt-payload.test.ts | 4 +- 20 files changed, 878 insertions(+), 28 deletions(-) create mode 100644 .changeset/claude-plugin-workflows-output-styles.md diff --git a/.changeset/claude-plugin-workflows-output-styles.md b/.changeset/claude-plugin-workflows-output-styles.md new file mode 100644 index 000000000..fbbb04e80 --- /dev/null +++ b/.changeset/claude-plugin-workflows-output-styles.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add Claude Code plugin workflow scripts and Markdown output styles as byte-faithful `workflows/` and `output-styles/` directory payloads. 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 ef2984e54..b40bbffb7 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 @@ -155,6 +155,13 @@ "skillsRootException": ".", "skillsRootExceptionSince": "2.1.221" }, + "outputStyles": { + "allowedSuffixes": [".md"], + "directory": "output-styles", + "frontmatterFields": ["description", "force-for-plugin", "keep-coding-instructions", "name"], + "manifestField": "outputStyles", + "replacesDefault": true + }, "settings": { "config": "settings.json", "placeholderSubstitution": false, @@ -175,6 +182,12 @@ "shellSubstitutionRejected": ["hookShellCommands", "monitorCommands", "mcpHeadersHelper"], "substitutionToken": "${user_config.KEY}", "types": ["boolean", "directory", "file", "number", "string"] + }, + "workflows": { + "directory": "workflows", + "fileContents": "opaque", + "manifestField": "workflows", + "replacesDefault": true } }, "tokens": { @@ -234,7 +247,11 @@ "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents custom component path fields as string or array values: commands, agents, workflows, outputStyles, experimental.themes, and experimental.monitors replace their default scans, while skills adds to the default scan. Keeping a replaced default requires listing it explicitly, for example `\"commands\": [\"./commands/\", \"./extras/\"]`.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference requires component paths to be relative to the plugin root and start with `./`, except skills also accepts `.` starting in v2.1.221; before that version `.` failed manifest validation. A marketplace-root source that declares specific skills subdirectories replaces the default skills scan.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that a default folder shadowed by a replacing manifest path still allows the plugin to load but warns in `claude plugin list` and the `/plugin` detail view. Its file-locations table defines commands/ as flat Markdown Skill files and recommends skills/ for new plugins; Agent Bundle already emits flat `.md` commands in the canonical commands/ directory and deliberately leaves custom-path discovery to the host.", - "2026-09-01: Local host proof against the observed Claude Code 2.1.257 binary (newer than the pinned 2.1.250 table): `claude plugin validate --strict` accepts an emitted plugin manifest containing displayName, object metadata, and defaultEnabled, and separately accepts `\"commands\": \"./custom/deploy.md\"` when that flat Markdown file exists and no default commands/ directory exists (host-adapters.native.test.ts). These positive probes establish acceptance; they do not claim that the CLI checks custom-path existence or contents." + "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.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin-root `workflows/` as workflow script files and `output-styles/` as output style definitions; their `workflows` and `outputStyles` manifest path fields accept string or array values and replace the corresponding default directory when present.", + "2026-09-01: https://code.claude.com/docs/en/output-styles documents custom output styles as Markdown files containing optional frontmatter metadata plus prompt instructions. The documented frontmatter fields are `name`, `description`, `keep-coding-instructions`, and plugin-only `force-for-plugin`; a filename supplies the style name when `name` is omitted.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents workflow scripts only as files in `workflows/`, without a deeper file schema, so Agent Bundle treats regular workflow files as opaque payloads.", + "2026-09-01: Local host proof against Claude Code 2.1.257: `claude plugin validate --strict` accepts an emitted plugin with `workflows/release-audit.js` and frontmatter-bearing `output-styles/terse.md`. A second probe accepts `output-styles/missing-frontmatter.md` containing plain Markdown and does not name that file, so the CLI does not inspect output-style frontmatter during strict plugin validation (host-adapters.native.test.ts)." ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 4c80a695e..6110819ed 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -6,6 +6,7 @@ import { type AgentBundleConfig, type AgentBundleHostConfig, type NormalizedMcpServer, + type NormalizedHostPayloadDirectory, type NormalizedPlugin, } from '../core/types.ts'; import { @@ -205,9 +206,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>; + /** Project-authored Markdown files copied to the plugin-root `output-styles/` convention. */ + readonly outputStyles?: string; readonly settings?: ClaudeSettingsConfig; /** Enable-time options copied into `.claude-plugin/plugin.json`. */ readonly userConfig?: Readonly>; + /** Project-authored script files copied to the plugin-root `workflows/` convention. */ + readonly workflows?: string; } export interface ClaudeConfigExtension { @@ -261,7 +266,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.11.0', + adapterRevision: '1.12.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -1287,6 +1292,125 @@ const planClaudeBin = (model: NormalizedPlugin, targetName: string): ClaudeBinPl return deepFreeze({ diagnostics, entries }); }; +interface ClaudePayloadDirectoryPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly entries: readonly TargetArtifactCopy[]; +} + +interface ClaudePayloadDirectoryOptions { + readonly configField: 'outputStyles' | 'workflows'; + readonly destination: 'output-styles' | 'workflows'; + readonly directories: readonly NormalizedHostPayloadDirectory[] | undefined; + readonly label: 'output styles' | 'workflows'; + readonly targetName: string; +} + +const planClaudePayloadDirectory = ({ + configField, + destination, + directories, + label, + targetName, +}: ClaudePayloadDirectoryOptions): ClaudePayloadDirectoryPlan => { + const diagnostics: Diagnostic[] = []; + const entries: TargetArtifactCopy[] = []; + const codePrefix = `claude.${configField}`; + for (const directory of directories ?? []) { + if (directory.target !== targetName) continue; + if (directory.issue !== undefined) { + switch (directory.issue) { + case 'missing': + diagnostics.push({ + ...errorDiagnostic( + `${codePrefix}.directory.missing`, + `Claude ${label} directory ${JSON.stringify(directory.source)} does not exist.`, + ), + recovery: `Create the configured Claude ${label} directory and add at least one file, then rebuild.`, + sourcePath: directory.provenance.sourcePath, + }); + break; + case 'empty': + diagnostics.push({ + ...errorDiagnostic( + `${codePrefix}.directory.empty`, + `Claude ${label} directory ${JSON.stringify(directory.source)} contains no files.`, + ), + recovery: `Add at least one file to the configured Claude ${label} directory, then rebuild.`, + sourcePath: directory.provenance.sourcePath, + }); + break; + case 'not-directory': + diagnostics.push({ + ...errorDiagnostic( + `${codePrefix}.directory.invalid`, + `Claude ${label} source ${JSON.stringify(directory.source)} must name a directory.`, + ), + recovery: `Set claude.${configField} to a nonempty directory path relative to the config file, then rebuild.`, + sourcePath: directory.provenance.sourcePath, + }); + break; + case 'outside': + diagnostics.push({ + ...errorDiagnostic( + `${codePrefix}.directory.outside`, + `Claude ${label} directory ${JSON.stringify(directory.source)} must resolve inside the project root.`, + ), + recovery: `Move the ${label} directory inside the project and update claude.${configField}, then rebuild.`, + sourcePath: directory.provenance.sourcePath, + }); + break; + case 'source-error': + diagnostics.push({ + ...errorDiagnostic(`${codePrefix}.source.error`, `Claude ${label} source resolution failed.`), + recovery: `Correct the claude.${configField} declaration so the adapter can read it, then rebuild.`, + sourcePath: directory.provenance.sourcePath, + }); + break; + case 'source-invalid': + diagnostics.push({ + ...errorDiagnostic( + `${codePrefix}.source.invalid`, + `Claude ${configField} must be a nonempty directory path.`, + ), + recovery: `Set claude.${configField} to a nonempty directory path relative to the config file, then rebuild.`, + sourcePath: directory.provenance.sourcePath, + }); + break; + default: { + const exhaustive: never = directory.issue; + return exhaustive; + } + } + continue; + } + if (configField === 'outputStyles') { + const invalidFiles = directory.files.filter((file) => !file.relativePath.endsWith('.md')); + if (invalidFiles.length > 0) { + diagnostics.push({ + ...errorDiagnostic( + 'claude.outputStyles.file.invalid', + `Claude output style file${invalidFiles.length === 1 ? '' : 's'} ${invalidFiles + .map((file) => JSON.stringify(file.relativePath)) + .join(', ')} must use the .md suffix.`, + ), + recovery: 'Rename every file in the configured Claude output styles directory to use the .md suffix, then rebuild.', + sourcePath: directory.provenance.sourcePath, + }); + continue; + } + } + entries.push(...directory.files.map((file): TargetArtifactCopy => ({ + bytes: file.bytes, + kind: 'copy', + prebuilt: true, + relativePath: `${destination}/${file.relativePath}`, + source: file.source, + sourceInputs: sourceInputs(directory.provenance.sourcePath, file.source), + }))); + } + 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 @@ -1451,6 +1575,22 @@ export const planClaudeArtifacts = ( diagnostics.push(...manifestMetadata.diagnostics); const bin = planClaudeBin(model, targetName); diagnostics.push(...bin.diagnostics); + const outputStyles = planClaudePayloadDirectory({ + configField: 'outputStyles', + destination: 'output-styles', + directories: model.hostOutputStyles, + label: 'output styles', + targetName, + }); + diagnostics.push(...outputStyles.diagnostics); + const workflows = planClaudePayloadDirectory({ + configField: 'workflows', + destination: 'workflows', + directories: model.hostWorkflows, + label: 'workflows', + targetName, + }); + diagnostics.push(...workflows.diagnostics); const settings = planClaudeSettings(model); diagnostics.push(...settings.diagnostics); const dependencies = planClaudeDependencies(model); @@ -1537,6 +1677,8 @@ export const planClaudeArtifacts = ( entries: sortedEntries([ ...basePlan.entries, ...bin.entries, + ...outputStyles.entries, + ...workflows.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), }), model, targetName === 'plugin' ? 'plugin' : 'claude'); @@ -1549,6 +1691,11 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands', }), + outputStyles: Object.freeze({ + allowedSuffixes: Object.freeze(['.md']), + directory: 'output-styles', + }), + workflows: 'workflows', }); export const claudeAdapter: TargetAdapter = Object.freeze({ @@ -1617,6 +1764,14 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude contract does not support both required modern MCP transports.', ), + outputStyles: capabilityStateFromSupport( + capabilityTable.plugin.outputStyles.directory === 'output-styles' && + capabilityTable.plugin.outputStyles.manifestField === 'outputStyles' && + capabilityTable.plugin.outputStyles.replacesDefault && + capabilityTable.plugin.outputStyles.allowedSuffixes.includes('.md'), + evidence, + 'The pinned Claude plugin contract does not document the plugin-root output-styles surface.', + ), 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.', ), @@ -1639,6 +1794,14 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not document enable-time userConfig options.', ), + workflows: capabilityStateFromSupport( + capabilityTable.plugin.workflows.directory === 'workflows' && + capabilityTable.plugin.workflows.manifestField === 'workflows' && + capabilityTable.plugin.workflows.replacesDefault && + capabilityTable.plugin.workflows.fileContents === 'opaque', + evidence, + 'The pinned Claude plugin contract does not document the plugin-root workflows surface.', + ), }), configExtension: Object.freeze({ key: claudeName }), hookContract, @@ -1647,5 +1810,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ name: claudeName, binSource: (config: Readonly) => config.claude?.bin, nativeHookSource: (config: Readonly) => config.claude?.nativeHooks, + outputStylesSource: (config: Readonly) => config.claude?.outputStyles, plan: planClaudeArtifacts, + workflowsSource: (config: Readonly) => config.claude?.workflows, }); diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index bb11e08dc..9188d314d 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -184,7 +184,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.10.0', + adapterRevision: '1.11.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 @@ -218,10 +218,12 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ hookWrappers: standardArtifactLayout.hookWrappers, mcpApps: standardArtifactLayout.mcpApps, mcpEntries: standardArtifactLayout.mcpEntries, + outputStyles: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'output-styles' }), rootDocuments: Object.freeze(['AGENTS.md', ...(standardArtifactLayout.rootDocuments ?? [])]), rules: Object.freeze({ allowedSuffixes: Object.freeze(['.mdc']), directory: 'rules' }), scripts: standardArtifactLayout.scripts, skills: standardArtifactLayout.skills, + workflows: 'workflows', }); const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginName, 'Agent plugin bundle'); @@ -233,10 +235,14 @@ interface AgentsDocumentOptions { readonly commands: boolean; /** True when the Claude half of this bundle emitted `.lsp.json`. */ readonly lsp: boolean; + /** True when the Claude half emitted output styles. */ + readonly outputStyles: 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; + /** True when the Claude half emitted workflow scripts. */ + readonly workflows: boolean; } const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions): string => { @@ -283,6 +289,16 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '- `bin/` — Claude Code executables added to the Bash tool PATH while the plugin is enabled; Codex and Cursor have no declared bin surface.', ] : []), + ...(options.workflows + ? [ + '- `workflows/` — Claude Code workflow scripts. Codex and Cursor have no declared workflows surface.', + ] + : []), + ...(options.outputStyles + ? [ + '- `output-styles/` — Claude Code output style definitions. Codex and Cursor have no declared output-styles surface.', + ] + : []), ...(options.rules ? [ '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', @@ -497,8 +513,10 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { bin: entries.some((entry) => entry.relativePath.startsWith('bin/')), commands: selectedCommands.length > 0, lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), + outputStyles: entries.some((entry) => entry.relativePath.startsWith('output-styles/')), rules: selectedRules.length > 0, settings: entries.some((entry) => entry.relativePath === claudeArtifactPaths.settings), + workflows: entries.some((entry) => entry.relativePath.startsWith('workflows/')), }), kind: 'write', relativePath: 'AGENTS.md', @@ -613,6 +631,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), cursorAdapter.capabilities.mcp!, ), + outputStyles: unavailableCapability( + 'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.', + ), // The bundle exposes Cursor's real rules directory; the composite row is // the honest three-host intersection, so it stays non-supported while // Claude and Codex cannot consume rules. @@ -636,6 +657,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ 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.', ), + workflows: unavailableCapability( + 'The unified bundle emits Claude-only workflows, but the pinned Codex and Cursor contracts declare no shared workflows surface.', + ), }), componentCapabilities, hookContract: bundleHookContract, @@ -643,5 +667,7 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ mcpRuntime, name: pluginName, binSource: (config: Readonly) => config.claude?.bin, + outputStylesSource: (config: Readonly) => config.claude?.outputStyles, plan, + workflowsSource: (config: Readonly) => config.claude?.workflows, }); diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index e44529f67..25f67ed92 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -5,6 +5,7 @@ import type { AgentBundleConfig, NormalizationConfigExtension, NormalizationHostBinSource, + NormalizationHostPayloadSource, NormalizationNativeHookSource, NormalizationTargetRegistry, } from '../core/types.ts'; @@ -33,6 +34,8 @@ import { deepFreeze } from '../core/freeze.ts'; const sha256Pattern = /^[0-9a-f]{64}$/; type NativeHookSource = NonNullable; type BinSource = NonNullable; +type OutputStylesSource = NonNullable; +type WorkflowsSource = NonNullable; const emptyArtifactValidation: TargetArtifactValidationContract = deepFreeze({ documents: [], @@ -191,6 +194,9 @@ const snapshotArtifactLayout = ( const mcpEntries = layout.mcpEntries === undefined ? undefined : snapshotOutputLayout(layout.mcpEntries, 'MCP entries'); + const outputStyles = layout.outputStyles === undefined + ? undefined + : snapshotOutputLayout(layout.outputStyles, 'output styles'); const rules = layout.rules === undefined ? undefined : snapshotOutputLayout(layout.rules, 'rules'); const scripts = layout.scripts === undefined ? undefined : snapshotOutputLayout(layout.scripts, 'scripts'); const assets = layout.assets === undefined @@ -202,6 +208,9 @@ const snapshotArtifactLayout = ( const skills = layout.skills === undefined ? undefined : requireNonempty(layout.skills, 'artifact layout skills namespace'); + const workflows = layout.workflows === undefined + ? undefined + : requireNonempty(layout.workflows, 'artifact layout workflows namespace'); const rootDocuments = layout.rootDocuments === undefined ? undefined : snapshotRootDocuments(layout.rootDocuments); if (assets !== undefined && !isSafeArtifactDirectory(assets)) { @@ -213,6 +222,9 @@ const snapshotArtifactLayout = ( if (skills !== undefined && !isSafeArtifactDirectory(skills)) { throw new Error('Target adapter artifact layout skills namespace must be a safe single namespace.'); } + if (workflows !== undefined && !isSafeArtifactDirectory(workflows)) { + throw new Error('Target adapter artifact layout workflows namespace must be a safe single namespace.'); + } if (hookWrappers !== undefined && hookContract === undefined) { throw new Error(`Target adapter "${adapter.name}" declares hook wrapper layout without a hook contract.`); } @@ -229,10 +241,12 @@ const snapshotArtifactLayout = ( ...(hookWrappers === undefined ? {} : { hookWrappers }), ...(mcpApps === undefined ? {} : { mcpApps }), ...(mcpEntries === undefined ? {} : { mcpEntries }), + ...(outputStyles === undefined ? {} : { outputStyles }), ...(rootDocuments === undefined ? {} : { rootDocuments }), ...(rules === undefined ? {} : { rules }), ...(scripts === undefined ? {} : { scripts }), ...(skills === undefined ? {} : { skills }), + ...(workflows === undefined ? {} : { workflows }), }); }; @@ -328,6 +342,22 @@ const snapshotBinSource = (adapter: TargetAdapter): BinSource | undefined => { return source; }; +const snapshotOutputStylesSource = (adapter: TargetAdapter): OutputStylesSource | undefined => { + const source = adapter.outputStylesSource; + if (source !== undefined && typeof source !== 'function') { + throw new Error('Target adapter output styles source must be a function.'); + } + return source; +}; + +const snapshotWorkflowsSource = (adapter: TargetAdapter): WorkflowsSource | undefined => { + const source = adapter.workflowsSource; + if (source !== undefined && typeof source !== 'function') { + throw new Error('Target adapter workflows source must be a function.'); + } + return source; +}; + const snapshotHookContract = (adapter: TargetAdapter): TargetHookContract | undefined => { const hookContract = adapter.hookContract; if (capabilityIsSupported(adapter.capabilities.hooks) && hookContract === undefined) { @@ -417,6 +447,8 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #metadata = new Map(); readonly #mcpRuntimes = new Map(); readonly #nativeHookSources = new Map(); + readonly #outputStylesSources = new Map(); + readonly #workflowsSources = new Map(); register(adapter: TargetAdapter, options: { readonly default?: boolean } = {}): this { if (this.#adapters.has(adapter.name)) { @@ -431,6 +463,8 @@ export class TargetRegistry implements NormalizationTargetRegistry { const artifactValidation = snapshotArtifactValidation(adapter, metadata); const binSource = snapshotBinSource(adapter); const nativeHookSource = snapshotNativeHookSource(adapter); + const outputStylesSource = snapshotOutputStylesSource(adapter); + const workflowsSource = snapshotWorkflowsSource(adapter); const hookContract = snapshotHookContract(adapter); const mcpRuntime = snapshotMcpRuntime(adapter); const artifactLayout = snapshotArtifactLayout(adapter, hookContract, mcpRuntime); @@ -451,6 +485,12 @@ export class TargetRegistry implements NormalizationTargetRegistry { if (nativeHookSource !== undefined) { this.#nativeHookSources.set(adapter.name, nativeHookSource); } + if (outputStylesSource !== undefined) { + this.#outputStylesSources.set(adapter.name, outputStylesSource); + } + if (workflowsSource !== undefined) { + this.#workflowsSources.set(adapter.name, workflowsSource); + } if (hookContract !== undefined) { this.#hookContracts.set(adapter.name, hookContract); } @@ -541,6 +581,50 @@ export class TargetRegistry implements NormalizationTargetRegistry { return Object.freeze(sources); } + outputStyleSources( + config: Readonly, + targetNames: readonly string[], + ): readonly NormalizationHostPayloadSource[] { + const sources: NormalizationHostPayloadSource[] = []; + for (const target of [...this.#outputStylesSources.keys()].sort((left, right) => left.localeCompare(right))) { + if (!targetNames.includes(target)) continue; + const adapter = this.#adapters.get(target)!; + try { + const source = this.#outputStylesSources.get(target)!.call(adapter, config); + if (typeof source === 'string' && source.trim().length > 0) { + sources.push(Object.freeze({ source, target })); + } else if (source !== undefined) { + sources.push(Object.freeze({ issue: 'invalid', target })); + } + } catch { + sources.push(Object.freeze({ issue: 'error', target })); + } + } + return Object.freeze(sources); + } + + workflowSources( + config: Readonly, + targetNames: readonly string[], + ): readonly NormalizationHostPayloadSource[] { + const sources: NormalizationHostPayloadSource[] = []; + for (const target of [...this.#workflowsSources.keys()].sort((left, right) => left.localeCompare(right))) { + if (!targetNames.includes(target)) continue; + const adapter = this.#adapters.get(target)!; + try { + const source = this.#workflowsSources.get(target)!.call(adapter, config); + if (typeof source === 'string' && source.trim().length > 0) { + sources.push(Object.freeze({ source, target })); + } else if (source !== undefined) { + sources.push(Object.freeze({ issue: 'invalid', target })); + } + } catch { + sources.push(Object.freeze({ issue: 'error', target })); + } + } + return Object.freeze(sources); + } + nativeHookSources( config: Readonly, targetNames: readonly string[], diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 5f72c7e1e..41963a2e4 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, and settings.json 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.", + "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.", "schemas": { "hooks.schema.json": { "bytes": 1108, diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 8f0ec941a..ae24537e1 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -413,11 +413,13 @@ export interface TargetArtifactLayout { readonly hookWrappers?: TargetArtifactOutputLayout; readonly mcpApps?: TargetArtifactOutputLayout; readonly mcpEntries?: TargetArtifactOutputLayout; + readonly outputStyles?: TargetArtifactOutputLayout; /** Adapter-owned plain documents at the artifact root (for example a generated AGENTS.md). */ readonly rootDocuments?: readonly string[]; readonly rules?: TargetArtifactOutputLayout; readonly scripts?: TargetArtifactOutputLayout; readonly skills?: string; + readonly workflows?: string; } /** @@ -502,5 +504,7 @@ export interface TargetAdapter { readonly name: string; binSource?(config: Readonly): string | undefined; nativeHookSource?(config: Readonly): string | undefined; + outputStylesSource?(config: Readonly): string | undefined; plan(model: NormalizedPlugin): TargetArtifactPlan; + workflowsSource?(config: Readonly): string | undefined; } diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 0fc8848f2..03923eb55 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -49,6 +49,7 @@ export type { AgentBundlePrebuiltEntry, NormalizationConfigExtension, NormalizationHostBinSource, + NormalizationHostPayloadSource, NormalizationTargetRegistry, NormalizedCommand, AgentBundleMcpApp, @@ -60,6 +61,7 @@ export type { NormalizedMetadata, NormalizedHostBin, NormalizedHostBinFile, + NormalizedHostPayloadDirectory, NormalizedMcpApp, NormalizedMcpServer, NormalizedPayload, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index d65ad635e..799a2de1d 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; -import { readFile, readdir, stat } from 'node:fs/promises'; +import { readFile, readdir, realpath, stat } from 'node:fs/promises'; import { basename, dirname, extname, posix, relative, resolve, sep, win32 } from 'node:path'; import { digest } from '../core/digest.ts'; @@ -32,6 +32,7 @@ import type { CanonicalHookEvent, CanonicalHookTool, NativeHookToolSelector, + NormalizationHostPayloadSource, NormalizationTargetRegistry, NormalizedAsset, NormalizedBinEntry, @@ -40,6 +41,7 @@ import type { NormalizedHook, NormalizedHostBin, NormalizedHostBinFile, + NormalizedHostPayloadDirectory, NormalizedLibEntry, NormalizedMcpApp, NormalizedMcpServer, @@ -349,10 +351,12 @@ export const reservedPayloadDestinations = Object.freeze(new Set([ 'mcp', 'mcp-apps', 'mcp.json', + 'output-styles', 'plugin.json', 'rules', 'scripts', 'skills', + 'workflows', ])); const normalizePayloads = ( @@ -586,7 +590,7 @@ const normalizeNativeHooks = async ( return nativeHooks; }; -const enumerateHostBinFiles = async (source: string): Promise => { +const enumerateHostPayloadFiles = async (source: string): Promise => { const files: NormalizedHostBinFile[] = []; const visit = async (directory: string): Promise => { const entries = await readdir(directory, { withFileTypes: true }); @@ -614,46 +618,84 @@ const normalizeHostBins = async ( loaded: LoadedConfig, targetNames: readonly string[], registry: NormalizationTargetRegistry, -): Promise => { +): Promise => + normalizeHostPayloadDirectories(loaded, registry.binSources?.(loaded.config, targetNames) ?? []); + +const normalizeHostPayloadDirectories = async ( + loaded: LoadedConfig, + sources: readonly NormalizationHostPayloadSource[], +): Promise => { const provenance: SourceProvenance = { kind: 'config', sourcePath: loaded.configPath }; - const bins: NormalizedHostBin[] = []; - for (const binSource of registry.binSources?.(loaded.config, targetNames) ?? []) { - if ('issue' in binSource) { - bins.push({ + const directories: NormalizedHostPayloadDirectory[] = []; + for (const declaredSource of sources) { + if ('issue' in declaredSource) { + directories.push({ files: [], - issue: `source-${binSource.issue}`, + issue: `source-${declaredSource.issue}`, provenance: { ...provenance }, source: loaded.configPath, - target: binSource.target, + target: declaredSource.target, }); continue; } - const source = resolve(dirname(loaded.configPath), binSource.source); + const source = resolve(dirname(loaded.configPath), declaredSource.source); if (!isInside(loaded.context.projectRoot, source)) { - bins.push({ files: [], issue: 'outside', provenance: { ...provenance }, source, target: binSource.target }); + directories.push({ + files: [], + issue: 'outside', + provenance: { ...provenance }, + source, + target: declaredSource.target, + }); continue; } let metadata; try { metadata = await stat(source); } catch { - bins.push({ files: [], issue: 'missing', provenance: { ...provenance }, source, target: binSource.target }); + directories.push({ + files: [], + issue: 'missing', + provenance: { ...provenance }, + source, + target: declaredSource.target, + }); continue; } if (!metadata.isDirectory()) { - bins.push({ files: [], issue: 'not-directory', provenance: { ...provenance }, source, target: binSource.target }); + directories.push({ + files: [], + issue: 'not-directory', + provenance: { ...provenance }, + source, + target: declaredSource.target, + }); continue; } - const files = await enumerateHostBinFiles(source); - bins.push({ + const [projectRealPath, sourceRealPath] = await Promise.all([ + realpath(loaded.context.projectRoot), + realpath(source), + ]); + if (!isInside(projectRealPath, sourceRealPath)) { + directories.push({ + files: [], + issue: 'outside', + provenance: { ...provenance }, + source, + target: declaredSource.target, + }); + continue; + } + const files = await enumerateHostPayloadFiles(source); + directories.push({ files, ...(files.length === 0 ? { issue: 'empty' as const } : {}), provenance: { ...provenance }, source, - target: binSource.target, + target: declaredSource.target, }); } - return bins; + return directories; }; const normalizeMcpServer = ( @@ -1157,6 +1199,14 @@ export const normalizeProject = async ( const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot); const version = resolvePluginVersion(loaded.config.plugin.version, packageIdentity.packageVersion); const hostBins = await normalizeHostBins(loaded, targetNames, registry); + const hostOutputStyles = await normalizeHostPayloadDirectories( + loaded, + registry.outputStyleSources?.(loaded.config, targetNames) ?? [], + ); + const hostWorkflows = await normalizeHostPayloadDirectories( + loaded, + registry.workflowSources?.(loaded.config, targetNames) ?? [], + ); const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); const payloads = normalizePayloads(loaded, discovered, targetNames); const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); @@ -1184,6 +1234,8 @@ export const normalizeProject = async ( ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), extensions: normalizeExtensions(loaded, registry, configProvenance), ...(hostBins.length === 0 ? {} : { hostBins }), + ...(hostOutputStyles.length === 0 ? {} : { hostOutputStyles }), + ...(hostWorkflows.length === 0 ? {} : { hostWorkflows }), metadata: { ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 0693d03aa..19fca5735 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2197,6 +2197,18 @@ export const validateModel = ( recordOutput(posix.join(target.name, 'bin', file.relativePath), file.source, target.name); } } + for (const directory of model.hostOutputStyles ?? []) { + if (directory.target !== target.name) continue; + for (const file of directory.files) { + recordOutput(posix.join(target.name, 'output-styles', file.relativePath), file.source, target.name); + } + } + for (const directory of model.hostWorkflows ?? []) { + if (directory.target !== target.name) continue; + for (const file of directory.files) { + recordOutput(posix.join(target.name, 'workflows', file.relativePath), file.source, target.name); + } + } } return diagnostics; diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 6e08d0703..df048efba 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -271,6 +271,14 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ bin.provenance.sourcePath, ...bin.files.map((file) => file.source), ]), + ...(model.hostOutputStyles ?? []).flatMap((directory) => [ + directory.provenance.sourcePath, + ...directory.files.map((file) => file.source), + ]), + ...(model.hostWorkflows ?? []).flatMap((directory) => [ + directory.provenance.sourcePath, + ...directory.files.map((file) => file.source), + ]), ...model.targets.map((target) => target.provenance.sourcePath), // A prebuilt hook's source is its payload file, which may not exist yet // (the payload comes from the consumer's own build step); its bytes join @@ -390,6 +398,32 @@ export const canonicalizeNormalizedModel = ( source: canonicalCompilerPath(root, bin.source, 'Host bin source path'), })), }), + ...(detached.hostOutputStyles === undefined + ? {} + : { + hostOutputStyles: detached.hostOutputStyles.map((directory) => ({ + ...directory, + files: directory.files.map((file) => ({ + ...file, + source: canonicalCompilerPath(root, file.source, 'Host output style file source path'), + })), + provenance: canonicalProvenance(root, directory.provenance), + source: canonicalCompilerPath(root, directory.source, 'Host output styles source path'), + })), + }), + ...(detached.hostWorkflows === undefined + ? {} + : { + hostWorkflows: detached.hostWorkflows.map((directory) => ({ + ...directory, + files: directory.files.map((file) => ({ + ...file, + source: canonicalCompilerPath(root, file.source, 'Host workflow file source path'), + })), + provenance: canonicalProvenance(root, directory.provenance), + source: canonicalCompilerPath(root, directory.source, 'Host workflows source path'), + })), + }), hooks: detached.hooks.map((hook) => ({ ...hook, provenance: canonicalProvenance(root, hook.provenance), diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index b81028bbd..eff59874b 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -521,8 +521,8 @@ export interface NormalizedHostBinFile { readonly source: string; } -/** One adapter-declared host-native plugin executable directory. */ -export interface NormalizedHostBin { +/** One adapter-declared host-native plugin payload directory. */ +export interface NormalizedHostPayloadDirectory { readonly files: readonly NormalizedHostBinFile[]; readonly issue?: 'empty' | 'missing' | 'not-directory' | 'outside' | 'source-error' | 'source-invalid'; readonly provenance: SourceProvenance; @@ -531,6 +531,9 @@ export interface NormalizedHostBin { readonly target: string; } +/** One adapter-declared host-native plugin executable directory. */ +export type NormalizedHostBin = NormalizedHostPayloadDirectory; + export interface NormalizedNativeHook { readonly document?: unknown; readonly issue?: 'missing' | 'parse' | 'source-error' | 'source-invalid'; @@ -587,6 +590,10 @@ export interface NormalizedPlugin { readonly extensions: Readonly>; /** Adapter-declared host-native executable directories, enumerated during normalization. */ readonly hostBins?: readonly NormalizedHostBin[]; + /** Adapter-declared host-native output-style directories, enumerated during normalization. */ + readonly hostOutputStyles?: readonly NormalizedHostPayloadDirectory[]; + /** Adapter-declared host-native workflow directories, enumerated during normalization. */ + readonly hostWorkflows?: readonly NormalizedHostPayloadDirectory[]; readonly hooks: readonly NormalizedHook[]; readonly marketplace?: true; readonly metadata: NormalizedMetadata; @@ -658,6 +665,8 @@ export type NormalizationHostBinSource = | NormalizationHostBinDocument | NormalizationHostBinSourceError; +export type NormalizationHostPayloadSource = NormalizationHostBinSource; + export interface NormalizationTargetRegistry { binSources?( config: Readonly, @@ -671,7 +680,15 @@ export interface NormalizationTargetRegistry { config: Readonly, targetNames: readonly string[], ): readonly NormalizationNativeHookSource[]; + outputStyleSources?( + config: Readonly, + targetNames: readonly string[], + ): readonly NormalizationHostPayloadSource[]; supports(name: string, capability: string): boolean; + workflowSources?( + config: Readonly, + targetNames: readonly string[], + ): readonly NormalizationHostPayloadSource[]; } export interface ConfigFactoryContext { diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 1f27ddd1c..46fd517a8 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -759,7 +759,16 @@ export class ProjectService { : (loaded.config.targets ?? registry.defaultTargetNames()); const hostBinRoots = (registry.binSources?.(loaded.config, targetNames) ?? []) .flatMap((source) => 'source' in source ? [resolve(dirname(loaded.configPath), source.source)] : []); - const additionalSourceRoots = [...configuredPayloadRoots(root, loaded.config), ...hostBinRoots]; + const hostOutputStyleRoots = (registry.outputStyleSources?.(loaded.config, targetNames) ?? []) + .flatMap((source) => 'source' in source ? [resolve(dirname(loaded.configPath), source.source)] : []); + const hostWorkflowRoots = (registry.workflowSources?.(loaded.config, targetNames) ?? []) + .flatMap((source) => 'source' in source ? [resolve(dirname(loaded.configPath), source.source)] : []); + const additionalSourceRoots = [ + ...configuredPayloadRoots(root, loaded.config), + ...hostBinRoots, + ...hostOutputStyleRoots, + ...hostWorkflowRoots, + ]; let snapshot: ProjectSourceSnapshot; try { snapshot = await snapshotProjectSource(root, loaded.configPath, outputRoots, additionalSourceRoots); diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 7d229ffa9..fbb843d8e 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -130,6 +130,31 @@ it('reports Claude bin support without inventing coverage on other native hosts' expect(registry.supports('plugin', 'bin')).toBe(false); }); +it.each([ + ['outputStyles', 'output styles'], + ['workflows', 'workflows'], +] as const)('reports Claude %s support and honest unavailable composite coverage', (capability, label) => { + 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]).toEqual({ + reason: `The unified bundle emits Claude-only ${label}, but the pinned Codex and Cursor contracts declare no shared ${label} surface.`, + 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 plugin settings 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 b6ebf4a92..d8ba98c03 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.11.0', + adapterRevision: '1.12.0', observedVersion: '2.1.250', schemas: [ { @@ -154,7 +154,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.10.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.11.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 527f797b4..d653d07bf 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -240,6 +240,80 @@ nativeIt('accepts an emitted Claude plugin with bin under strict native validati } }); +nativeIt('accepts emitted Claude workflows and output styles under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-workflows-output-styles-')); + const sourceRoot = join(root, 'authored'); + const workflowsRoot = join(sourceRoot, 'workflows'); + const outputStylesRoot = join(sourceRoot, 'styles'); + const workflowSource = join(workflowsRoot, 'release-audit.js'); + const outputStyleSource = join(outputStylesRoot, 'terse.md'); + const outputRoot = join(root, 'plugin'); + const workflow = 'export default async function releaseAudit() {}\n'; + const outputStyle = '---\nname: Terse\ndescription: Be concise\n---\n\nBe concise.\n'; + const payloadModel: NormalizedPlugin = { + ...model, + hostOutputStyles: [{ + files: [{ + bytes: Buffer.byteLength(outputStyle), + executable: false, + relativePath: 'terse.md', + source: outputStyleSource, + }], + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: outputStylesRoot, + target: 'claude', + }], + hostWorkflows: [{ + files: [{ + bytes: Buffer.byteLength(workflow), + executable: false, + relativePath: 'release-audit.js', + source: workflowSource, + }], + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: workflowsRoot, + target: 'claude', + }], + }; + + try { + await Promise.all([ + mkdir(workflowsRoot, { recursive: true }), + mkdir(outputStylesRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(workflowSource, workflow), + writeFile(outputStyleSource, outputStyle), + ]); + await emitPlanEntries({ entries: claudeAdapter.plan(payloadModel).entries, root: outputRoot }); + expect(await readFile(join(outputRoot, 'workflows', 'release-audit.js'), 'utf8')).toBe(workflow); + expect(await readFile(join(outputRoot, 'output-styles', 'terse.md'), 'utf8')).toBe(outputStyle); + const validation = await runClaudeValidation(outputRoot, outputRoot); + + 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 output-style frontmatter', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-output-style-frontmatter-')); + const outputRoot = join(root, 'plugin'); + + try { + await writeClaudeArtifact(outputRoot, model); + await mkdir(join(outputRoot, 'output-styles'), { recursive: true }); + await writeFile(join(outputRoot, 'output-styles', 'missing-frontmatter.md'), 'Be concise.\n'); + const validation = await runClaudeValidation(outputRoot, outputRoot); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).not.toContain('missing-frontmatter.md'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + nativeIt('accepts emitted Claude userConfig under strict native validation', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-user-config-')); const outputRoot = join(root, 'plugin'); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 6f30b8ec6..4844d4bf8 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -155,6 +155,26 @@ const withClaudeBin = ( }], }); +const withClaudePayloadDirectory = ( + model: NormalizedPlugin, + field: 'hostOutputStyles' | 'hostWorkflows', + files: NonNullable[number]['files'], + options: { + readonly issue?: NonNullable[number]['issue']; + readonly source?: string; + readonly target?: string; + } = {}, +): NormalizedPlugin => ({ + ...model, + [field]: [{ + files, + ...(options.issue === undefined ? {} : { issue: options.issue }), + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: options.source ?? (field === 'hostWorkflows' ? '/workspace/workflows' : '/workspace/styles'), + target: options.target ?? 'claude', + }], +}); + const withClaudeSettings = ( model: NormalizedPlugin, settings: unknown, @@ -1190,6 +1210,118 @@ it('preserves the executable mode when emitting a Claude bin copy entry', async } }); +it('plans Claude workflow and output-style files as byte-faithful prebuilt copies without manifest path fields', () => { + const withWorkflows = withClaudePayloadDirectory(plugin, 'hostWorkflows', [{ + bytes: 48, + executable: false, + relativePath: 'release-audit.js', + source: '/workspace/workflows/release-audit.js', + }]); + const model = withClaudePayloadDirectory(withWorkflows, 'hostOutputStyles', [{ + bytes: 72, + executable: false, + relativePath: 'terse.md', + source: '/workspace/styles/terse.md', + }]); + const plan = createDefaultRegistry().get('claude').plan(model); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toEqual([]); + expect(plan.entries.filter((entry) => + entry.relativePath.startsWith('workflows/') || entry.relativePath.startsWith('output-styles/'))).toEqual([ + { + bytes: 72, + kind: 'copy', + prebuilt: true, + relativePath: 'output-styles/terse.md', + source: '/workspace/styles/terse.md', + sourceInputs: ['/workspace/agent-bundle.config.ts', '/workspace/styles/terse.md'], + }, + { + bytes: 48, + kind: 'copy', + prebuilt: true, + relativePath: 'workflows/release-audit.js', + source: '/workspace/workflows/release-audit.js', + sourceInputs: ['/workspace/agent-bundle.config.ts', '/workspace/workflows/release-audit.js'], + }, + ]); + if (manifest?.kind !== 'write') throw new Error('Expected the Claude plugin manifest.'); + expect(JSON.parse(manifest.content)).not.toHaveProperty('workflows'); + expect(JSON.parse(manifest.content)).not.toHaveProperty('outputStyles'); +}); + +it.each([ + { + code: 'claude.workflows.directory.missing', + field: 'hostWorkflows' as const, + issue: 'missing' as const, + }, + { + code: 'claude.workflows.directory.invalid', + field: 'hostWorkflows' as const, + issue: 'not-directory' as const, + }, + { + code: 'claude.workflows.directory.outside', + field: 'hostWorkflows' as const, + issue: 'outside' as const, + }, + { + code: 'claude.workflows.source.error', + field: 'hostWorkflows' as const, + issue: 'source-error' as const, + }, + { + code: 'claude.workflows.source.invalid', + field: 'hostWorkflows' as const, + issue: 'source-invalid' as const, + }, + { + code: 'claude.outputStyles.directory.empty', + field: 'hostOutputStyles' as const, + issue: 'empty' as const, + }, +])('diagnoses $code without emitting the declared payload directory', ({ code, field, issue }) => { + const plan = createDefaultRegistry().get('claude').plan( + withClaudePayloadDirectory(plugin, field, [], { issue }), + ); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + recovery: expect.any(String), + severity: 'error', + })); + expect(plan.entries.some((entry) => + entry.relativePath.startsWith('workflows/') || entry.relativePath.startsWith('output-styles/'))).toBe(false); +}); + +it('rejects non-Markdown Claude output-style files without applying executable requirements', () => { + const plan = createDefaultRegistry().get('claude').plan( + withClaudePayloadDirectory(plugin, 'hostOutputStyles', [ + { + bytes: 12, + executable: true, + relativePath: 'nested/terse.md', + source: '/workspace/styles/nested/terse.md', + }, + { + bytes: 12, + executable: false, + relativePath: 'README.txt', + source: '/workspace/styles/README.txt', + }, + ]), + ); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code: 'claude.outputStyles.file.invalid', + recovery: expect.stringContaining('.md'), + severity: 'error', + })); + expect(plan.entries.some((entry) => entry.relativePath.startsWith('output-styles/'))).toBe(false); +}); + it('pins all documented Claude plugin-manifest LSP declaration forms', async () => { const schema = (await import('../src/adapters/schemas/claude/plugin.schema.json', { with: { type: 'json' }, diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 8cd4a3975..0177e9909 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@rstest/core'; -import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -268,6 +268,77 @@ it('enumerates claude.bin relative to the config file into immutable executable } }); +it('enumerates Claude workflows and output styles relative to the config file into immutable payload metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-payloads-normalize-')); + const configDir = join(root, 'configs'); + const workflowsRoot = join(root, 'workflows'); + const outputStylesRoot = join(root, 'styles'); + const workflow = join(workflowsRoot, 'release-audit.js'); + const outputStyle = join(outputStylesRoot, 'terse.md'); + const workflowContents = 'export default async function releaseAudit() {}\n'; + const outputStyleContents = '---\nname: Terse\ndescription: Be concise\n---\n\nBe concise.\n'; + await Promise.all([ + mkdir(configDir, { recursive: true }), + mkdir(workflowsRoot, { recursive: true }), + mkdir(outputStylesRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(workflow, workflowContents), + writeFile(outputStyle, outputStyleContents), + ]); + const loaded: LoadedConfig = { + ...loadedProject({ + claude: { + outputStyles: '../styles', + workflows: '../workflows', + }, + plugin: { name: 'claude-payload-fixture', version: '1.0.0' }, + targets: ['claude'], + }, { root }), + configPath: join(configDir, 'agent-bundle.config.ts'), + }; + + try { + const model = await normalizeProject(loaded, { skills: [] }, createDefaultRegistry()); + + expect(model.hostWorkflows).toEqual([{ + files: [{ + bytes: Buffer.byteLength(workflowContents), + executable: false, + relativePath: 'release-audit.js', + source: workflow, + }], + provenance: { kind: 'config', sourcePath: loaded.configPath }, + source: workflowsRoot, + target: 'claude', + }]); + expect(model.hostOutputStyles).toEqual([{ + files: [{ + bytes: Buffer.byteLength(outputStyleContents), + executable: false, + relativePath: 'terse.md', + source: outputStyle, + }], + provenance: { kind: 'config', sourcePath: loaded.configPath }, + source: outputStylesRoot, + target: 'claude', + }]); + expect(Object.isFrozen(model.hostWorkflows)).toBe(true); + expect(Object.isFrozen(model.hostWorkflows?.[0]?.files[0])).toBe(true); + expect(Object.isFrozen(model.hostOutputStyles)).toBe(true); + expect(Object.isFrozen(model.hostOutputStyles?.[0]?.files[0])).toBe(true); + + const pluginModel = await normalizeProject({ + ...loaded, + config: { ...loaded.config, targets: ['plugin'] }, + }, { skills: [] }, createDefaultRegistry()); + expect(pluginModel.hostWorkflows?.[0]?.target).toBe('plugin'); + expect(pluginModel.hostOutputStyles?.[0]?.target).toBe('plugin'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it.each([ { code: 'claude.bin.directory.missing', create: false, issue: 'missing' as const }, { code: 'claude.bin.directory.empty', create: true, issue: 'empty' as const }, @@ -292,6 +363,73 @@ it.each([ } }); +it.each([ + { + code: 'claude.workflows.directory.empty', + field: 'workflows' as const, + issue: 'empty' as const, + }, + { + code: 'claude.outputStyles.directory.missing', + field: 'outputStyles' as const, + issue: 'missing' as const, + }, +])('normalizes and diagnoses a Claude $field directory that is $issue', async ({ code, field, issue }) => { + const root = await mkdtemp(join(tmpdir(), `agent-bundle-claude-${field}-diagnostic-`)); + const sourceRoot = join(root, 'payload'); + if (issue === 'empty') await mkdir(sourceRoot, { recursive: true }); + const loaded = loadedProject({ + claude: { [field]: './payload' }, + plugin: { name: 'claude-payload-diagnostic', version: '1.0.0' }, + targets: ['claude'], + }, { root }); + + try { + const model = await normalizeProject(loaded, { skills: [] }, createDefaultRegistry()); + const plan = createDefaultRegistry().get('claude').plan(model); + const payload = field === 'workflows' ? model.hostWorkflows?.[0] : model.hostOutputStyles?.[0]; + + expect(payload).toMatchObject({ files: [], issue, source: sourceRoot, target: 'claude' }); + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ code, severity: 'error' })); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('rejects a Claude payload directory symlink that resolves outside the project', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-payload-symlink-')); + const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-payload-outside-')); + const linked = join(root, 'styles'); + await writeFile(join(outside, 'terse.md'), 'Be concise.\n'); + await symlink(outside, linked, 'dir'); + const loaded = loadedProject({ + claude: { outputStyles: './styles' }, + plugin: { name: 'claude-payload-symlink', version: '1.0.0' }, + targets: ['claude'], + }, { root }); + + try { + const model = await normalizeProject(loaded, { skills: [] }, createDefaultRegistry()); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(model.hostOutputStyles?.[0]).toMatchObject({ + files: [], + issue: 'outside', + source: linked, + target: 'claude', + }); + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code: 'claude.outputStyles.directory.outside', + severity: 'error', + })); + } finally { + await Promise.all([ + rm(root, { force: true, recursive: true }), + rm(outside, { force: true, recursive: true }), + ]); + } +}); + it('rejects non-JSON values in registered config extensions before normalization', async () => { class ExtensionClass { readonly enabled = true; diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 2dbcccf47..add920747 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -424,6 +424,58 @@ it('emits the Claude bin directory from the unified plugin target', () => { expect(writeContents(model)['AGENTS.md']).toContain('`bin/`'); }); +it('emits Claude workflows and output styles from the unified plugin target', () => { + const model: NormalizedPlugin = { + ...bundleModel, + hostOutputStyles: [{ + files: [{ + bytes: 72, + executable: false, + relativePath: 'terse.md', + source: '/workspace/styles/terse.md', + }], + provenance: { kind: 'config', sourcePath: configPath }, + source: '/workspace/styles', + target: 'plugin', + }], + hostWorkflows: [{ + files: [{ + bytes: 48, + executable: false, + relativePath: 'release-audit.js', + source: '/workspace/workflows/release-audit.js', + }], + provenance: { kind: 'config', sourcePath: configPath }, + source: '/workspace/workflows', + target: 'plugin', + }], + }; + const plan = planBundle(model); + + expect(plan.diagnostics).toEqual([]); + expect(plan.entries.filter((entry) => + entry.relativePath.startsWith('workflows/') || entry.relativePath.startsWith('output-styles/'))).toEqual([ + { + bytes: 72, + kind: 'copy', + prebuilt: true, + relativePath: 'output-styles/terse.md', + source: '/workspace/styles/terse.md', + sourceInputs: [configPath, '/workspace/styles/terse.md'], + }, + { + bytes: 48, + kind: 'copy', + prebuilt: true, + relativePath: 'workflows/release-audit.js', + source: '/workspace/workflows/release-audit.js', + sourceInputs: [configPath, '/workspace/workflows/release-audit.js'], + }, + ]); + expect(writeContents(model)['AGENTS.md']).toContain('`workflows/`'); + expect(writeContents(model)['AGENTS.md']).toContain('`output-styles/`'); +}); + it('emits Claude-only plugin default settings at the shared composite root', () => { const model = { ...bundleModel, diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index 961bf4b20..ebba15aa2 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -195,6 +195,8 @@ it('reports the prebuilt payload source diagnostics', async () => { ' payload: {', " bin: './built/app',", " 'mcp-apps': './built/app',", + " 'output-styles': './built/app',", + " workflows: './built/app',", " absent: './built/never-built',", " runtime: { source: './built/runtime', targets: ['claude'] },", ' },', @@ -205,7 +207,7 @@ it('reports the prebuilt payload source diagnostics', async () => { const result = await validate({ root }); const codes = result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity] as const); // The reserved destination name. - expect(codes.filter(([code]) => code === 'AB4741')).toHaveLength(2); + expect(codes.filter(([code]) => code === 'AB4741')).toHaveLength(4); expect(result.diagnostics.find((diagnostic) => diagnostic.code === 'AB4741' && diagnostic.message.includes('"bin"'))?.recovery).toContain('claude.bin'); // The not-yet-built payload directory warns instead of failing validation.