Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claude-plugin-workflows-output-styles.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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": {
Expand Down Expand Up @@ -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)."
]
}
}
167 changes: 166 additions & 1 deletion packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AgentBundleConfig,
type AgentBundleHostConfig,
type NormalizedMcpServer,
type NormalizedHostPayloadDirectory,
type NormalizedPlugin,
} from '../core/types.ts';
import {
Expand Down Expand Up @@ -205,9 +206,13 @@ export interface ClaudeHostConfig extends AgentBundleHostConfig {
readonly lspServers?: Readonly<Record<string, ClaudeLspServerConfig>>;
/** Free-form catalog or entitlement data that Claude Code preserves but does not interpret. */
readonly metadata?: Readonly<Record<string, unknown>>;
/** 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<Record<string, ClaudeUserConfigOption>>;
/** Project-authored script files copied to the plugin-root `workflows/` convention. */
readonly workflows?: string;
}

export interface ClaudeConfigExtension {
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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({
Expand Down Expand Up @@ -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.',
),
Expand All @@ -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,
Expand All @@ -1647,5 +1810,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
name: claudeName,
binSource: (config: Readonly<AgentBundleConfig>) => config.claude?.bin,
nativeHookSource: (config: Readonly<AgentBundleConfig>) => config.claude?.nativeHooks,
outputStylesSource: (config: Readonly<AgentBundleConfig>) => config.claude?.outputStyles,
plan: planClaudeArtifacts,
workflowsSource: (config: Readonly<AgentBundleConfig>) => config.claude?.workflows,
});
28 changes: 27 additions & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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 => {
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand All @@ -636,12 +657,17 @@ 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,
metadata,
mcpRuntime,
name: pluginName,
binSource: (config: Readonly<AgentBundleConfig>) => config.claude?.bin,
outputStylesSource: (config: Readonly<AgentBundleConfig>) => config.claude?.outputStyles,
plan,
workflowsSource: (config: Readonly<AgentBundleConfig>) => config.claude?.workflows,
});
Loading
Loading