From dac4f278bfeefcd1e6c64b22caf5d34f4570e34e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 06:10:02 +0000 Subject: [PATCH] feat(claude): emit plugin dependencies Validate Claude dependency declarations before packaging and preserve them in native and unified plugin manifests with pinned host evidence. --- .changeset/claude-plugin-dependencies.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 26 +- packages/agent-bundle/src/adapters/claude.ts | 237 +++++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 10 +- .../adapters/schemas/claude/PROVENANCE.json | 7 +- .../schemas/claude/plugin.schema.json | 20 ++ .../tests/adapter-capability-states.test.ts | 22 ++ .../tests/adapter-metadata.test.ts | 6 +- packages/agent-bundle/tests/api.test.ts | 30 +++ .../tests/artifact-validator.test.ts | 57 +++++ .../tests/host-adapters.native.test.ts | 30 +++ .../agent-bundle/tests/host-adapters.test.ts | 95 +++++++ .../agent-bundle/tests/plugin-bundle.test.ts | 30 +++ 13 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 .changeset/claude-plugin-dependencies.md diff --git a/.changeset/claude-plugin-dependencies.md b/.changeset/claude-plugin-dependencies.md new file mode 100644 index 000000000..41bb21d52 --- /dev/null +++ b/.changeset/claude-plugin-dependencies.md @@ -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. 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 58e12973b..e525e705a 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 @@ -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, @@ -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_`), 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[].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)." ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 6877feab4..285962436 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -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; @@ -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>; readonly settings?: ClaudeSettingsConfig; /** Enable-time options copied into `.claude-plugin/plugin.json`. */ @@ -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), }); @@ -382,6 +407,203 @@ const isDataRecord = (value: unknown): value is Readonly const isPlainDataRecord = (value: unknown): value is Readonly> => isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value)); +const dependencyFields: ReadonlySet = 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; + 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>)[]; + 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>)[] = []; + const seen = new Set(); + 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; + } + 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; @@ -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) { @@ -1020,6 +1244,7 @@ export const planClaudeArtifacts = ( const plugin = { author: { name: model.metadata.name }, + ...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }), description: model.metadata.description ?? model.metadata.name, ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), name: model.metadata.name, @@ -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, @@ -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), diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 3de8714e4..c76584d2c 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.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 @@ -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.', ), diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 3af54c8ce..24670088e 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -2,7 +2,8 @@ "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.", + "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.", + "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -25,8 +26,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/mcp" }, "plugin.schema.json": { - "bytes": 4980, - "sha256": "6c8630118fbac739961d18eb2912c784db6e0dd015bb03f07bb80974f7bc9ebb", + "bytes": 5581, + "sha256": "9d69367331f484a8907d8e883f62da4d7b51c64fd843e9f98f2b0b48bf1ef319", "url": "https://code.claude.com/docs/en/plugins-reference" }, "settings.schema.json": { diff --git a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json index 88a4d34f8..e6fa25931 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json @@ -2,6 +2,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/plugin.schema.json", "$defs": { + "dependency": { + "additionalProperties": false, + "properties": { + "marketplace": { "minLength": 1, "type": "string" }, + "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + "version": { "minLength": 1, "type": "string" } + }, + "required": ["name"], + "type": "object" + }, "lspServer": { "additionalProperties": false, "properties": { @@ -106,6 +116,16 @@ "type": "object" }, "description": { "minLength": 1, "type": "string" }, + "dependencies": { + "items": { + "oneOf": [ + { "minLength": 1, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + { "$ref": "#/$defs/dependency" } + ] + }, + "minItems": 1, + "type": "array" + }, "hooks": { "const": "./hooks/hooks.json", "type": "string" }, "lspServers": { "anyOf": [ diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 3f1b799ac..353ae6cba 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -175,6 +175,28 @@ it('reports Claude userConfig support and honest unavailable composite coverage' expect(registry.supports('plugin', 'userConfig')).toBe(false); }); +it('reports Claude dependency support and honest unavailable composite coverage', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities.dependencies).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities.dependencies).toMatchObject({ + reason: expect.stringContaining('Claude Code only'), + state: 'unavailable', + }); + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities.dependencies).toBeUndefined(); + expect(registry.supports(target, 'dependencies')).toBe(false); + } + expect(registry.supports('claude', 'dependencies')).toBe(true); + expect(registry.supports('plugin', 'dependencies')).toBe(false); +}); + it('intersects supported composite capabilities and merges both evidence records', () => { const intersection = intersectCapabilityStates( supportedCapability(evidence('claude')), diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index dc8fec4fb..66c64a788 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.8.0', + adapterRevision: '1.9.0', observedVersion: '2.1.250', schemas: [ { @@ -119,7 +119,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '2.1.250', - sha256: '6c8630118fbac739961d18eb2912c784db6e0dd015bb03f07bb80974f7bc9ebb', + sha256: '9d69367331f484a8907d8e883f62da4d7b51c64fd843e9f98f2b0b48bf1ef319', }, { name: 'settings', @@ -154,7 +154,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.7.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.8.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 99b403118..fdc26216e 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -254,6 +254,36 @@ it('attaches a specific recovery to every invalid inspection diagnostic', async } }); +it('accepts the public claude.dependencies config surface and plans its manifest', async () => { + const root = await createProject(); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['claude'],", + ' claude: {', + " dependencies: ['audit-logger', { name: 'policy-kit', version: '^2.0', marketplace: 'acme-shared' }],", + ' },', + '};', + '', + ].join('\n')); + + const result = await readyInspection({ root }); + const manifest = result.plans[0]?.entries.find((entry) => + entry.relativePath === '.claude-plugin/plugin.json'); + + expect(result.diagnostics).toEqual([]); + expect(manifest?.kind).toBe('write'); + if (manifest?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); + expect(JSON.parse(manifest.content).dependencies).toEqual([ + 'audit-logger', + { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, + ]); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + it('reports one modern-MCP source diagnostic for a legacy SSE declaration', async () => { const root = await createProject(); try { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 0c9539f98..2d0e4e7bb 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -2114,6 +2114,63 @@ it('rejects a rehashed Claude settings document that carries an unsupported key' } }); +const claudeDependenciesFiles = async (dependencies: unknown): Promise => { + const registry = createDefaultRegistry(); + const model: NormalizedPlugin = { + ...installSurfaceModel('claude'), + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + target: 'claude', + value: { dependencies: ['audit-logger'] }, + }, + }, + }; + const files = registry.get('claude').plan(model).entries + .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') + .map((entry) => { + if (entry.relativePath !== '.claude-plugin/plugin.json') { + return { contents: entry.content, kind: 'generated' as const, path: `claude/${entry.relativePath}` }; + } + const manifest = JSON.parse(entry.content) as Record; + manifest.dependencies = dependencies; + return { + contents: `${JSON.stringify(manifest)}\n`, + kind: 'generated' as const, + path: 'claude/.claude-plugin/plugin.json', + }; + }); + return writeArtifact(files, true, [targetFromRegistry(registry, 'claude')]); +}; + +it('accepts a Claude plugin manifest carrying valid dependencies', async () => { + const root = await claudeDependenciesFiles([ + 'audit-logger', + { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, + ]); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('rejects a rehashed Claude plugin manifest carrying invalid dependencies', async () => { + const root = await claudeDependenciesFiles([{ name: 'audit-logger', source: './audit' }]); + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([expect.objectContaining({ + code: 'AB6012', + generatedPath: 'claude/.claude-plugin/plugin.json', + message: expect.stringContaining('schema "plugin"'), + target: 'claude', + })]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ ...installSurfaceModel(target), metadata: { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index b22394f78..3eb9cd97c 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -70,6 +70,19 @@ const withClaudeSettings = (settings: unknown): NormalizedPlugin => ({ }, }); +const withClaudeDependencies = (dependencies: unknown): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: { dependencies }, + }, + }, +}); + const writeClaudeArtifact = async ( root: string, planned: NormalizedPlugin, @@ -243,3 +256,20 @@ nativeIt('accepts emitted Claude userConfig under strict native validation', asy await rm(root, { force: true, recursive: true }); } }); + +nativeIt('accepts emitted Claude plugin dependencies under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-dependencies-')); + + try { + await writeClaudeArtifact(root, withClaudeDependencies([ + 'audit-logger', + { name: 'secrets-vault', version: '~2.1.0' }, + ])); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 3861f36bc..7e09ea1ac 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -7,6 +7,7 @@ import addFormats from 'ajv-formats'; import { expect, it } from '@rstest/core'; import { cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; +import { isValidClaudeDependencyRange } from '../src/adapters/claude.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { build } from './support/build.ts'; @@ -153,6 +154,23 @@ const withClaudeSettings = ( }, }); +const withClaudeDependencies = ( + model: NormalizedPlugin, + dependencies: unknown, + target = 'claude', +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/dependencies.config.ts' }, + target, + value: { dependencies }, + }, + }, +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -999,6 +1017,83 @@ it('emits no Claude settings document when the host config declares none', () => expect(plan.entries.some((entry) => entry.relativePath === 'settings.json')).toBe(false); }); +it('emits Claude plugin dependencies in authored order with closed object keys and extension provenance', async () => { + const model = withClaudeDependencies(plugin, [ + 'audit-logger', + { version: '~2.1.0', name: 'secrets-vault' }, + { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0.0-0' }, + ]); + const plan = createDefaultRegistry().get('claude').plan(model); + const entry = plan.entries.find((candidate) => candidate.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toEqual([]); + expect(entry?.kind).toBe('write'); + if (entry?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); + expect(JSON.parse(entry.content).dependencies).toEqual([ + 'audit-logger', + { name: 'secrets-vault', version: '~2.1.0' }, + { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0.0-0' }, + ]); + expect(entry.sourceInputs).toContain('/workspace/dependencies.config.ts'); + await validateDocuments('claude', writeContents(model, 'claude')); +}); + +it.each([ + { dependencies: [], code: 'claude.dependencies.declaration.invalid', label: 'an empty array' }, + { dependencies: [7], code: 'claude.dependencies.entry.invalid', label: 'a non-string non-object entry' }, + { dependencies: [''], code: 'claude.dependencies.entry.invalid', label: 'an empty string entry' }, + { dependencies: [{}], code: 'claude.dependencies.name.required', label: 'an object without a name' }, + { dependencies: [{ name: '', version: '^2.0' }], code: 'claude.dependencies.name.required', label: 'an empty object name' }, + { dependencies: [{ name: 'audit-logger', source: './audit' }], code: 'claude.dependencies.field.unknown', label: 'an unknown object field' }, + { dependencies: ['Audit Logger'], code: 'claude.dependencies.name.invalid', label: 'an implausible plugin name' }, + { dependencies: ['audit-logger', { name: 'audit-logger' }], code: 'claude.dependencies.duplicate', label: 'a duplicate same-marketplace name' }, + { + dependencies: [ + { marketplace: 'acme-shared', name: 'audit-logger' }, + { marketplace: 'acme-shared', name: 'audit-logger', version: '^2.0' }, + ], + code: 'claude.dependencies.duplicate', + label: 'a duplicate cross-marketplace name', + }, + { dependencies: ['review-tools'], code: 'claude.dependencies.self', label: 'a self dependency' }, + { dependencies: [{ name: 'audit-logger', version: 'latest' }], code: 'claude.dependencies.version.invalid', label: 'an invalid version range' }, + { dependencies: [{ marketplace: '', name: 'audit-logger' }], code: 'claude.dependencies.marketplace.invalid', label: 'an empty marketplace' }, + { dependencies: [{ marketplace: 7, name: 'audit-logger' }], code: 'claude.dependencies.marketplace.invalid', label: 'a non-string marketplace' }, +])('rejects $label before emitting Claude dependencies', ({ dependencies, code }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeDependencies(plugin, dependencies)); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + recovery: expect.any(String), + severity: 'error', + })); + if (manifest?.kind === 'write') { + expect(JSON.parse(manifest.content)).not.toHaveProperty('dependencies'); + } +}); + +it.each([ + '~2.1.0', + '^2.0', + '>=1.4', + '=2.1.0', + '2.x', + '1.2.3 - 2.0.0', + '>=2.0 <3.0', + '^2.0.0-0', + '^1.0 || >=2.0 <3.0', +])('accepts documented Claude dependency semver range %s', (range) => { + expect(isValidClaudeDependencyRange(range)).toBe(true); +}); + +it.each(['', 'latest', '^', 'not-a-range', '>=', '||', '<= >', '1.x.3', '^2.0.0-01'])( + 'rejects malformed Claude dependency semver range %s', + (range) => { + expect(isValidClaudeDependencyRange(range)).toBe(false); + }, +); + it.each([ { code: 'claude.settings.declaration.invalid', diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index a3cb91590..a0977ece5 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -368,6 +368,36 @@ it('emits Claude-only plugin default settings at the shared composite root', () expect(documents['AGENTS.md']).toContain('- `settings.json` — Claude Code default configuration'); }); +it('emits Claude-only dependencies from the unified plugin target', () => { + const model = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config' as const, sourcePath: configPath }, + target: 'claude', + value: { + dependencies: [ + 'audit-logger', + { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, + ], + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!).dependencies).toEqual([ + 'audit-logger', + { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, + ]); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); +}); + it('emits each shared surface exactly once with no duplicate artifact paths', () => { const plan = planBundle(bundleModel); const paths = plan.entries.map((entry) => entry.relativePath);