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-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Add validated Claude Code plugin channel declarations bound to emitted MCP servers, including per-channel user configuration and unified plugin bundle emission.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
"enabledOnly": true,
"organizationDistributionProhibited": true
},
"channels": {
"bindsToPluginMcpServer": true,
"perChannelUserConfig": true
},
"commands": true,
"dependencies": {
"autoInstall": true,
Expand Down Expand Up @@ -214,6 +218,10 @@
"2026-09-01: https://code.claude.com/docs/en/plugins-reference stores non-sensitive options under `pluginConfigs[<plugin>].options` in user settings and sensitive options in macOS Keychain with credentials-file fallback, or `~/.claude/.credentials.json` without a supported keychain; Keychain storage is shared with OAuth tokens and has an approximately 2 KB total limit. pluginConfigs precedence is managed settings, then `--settings`, then user settings; project and local settings are ignored for pluginConfigs (but not enabledPlugins), while before v2.1.207 they were read.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents repeatable `claude plugin install --config key=value` for setting declared userConfig options.",
"2026-09-01: Claude Code 2.1.257 `claude plugin validate --strict` accepts an emitted plugin manifest declaring userConfig with a sensitive string option and a bounded number option.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference lists the component path field as: \"channels | array | Channel declarations for message injection (Telegram, Slack, Discord style). See Channels\".",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents `channels` as an array of message-channel declarations for Telegram, Slack, or Discord-style injection; each required `server` \"must match a key in the plugin's mcpServers.\"",
"2026-09-01: The same Channels section states that optional per-channel `userConfig` \"uses the same schema as the top-level field\", so sensitive channel options follow the top-level secure-storage semantics rather than a second option contract.",
"2026-09-01: Local host proof against Claude Code 2.1.257 shows `claude plugin validate --strict` accepts a channel bound to an existing `.mcp.json` server and also accepts a deliberately dangling server name; the CLI validates the declaration shape but does not cross-check the sibling MCP keys, so Agent Bundle's claude.channels.server.unknown plan diagnostic is the binding guard (host-adapters.native.test.ts).",
"2026-09-01: https://code.claude.com/docs/en/plugin-dependencies documents `.claude-plugin/plugin.json` dependencies as a union of bare plugin-name strings and closed objects with required `name` plus optional `version` and `marketplace`; omitted marketplace resolves in the declaring plugin's marketplace.",
"2026-09-01: https://code.claude.com/docs/en/plugin-dependencies documents npm-style semver ranges, pre-release exclusion unless a range opts in, and git tag resolution through `{name}--v{version}`. Git sources fetch the highest satisfying tag; npm, archive, and command sources are load-checked only, command-source dependencies are never auto-installed, and a dependency's headersHelper is never run automatically.",
"2026-09-01: https://code.claude.com/docs/en/plugin-dependencies requires cross-marketplace targets in `allowCrossMarketplaceDependenciesOn` on the root marketplace; only the root allowlist is consulted, trust does not chain, and users may manually install a blocked dependency first.",
Expand Down
154 changes: 153 additions & 1 deletion packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ export interface ClaudeUserConfigOption {
readonly type: ClaudeUserConfigOptionType;
}

/** One Claude Code message channel bound to an MCP server supplied by this plugin. */
export interface ClaudeChannelConfig {
readonly server: string;
readonly userConfig?: Readonly<Record<string, ClaudeUserConfigOption>>;
}

/**
* One Claude Code subagent status line: the command object documented for
* `subagentStatusLine`, which renders a custom row body for each subagent in
Expand Down Expand Up @@ -184,6 +190,8 @@ export interface ClaudeDependencyConfig {
export interface ClaudeHostConfig extends AgentBundleHostConfig {
/** Project-authored directory copied to the plugin-root `bin/` executable convention. */
readonly bin?: string;
/** Message channels whose server names must resolve in this plugin's emitted `.mcp.json`. */
readonly channels?: readonly ClaudeChannelConfig[];
/** Whether a newly installed plugin starts enabled when no stronger host state exists. */
readonly defaultEnabled?: boolean;
/**
Expand Down Expand Up @@ -253,7 +261,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.10.0',
adapterRevision: '1.11.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -985,6 +993,17 @@ const noManifestMetadataPlan: ClaudeManifestMetadataPlan = deepFreeze({
sourceInputs: [],
});

interface ClaudeChannelsPlan {
readonly diagnostics: readonly Diagnostic[];
readonly document?: readonly Record<string, unknown>[];
readonly sourceInputs: readonly string[];
}

const noChannelsPlan: ClaudeChannelsPlan = deepFreeze({
diagnostics: [],
sourceInputs: [],
});

const manifestMetadataDiagnostic = (
code: string,
message: string,
Expand Down Expand Up @@ -1045,6 +1064,129 @@ const planClaudeManifestMetadata = (model: NormalizedPlugin): ClaudeManifestMeta
};
};

const channelFields: ReadonlySet<string> = new Set(['server', 'userConfig']);

/**
* Lowers `claude.channels` into plugin manifest declarations after binding
* each channel to a server that survived this target's MCP planning. Duplicate
* declarations are retained in authored order because the host contract does
* not require one channel per server.
*/
export const planClaudeChannels = (
model: NormalizedPlugin,
pluginMcpServerNames: ReadonlySet<string>,
): ClaudeChannelsPlan => {
const extension = model.extensions[claudeName];
if (extension === undefined || !isDataRecord(extension.value)) return noChannelsPlan;
const declared = extension.value['channels'];
if (declared === undefined) return noChannelsPlan;
const inputs = sourceInputs(extension.provenance.sourcePath);
if (!Array.isArray(declared) || declared.length === 0) {
return {
diagnostics: [userConfigDiagnostic(
'claude.channels.declaration.invalid',
'Claude channels must be a nonempty array of channel declarations.',
'Declare at least one channel as { server, userConfig? }, then rebuild.',
)],
sourceInputs: inputs,
};
}

const diagnostics: Diagnostic[] = [];
const document: Record<string, unknown>[] = [];
for (const [index, channel] of declared.entries()) {
if (!isPlainDataRecord(channel)) {
diagnostics.push(userConfigDiagnostic(
'claude.channels.entry.invalid',
`Claude channel at index ${index} must be a channel declaration object.`,
`Replace channels[${index}] with an object containing server and optional userConfig, then rebuild.`,
));
continue;
}
for (const field of Object.keys(channel).sort()) {
if (channelFields.has(field)) continue;
diagnostics.push(userConfigDiagnostic(
'claude.channels.field.unknown',
`Claude channel at index ${index} declares unknown field ${JSON.stringify(field)}.`,
`Remove channels[${index}].${field}; channel declarations support only server and userConfig.`,
));
}

const server = channel['server'];
if (typeof server !== 'string' || server.length === 0) {
diagnostics.push(userConfigDiagnostic(
'claude.channels.server.required',
`Claude channel at index ${index} requires a nonempty server name.`,
`Set channels[${index}].server to a key emitted in this plugin's .mcp.json, then rebuild.`,
));
} else if (!pluginMcpServerNames.has(server)) {
diagnostics.push(userConfigDiagnostic(
'claude.channels.server.unknown',
pluginMcpServerNames.size === 0
? `Claude channel at index ${index} binds to ${JSON.stringify(server)}, but this target emits no plugin MCP servers.`
: `Claude channel at index ${index} binds to undeclared plugin MCP server ${JSON.stringify(server)}.`,
`Set channels[${index}].server to one of the plugin MCP servers selected for this target, then rebuild.`,
));
}

const userConfig = channel['userConfig'];
let plannedUserConfig: Record<string, Record<string, unknown>> | undefined;
if (userConfig !== undefined) {
if (!isPlainDataRecord(userConfig) || Object.keys(userConfig).length === 0) {
diagnostics.push(userConfigDiagnostic(
'claude.channels.userConfig.invalid',
`Claude channel at index ${index} userConfig must be a nonempty plain record of option key to option declaration.`,
`Set channels[${index}].userConfig to a nonempty option map or remove it, then rebuild.`,
));
} else {
plannedUserConfig = Object.create(null) as Record<string, Record<string, unknown>>;
const environmentOwners = new Map<string, string>();
for (const key of Object.keys(userConfig).sort()) {
if (!userConfigIdentifier.test(key)) {
diagnostics.push(userConfigDiagnostic(
'claude.channels.key.invalid',
`Claude channel at index ${index} userConfig option key "${key}" must match ^[A-Za-z_][A-Za-z0-9_]*$.`,
`Rename channels[${index}].userConfig option "${key}" to a valid identifier, then rebuild.`,
));
}
const environmentKey = key.toUpperCase();
const owner = environmentOwners.get(environmentKey);
if (owner === undefined) {
environmentOwners.set(environmentKey, key);
} else {
diagnostics.push(userConfigDiagnostic(
'claude.channels.key.collision',
`Claude channel at index ${index} userConfig option keys "${owner}" and "${key}" both export as CLAUDE_PLUGIN_OPTION_${environmentKey}.`,
`Rename one channels[${index}].userConfig option so every key remains unique after uppercasing, then rebuild.`,
));
}
const optionPlan = planClaudeUserConfigOption(key, userConfig[key]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point channel option diagnostics at the channel

When an option inside channels[index].userConfig is malformed, this shared helper emits claude.userConfig.* messages and recoveries such as Set userConfig.bot_token.type..., which refers to the unrelated top-level field and omits the channel index. In configurations with top-level options or multiple channels, following the reported recovery does not repair the offending declaration and the build remains blocked; pass the channel path/context into the helper so these diagnostics identify channels[index].userConfig.<key>.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2530cc32. The shared option planner now accepts a path prefix, so channel diagnostics and recoveries identify channels[i].userConfig and emit indexed claude.channels[i].userConfig.* codes.

diagnostics.push(...optionPlan.diagnostics);
if (optionPlan.value !== undefined) plannedUserConfig[key] = optionPlan.value;
}
}
}

if (
typeof server === 'string' &&
server.length > 0 &&
pluginMcpServerNames.has(server) &&
(userConfig === undefined || plannedUserConfig !== undefined)
) {
document.push({
server,
...(plannedUserConfig === undefined ? {} : { userConfig: plannedUserConfig }),
});
}
}

return {
diagnostics,
...(diagnostics.length === 0 ? { document: Object.freeze(document) } : {}),
sourceInputs: inputs,
};
};

interface ClaudeBinPlan {
readonly diagnostics: readonly Diagnostic[];
readonly entries: readonly TargetArtifactCopy[];
Expand Down Expand Up @@ -1299,6 +1441,8 @@ export const planClaudeArtifacts = (
const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers };
const mcpValid = mcp !== undefined && validateMcp(mcp);
if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors));
const channels = planClaudeChannels(model, new Set(mcpValid ? Object.keys(servers) : []));
diagnostics.push(...channels.diagnostics);
const lsp = planClaudeLsp(model);
diagnostics.push(...lsp.diagnostics);
const userConfig = planClaudeUserConfig(model);
Expand All @@ -1324,6 +1468,7 @@ export const planClaudeArtifacts = (
const plugin = {
author: { name: model.metadata.name },
...manifestMetadata.document,
...(channels.document === undefined ? {} : { channels: channels.document }),
...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }),
description: model.metadata.description ?? model.metadata.name,
...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }),
Expand Down Expand Up @@ -1365,6 +1510,7 @@ export const planClaudeArtifacts = (

const basePlan = standardPluginArtifactPlan({
additionalPluginSourceInputs: sourceInputs(
...channels.sourceInputs,
...userConfig.sourceInputs,
...manifestMetadata.sourceInputs,
...dependencies.sourceInputs,
Expand Down Expand Up @@ -1419,6 +1565,12 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
evidence,
'The pinned Claude plugin contract does not document the plugin-root bin executable surface.',
),
channels: capabilityStateFromSupport(
capabilityTable.plugin.channels.bindsToPluginMcpServer &&
capabilityTable.plugin.channels.perChannelUserConfig,
evidence,
'The pinned Claude plugin contract does not document message channel declarations.',
),
commands: capabilityStateFromSupport(
capabilityTable.plugin.commands,
evidence,
Expand Down
5 changes: 4 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.9.0',
adapterRevision: '1.10.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 @@ -567,6 +567,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
bin: unavailableCapability(
'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.',
),
channels: unavailableCapability(
'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.',
),
commands: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!),
cursorAdapter.capabilities.commands!,
Expand Down
Loading
Loading