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

Add validated Claude Code plugin dependency declarations and emit them in generated plugin manifests, including unified plugin bundles.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@
"organizationDistributionProhibited": true
},
"commands": true,
"dependencies": {
"autoInstall": true,
"autoInstallExceptions": ["command-source", "headersHelper"],
"crossMarketplaceAllowlist": "allowCrossMarketplaceDependenciesOn",
"entryForms": ["name", "object"],
"errorCodes": [
"dependency-unsatisfied",
"range-conflict",
"dependency-version-unsatisfied",
"no-matching-tag"
],
"objectFields": ["marketplace", "name", "version"],
"prereleaseOptIn": true,
"prune": true,
"rangeIntersection": true,
"semverRanges": true,
"tagConvention": "{name}--v{version}"
},
"devtools": {
"details": true,
"listJson": true,
Expand Down Expand Up @@ -149,7 +167,13 @@
"2026-09-01: https://code.claude.com/docs/en/plugins-reference rejects `${user_config.*}` in shell-form hook commands (use exec form with args or `CLAUDE_PLUGIN_OPTION_<KEY>`), monitor commands (read a config file), and MCP `headersHelper` (read a config file); before Claude Code v2.1.207 those fields performed substitution.",
"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: 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/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.",
"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)."
]
}
}
237 changes: 235 additions & 2 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,25 @@ export interface ClaudeSettingsConfig {
readonly subagentStatusLine?: ClaudeSubagentStatusLineConfig;
}

/**
* One Claude Code plugin dependency. Without `marketplace`, Claude resolves
* the name in the declaring plugin's marketplace. Cross-marketplace
* dependencies require the target marketplace in the root marketplace's
* `allowCrossMarketplaceDependenciesOn`; only the root allowlist is consulted,
* so trust does not chain.
*
* Version ranges resolve against git tags named `{name}--v{version}`. Git
* sources fetch the highest satisfying tag; npm, archive, and command sources
* are only checked after loading. Command-source dependencies and dependencies
* that require `headersHelper` are never auto-installed. Pre-releases are
* excluded unless the range opts in with a suffix such as `^2.0.0-0`.
*/
export interface ClaudeDependencyConfig {
readonly marketplace?: string;
readonly name: string;
readonly version?: string;
}

/**
* Claude's host config. `lspServers` lives here rather than in a portable
* top-level block because no other pinned host contract has an LSP surface;
Expand All @@ -165,6 +184,12 @@ export interface ClaudeSettingsConfig {
export interface ClaudeHostConfig extends AgentBundleHostConfig {
/** Project-authored directory copied to the plugin-root `bin/` executable convention. */
readonly bin?: string;
/**
* 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)[];
readonly lspServers?: Readonly<Record<string, ClaudeLspServerConfig>>;
readonly settings?: ClaudeSettingsConfig;
/** Enable-time options copied into `.claude-plugin/plugin.json`. */
Expand Down Expand Up @@ -222,7 +247,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.8.0',
adapterRevision: '1.9.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -382,6 +407,203 @@ const isDataRecord = (value: unknown): value is Readonly<Record<string, unknown>

const isPlainDataRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value));
const dependencyFields: ReadonlySet<string> = new Set(['marketplace', 'name', 'version']);
const pluginNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const numericSemverIdentifier = '(?:0|[1-9][0-9]*)';
const prereleaseSemverIdentifier = '(?:0|[1-9][0-9]*|[A-Za-z-][0-9A-Za-z-]*)';
const semverSuffix = `(?:-${prereleaseSemverIdentifier}(?:\\.${prereleaseSemverIdentifier})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?`;
const fullSemverVersion = `${numericSemverIdentifier}\\.${numericSemverIdentifier}\\.${numericSemverIdentifier}${semverSuffix}`;
const partialSemverVersion = `${numericSemverIdentifier}(?:\\.${numericSemverIdentifier})?`;
const wildcardSemverVersion = `${numericSemverIdentifier}\\.(?:[xX*]|${numericSemverIdentifier}\\.[xX*])`;
const semverRangeVersion = `(?:${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`;
const hyphenRangePattern = new RegExp(`^${semverRangeVersion}\\s+-\\s+${semverRangeVersion}$`, 'u');
const comparatorPattern = new RegExp(`(?:~|\\^|>=|<=|>|<|=)?\\s*${semverRangeVersion}`, 'uy');

/**
* Validates npm-style dependency range syntax without resolving versions.
* Accepted clauses are bare/partial versions, x-wildcards, `~`, `^`, `>=`,
* `<=`, `>`, `<`, and `=` comparators, space-separated intersections, hyphen
* ranges, `||` unions, and semver pre-release/build suffixes.
*/
export const isValidClaudeDependencyRange = (value: string): boolean => {
if (value.length === 0 || value.trim() !== value) return false;
for (const clause of value.split('||')) {
const range = clause.trim();
if (range.length === 0) return false;
if (hyphenRangePattern.test(range)) continue;
Comment on lines +428 to +433

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 Accept standalone npm wildcard ranges

For a dependency version of "*" or "x", which are valid npm-style semver ranges, semverRangeVersion requires a leading numeric identifier, so this validator returns false and suppresses the entire dependency document with claude.dependencies.version.invalid. Since the new surface promises npm-style ranges and explicitly describes x-wildcards, accept standalone wildcard ranges rather than rejecting valid host input.

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. isValidClaudeDependencyRange now accepts standalone *, x, and X clauses, with direct regression cases.

let offset = 0;
let comparators = 0;
while (offset < range.length) {
comparatorPattern.lastIndex = offset;
const match = comparatorPattern.exec(range);
if (match === null || match.index !== offset) return false;
comparators += 1;
offset = comparatorPattern.lastIndex;
if (offset === range.length) break;
const whitespace = /^\s+/u.exec(range.slice(offset));
if (whitespace === null) return false;
offset += whitespace[0].length;
}
if (comparators === 0) return false;
}
return true;
};

interface ClaudeDependenciesPlan {
readonly diagnostics: readonly Diagnostic[];
readonly document?: readonly (string | Readonly<Record<string, string>>)[];
readonly sourceInputs: readonly string[];
}

const noDependenciesPlan: ClaudeDependenciesPlan = deepFreeze({
diagnostics: [],
sourceInputs: [],
});

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

const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan => {
const extension = model.extensions[claudeName];
if (extension === undefined || !isDataRecord(extension.value)) return noDependenciesPlan;
const declared = extension.value['dependencies'];
if (declared === undefined) return noDependenciesPlan;
const inputs = sourceInputs(extension.provenance.sourcePath);
if (!Array.isArray(declared) || declared.length === 0) {
return {
diagnostics: [dependencyDiagnostic(
'claude.dependencies.declaration.invalid',
'Claude dependencies must be a nonempty array of plugin names or dependency objects.',
'Declare at least one dependency as a nonempty plugin name or { name, version?, marketplace? } object, then rebuild.',
)],
sourceInputs: inputs,
};
}

const diagnostics: Diagnostic[] = [];
const document: (string | Readonly<Record<string, string>>)[] = [];
const seen = new Set<string>();
for (const [index, entry] of declared.entries()) {
if (typeof entry === 'string') {
if (entry.length === 0) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.entry.invalid',
`Claude dependency at index ${index} must be a nonempty plugin name or dependency object.`,
'Replace the entry with a nonempty plugin name or { name, version?, marketplace? } object, then rebuild.',
));
continue;
}
if (!pluginNamePattern.test(entry)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.name.invalid',
`Claude dependency name ${JSON.stringify(entry)} must match the plugin-name pattern ${pluginNamePattern.source}.`,
'Use a lowercase kebab-case Claude plugin name, then rebuild.',
));
continue;
}
if (entry === model.metadata.name) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.self',
`Claude plugin ${JSON.stringify(model.metadata.name)} cannot depend on itself.`,
'Remove the self-dependency; self-dependencies can deadlock plugin enable and disable operations.',
));
continue;
}
const identity = `\u0000${entry}`;
if (seen.has(identity)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.duplicate',
`Claude dependency ${JSON.stringify(entry)} is declared more than once in the same marketplace.`,
'Keep one declaration for each dependency name and marketplace pair, then rebuild.',
));
continue;
}
seen.add(identity);
document.push(entry);
continue;
}

if (!isDataRecord(entry)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.entry.invalid',
`Claude dependency at index ${index} must be a nonempty plugin name or dependency object.`,
'Replace the entry with a nonempty plugin name or { name, version?, marketplace? } object, then rebuild.',
));
continue;
}
for (const field of Object.keys(entry).sort()) {
if (dependencyFields.has(field)) continue;
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.field.unknown',
`Claude dependency ${index} declares unknown field ${JSON.stringify(field)}.`,
'Remove the unknown field; dependency objects support only name, version, and marketplace.',
));
}
const name = entry['name'];
if (typeof name !== 'string' || name.length === 0) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.name.required',
`Claude dependency object at index ${index} requires a nonempty name.`,
'Set name to a nonempty lowercase kebab-case Claude plugin name, then rebuild.',
));
continue;
}
if (!pluginNamePattern.test(name)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.name.invalid',
`Claude dependency name ${JSON.stringify(name)} must match the plugin-name pattern ${pluginNamePattern.source}.`,
'Use a lowercase kebab-case Claude plugin name, then rebuild.',
));
continue;
}
const marketplace = entry['marketplace'];
if (marketplace !== undefined && (typeof marketplace !== 'string' || marketplace.length === 0)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.marketplace.invalid',
`Claude dependency ${JSON.stringify(name)} marketplace must be a nonempty string when declared.`,
'Set marketplace to a nonempty marketplace name or omit it for same-marketplace resolution, then rebuild.',
));
continue;
}
const version = entry['version'];
if (version !== undefined && (typeof version !== 'string' || !isValidClaudeDependencyRange(version))) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.version.invalid',
`Claude dependency ${JSON.stringify(name)} version must be a valid npm-style semver range using forms such as ~2.1.0, ^2.0, >=1.4, =2.1.0, || unions, or an explicit pre-release opt-in such as ^2.0.0-0.`,
'Replace version with a documented semver range; invalid ranges become range-conflict errors only after distribution.',
));
continue;
}
if (name === model.metadata.name) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.self',
`Claude plugin ${JSON.stringify(model.metadata.name)} cannot depend on itself.`,
'Remove the self-dependency; self-dependencies can deadlock plugin enable and disable operations.',
));
continue;
Comment on lines +579 to +585

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 Allow same-named plugins from another marketplace

When a plugin such as review-tools declares { marketplace: "acme-shared", name: "review-tools" }, this unconditional name comparison reports a self-dependency even though the explicit marketplace gives the dependency a different identity. The implementation itself deduplicates by marketplace/name pairs, and only an omitted marketplace resolves beside the declaring plugin, so the self check should account for the marketplace rather than rejecting every cross-marketplace name collision.

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 self-dependency diagnostic now applies only to same-marketplace resolution, so { marketplace: "acme-shared", name: "<own-name>" } is preserved and covered.

}
const identity = `${typeof marketplace === 'string' ? marketplace : ''}\u0000${name}`;
if (seen.has(identity)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.duplicate',
`Claude dependency ${JSON.stringify(name)} is declared more than once for marketplace ${JSON.stringify(marketplace ?? 'same marketplace')}.`,
'Keep one declaration for each dependency name and marketplace pair, then rebuild.',
));
continue;
}
seen.add(identity);
document.push(Object.freeze({
...(typeof marketplace === 'string' ? { marketplace } : {}),
name,
...(typeof version === 'string' ? { version } : {}),
}));
}

if (hasErrors(diagnostics)) return { diagnostics, sourceInputs: inputs };
return { diagnostics, document: Object.freeze(document), sourceInputs: inputs };
};

const expandLspToken = (value: unknown): unknown =>
typeof value === 'string' ? expandClaudeToken(value) : value;
Expand Down Expand Up @@ -1008,6 +1230,8 @@ export const planClaudeArtifacts = (
diagnostics.push(...bin.diagnostics);
const settings = planClaudeSettings(model);
diagnostics.push(...settings.diagnostics);
const dependencies = planClaudeDependencies(model);
diagnostics.push(...dependencies.diagnostics);
const generatedHooks = planHooks(model, targetName, hookContract);
diagnostics.push(...generatedHooks.diagnostics);
if (generatedHooks.document !== undefined) {
Expand All @@ -1020,6 +1244,7 @@ export const planClaudeArtifacts = (

const plugin = {
author: { name: model.metadata.name },
...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Emit marketplace metadata needed to resolve dependencies

On a fresh installation using the generated INSTALL.md, neither dependency form is generally auto-installable: bare names resolve in the generated declaring marketplace, whose plugins array contains only the declaring plugin, while cross-marketplace objects require the root marketplace's allowCrossMarketplaceDependenciesOn field according to claude-2.1.250.json, but the emitted marketplace and its schema omit that field. The manifest therefore advertises dependencies that become dependency-unsatisfied or are blocked unless users manually install them first; emit the required marketplace entries/allowlist or reject declarations the generated distribution cannot resolve.

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 allowlist half was fixed by #310; this commit fixes the bare-name prong by validating same-marketplace names against the emitted plugin list and raising claude.dependencies.unresolved before an unresolvable declaration ships.

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

const basePlan = standardPluginArtifactPlan({
additionalPluginSourceInputs: userConfig.sourceInputs,
additionalPluginSourceInputs: sourceInputs(...userConfig.sourceInputs, ...dependencies.sourceInputs),
diagnostics,
...(hostDocuments.length === 0 ? {} : { hostDocuments }),
hookDocument,
Expand Down Expand Up @@ -1115,6 +1340,14 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
evidence,
'The pinned Claude Code plugin contract does not support commands.',
),
dependencies: capabilityStateFromSupport(
capabilityTable.plugin.dependencies.autoInstall &&
capabilityTable.plugin.dependencies.entryForms.includes('name') &&
capabilityTable.plugin.dependencies.entryForms.includes('object') &&
capabilityTable.plugin.dependencies.semverRanges,
evidence,
'The pinned Claude plugin contract does not document manifest dependencies.',
),
install: supportedCapability(evidence),
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
Expand Down
10 changes: 9 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.7.0',
adapterRevision: '1.8.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 @@ -571,6 +571,14 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!),
cursorAdapter.capabilities.commands!,
),
// The Claude half emits the declaration, but neither pinned non-Claude
// manifest has a shared dependency-resolution surface.
dependencies: intersectCapabilityStates(
claudeAdapter.capabilities.dependencies!,
unavailableCapability(
'The pinned Codex and Cursor plugin contracts publish no dependency declaration or resolution surface; manifest dependencies reach Claude Code only.',
),
),
install: unavailableCapability(
'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.',
),
Expand Down
Loading
Loading