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

Emit validated Claude Code `displayName`, `metadata`, and `defaultEnabled` manifest fields, and pin the documented custom component-path discovery rules as capability evidence.
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,54 @@
"transports": ["socket", "stdio"],
"vendorsServerBinary": false
},
"metadata": {
"defaultEnabled": {
"default": true,
"dependencyRequirementPrecedence": true,
"field": true,
"marketplaceEntryPrecedence": true,
"persistedEnabledPluginsPrecedence": true
},
"displayName": {
"fallback": "name",
"field": true
},
"freeform": {
"field": true,
"hostReadsValues": false,
"nonObjectHandling": "warning-and-ignore",
"strictValidation": "failure"
},
"standardFields": [
"$schema",
"author",
"description",
"homepage",
"keywords",
"license",
"repository",
"version"
]
},
"manifest": ".claude-plugin/plugin.json",
"marketplace": ".claude-plugin/marketplace.json",
"paths": {
"addsToDefault": ["skills"],
"fieldTypes": ["array", "string"],
"ignoredDefaultFolderWarning": true,
"marketplaceRootSkillsSubdirectoriesReplaceDefault": true,
"relativePrefix": "./",
"replacesDefault": [
"agents",
"commands",
"experimental.monitors",
"experimental.themes",
"outputStyles",
"workflows"
],
"skillsRootException": ".",
"skillsRootExceptionSince": "2.1.221"
},
"settings": {
"config": "settings.json",
"placeholderSubstitution": false,
Expand Down Expand Up @@ -173,7 +219,14 @@
"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.",
"2026-09-01: https://code.claude.com/docs/en/plugin-dependencies documents intersection of constraints from multiple dependents, constrained auto-update, transitive enable, disable refusal while depended upon, release of constraints after uninstall, and pruning only auto-installed orphan dependencies.",
"2026-09-01: https://code.claude.com/docs/en/plugin-dependencies exposes dependency-unsatisfied, range-conflict, dependency-version-unsatisfied, and no-matching-tag in `claude plugin list --json` errors.",
"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 declaring one bare dependency and one `{name, version}` dependency object and prints \"Validation passed\" (host-adapters.native.test.ts)."
"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 declaring one bare dependency and one `{name, version}` dependency object and prints \"Validation passed\" (host-adapters.native.test.ts).",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents `displayName` as the human-readable UI name that falls back to `name`, `metadata` as a free-form object Claude Code does not read, and `defaultEnabled` as the boolean fallback enabled state whose default is true. The same metadata table documents `$schema`, version, description, author, homepage, repository, license, and keywords.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that an existing `enabledPlugins` user setting and an active dependency requirement both take precedence over plugin.json `defaultEnabled`, while a marketplace entry's `defaultEnabled` takes precedence over the plugin manifest value.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents that wrong types make most manifest fields fail plugin loading, but non-object `experimental` and `metadata` values are ignored with a `claude plugin validate` warning; `--strict` promotes warnings to failure. Before v2.1.222, `metadata` was treated as unrecognized.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents custom component path fields as string or array values: commands, agents, workflows, outputStyles, experimental.themes, and experimental.monitors replace their default scans, while skills adds to the default scan. Keeping a replaced default requires listing it explicitly, for example `\"commands\": [\"./commands/\", \"./extras/\"]`.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference 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."
]
}
}
107 changes: 105 additions & 2 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,19 @@ export interface ClaudeDependencyConfig {
export interface ClaudeHostConfig extends AgentBundleHostConfig {
/** Project-authored directory copied to the plugin-root `bin/` executable convention. */
readonly bin?: string;
/** Whether a newly installed plugin starts enabled when no stronger host state exists. */
readonly defaultEnabled?: boolean;
/**
* Plugins Claude Code resolves and auto-installs. A bare name uses the
* declaring plugin's marketplace; the object form adds a semver range or an
* explicitly allowlisted cross-marketplace source.
*/
readonly dependencies?: readonly (string | ClaudeDependencyConfig)[];
/** Human-readable plugin name shown in Claude Code UI surfaces. */
readonly displayName?: string;
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>>;
readonly settings?: ClaudeSettingsConfig;
/** Enable-time options copied into `.claude-plugin/plugin.json`. */
readonly userConfig?: Readonly<Record<string, ClaudeUserConfigOption>>;
Expand Down Expand Up @@ -247,7 +253,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.9.0',
adapterRevision: '1.10.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -968,6 +974,77 @@ const planClaudeUserConfig = (model: NormalizedPlugin): ClaudeUserConfigPlan =>
};
};

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

const noManifestMetadataPlan: ClaudeManifestMetadataPlan = deepFreeze({
diagnostics: [],
sourceInputs: [],
});

const manifestMetadataDiagnostic = (
code: string,
message: string,
recovery: string,
): Diagnostic => ({
...errorDiagnostic(code, message),
recovery,
});

/**
* Validates Claude-only manifest metadata fields from the normalized
* extension envelope. Normalization already guarantees strict finite JSON;
* this boundary additionally requires metadata's top level to be a plain
* object because Claude Code only warns and ignores arrays or null.
*/
const planClaudeManifestMetadata = (model: NormalizedPlugin): ClaudeManifestMetadataPlan => {
const extension = model.extensions[claudeName];
if (extension === undefined || !isDataRecord(extension.value)) return noManifestMetadataPlan;
const displayName = extension.value['displayName'];
const metadataValue = extension.value['metadata'];
const defaultEnabled = extension.value['defaultEnabled'];
if (displayName === undefined && metadataValue === undefined && defaultEnabled === undefined) {
return noManifestMetadataPlan;
}

const diagnostics: Diagnostic[] = [];
if (displayName !== undefined && (typeof displayName !== 'string' || displayName.trim().length === 0)) {
diagnostics.push(manifestMetadataDiagnostic(
'claude.manifest.displayName.invalid',
'Claude displayName must be a nonempty string after trimming whitespace.',
'Set claude.displayName to the human-readable plugin name shown in Claude Code, or remove it to fall back to plugin.name.',
));
}
if (metadataValue !== undefined && !isPlainDataRecord(metadataValue)) {
diagnostics.push(manifestMetadataDiagnostic(
'claude.manifest.metadata.invalid',
'Claude metadata must be a plain JSON object; arrays and null are not accepted.',
'Set claude.metadata to a plain JSON-serializable object, or remove it.',
));
}
if (defaultEnabled !== undefined && typeof defaultEnabled !== 'boolean') {
diagnostics.push(manifestMetadataDiagnostic(
'claude.manifest.defaultEnabled.invalid',
'Claude defaultEnabled must be a boolean.',
'Set claude.defaultEnabled to true or false, or remove it to use Claude Code\'s default of true.',
));
}
const inputs = sourceInputs(extension.provenance.sourcePath);
if (diagnostics.length > 0) return { diagnostics, sourceInputs: inputs };
return {
diagnostics,
document: Object.freeze({
...(defaultEnabled === undefined ? {} : { defaultEnabled }),
...(displayName === undefined ? {} : { displayName }),
...(metadataValue === undefined ? {} : { metadata: metadataValue }),
}),
sourceInputs: inputs,
};
};

interface ClaudeBinPlan {
readonly diagnostics: readonly Diagnostic[];
readonly entries: readonly TargetArtifactCopy[];
Expand Down Expand Up @@ -1226,6 +1303,8 @@ export const planClaudeArtifacts = (
diagnostics.push(...lsp.diagnostics);
const userConfig = planClaudeUserConfig(model);
diagnostics.push(...userConfig.diagnostics);
const manifestMetadata = planClaudeManifestMetadata(model);
diagnostics.push(...manifestMetadata.diagnostics);
const bin = planClaudeBin(model, targetName);
diagnostics.push(...bin.diagnostics);
const settings = planClaudeSettings(model);
Expand All @@ -1244,6 +1323,7 @@ export const planClaudeArtifacts = (

const plugin = {
author: { name: model.metadata.name },
...manifestMetadata.document,
...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }),
description: model.metadata.description ?? model.metadata.name,
...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }),
Expand Down Expand Up @@ -1284,7 +1364,11 @@ export const planClaudeArtifacts = (
}

const basePlan = standardPluginArtifactPlan({
additionalPluginSourceInputs: sourceInputs(...userConfig.sourceInputs, ...dependencies.sourceInputs),
additionalPluginSourceInputs: sourceInputs(
...userConfig.sourceInputs,
...manifestMetadata.sourceInputs,
...dependencies.sourceInputs,
),
diagnostics,
...(hostDocuments.length === 0 ? {} : { hostDocuments }),
hookDocument,
Expand Down Expand Up @@ -1357,6 +1441,25 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
evidence,
'The pinned Claude plugin contract does not document the plugin-root .lsp.json LSP surface.',
),
manifestMetadata: capabilityStateFromSupport(
capabilityTable.plugin.metadata.defaultEnabled.default &&
capabilityTable.plugin.metadata.defaultEnabled.field &&
capabilityTable.plugin.metadata.displayName.fallback === 'name' &&
capabilityTable.plugin.metadata.displayName.field &&
capabilityTable.plugin.metadata.freeform.field &&
capabilityTable.plugin.metadata.freeform.hostReadsValues === false,
evidence,
'The pinned Claude plugin contract does not document displayName, metadata, and defaultEnabled manifest fields.',
),
manifestPaths: capabilityStateFromSupport(
capabilityTable.plugin.paths.addsToDefault.includes('skills') &&
capabilityTable.plugin.paths.replacesDefault.includes('commands') &&
capabilityTable.plugin.paths.relativePrefix === './' &&
capabilityTable.plugin.paths.skillsRootException === '.' &&
capabilityTable.plugin.paths.skillsRootExceptionSince === '2.1.221',
evidence,
'The pinned Claude plugin contract does not document custom component path fields and their replace-versus-add rules.',
),
mcp: capabilityStateFromSupport(
capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
evidence,
Expand Down
14 changes: 13 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.8.0',
adapterRevision: '1.9.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 @@ -594,6 +594,18 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
// Claude supports LSP and Codex has no LSP surface, so this intersection is
// honestly unavailable even though the Claude half still emits `.lsp.json`.
lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!),
manifestMetadata: intersectCapabilityStates(
claudeAdapter.capabilities.manifestMetadata!,
unavailableCapability(
'The pinned Codex and Cursor plugin contracts do not share Claude manifest metadata fields; displayName, metadata, and defaultEnabled reach Claude Code only.',
),
),
manifestPaths: intersectCapabilityStates(
claudeAdapter.capabilities.manifestPaths!,
unavailableCapability(
'The unified bundle emits canonical default component directories and the pinned Codex and Cursor contracts do not share Claude custom manifest path rules.',
),
),
mcp: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!),
cursorAdapter.capabilities.mcp!,
Expand Down
Loading
Loading