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-adapter-review-batch-two.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Harden Claude adapter planning for monitor path tokens, theme presets, dependency resolution and ranges, indexed channel diagnostics, scoped installation capabilities, and Unicode marketplace topics.
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,11 @@
]
},
"pluginInstallScopes": {
"state": "unavailable",
"reason": "Agent Bundle emits installation instructions but cannot choose or persist a Claude plugin installation scope.",
"state": "supported",
"evidence": [
"retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference maps user, project, and local installs to ~/.claude/settings.json, .claude/settings.json, and .claude/settings.local.json respectively, while managed scope is read-only and update-only.",
"retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents --scope user|project|local for install and uninstall, scope auto-detection for enable and disable, and --scope user|project|local|managed for update."
"retrieved 2026-09-02: https://code.claude.com/docs/en/plugins-reference documents --scope user|project|local for install and uninstall, scope auto-detection for enable and disable, and --scope user|project|local|managed for update.",
"retrieved 2026-09-02: packages/agent-bundle/src/install/install.ts installPublicCli accepts the requested user, project, or local scope and forwards it to `claude plugin install ... --scope <scope>`."
]
},
"pluginReload": {
Expand Down
118 changes: 77 additions & 41 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ export interface ClaudeSettingsConfig {
}

/** One experimental Claude Code color theme emitted under the plugin-root `themes/` directory. */
export type ClaudeThemeBasePreset = 'dark' | 'light';

export interface ClaudeThemeConfig {
/** Built-in preset inherited before sparse token overrides are applied. */
readonly base: string;
readonly base: ClaudeThemeBasePreset;
/** Display name shown in `/theme`; defaults to the declaration key. */
readonly name?: string;
/** Sparse color-token overrides. Values stay host-defined strings rather than being narrowed to hex colors. */
Expand Down Expand Up @@ -422,7 +424,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.16.0',
adapterRevision: '1.17.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -595,7 +597,7 @@ const semverSuffix = `(?:-${prereleaseSemverIdentifier}(?:\\.${prereleaseSemverI
const fullSemverVersion = `${numericSemverIdentifier}\\.${numericSemverIdentifier}\\.${numericSemverIdentifier}${semverSuffix}`;
const partialSemverVersion = `${numericSemverIdentifier}(?:\\.${numericSemverIdentifier})?`;
const wildcardSemverVersion = `${numericSemverIdentifier}\\.(?:[xX*]|${numericSemverIdentifier}\\.[xX*])`;
const semverRangeVersion = `(?:${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`;
const semverRangeVersion = `(?:[xX*]|${fullSemverVersion}|${wildcardSemverVersion}|${partialSemverVersion})`;
const hyphenRangePattern = new RegExp(`^${semverRangeVersion}\\s+-\\s+${semverRangeVersion}$`, 'u');
const comparatorPattern = new RegExp(`(?:~|\\^|>=|<=|>|<|=)?\\s*${semverRangeVersion}`, 'uy');

Expand Down Expand Up @@ -645,7 +647,10 @@ const dependencyDiagnostic = (code: string, message: string, recovery: string):
recovery,
});

const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan => {
const planClaudeDependencies = (
model: NormalizedPlugin,
emittedMarketplacePluginNames: ReadonlySet<string>,
): ClaudeDependenciesPlan => {
const extension = model.extensions[claudeName];
if (extension === undefined || !isDataRecord(extension.value)) return noDependenciesPlan;
const declared = extension.value['dependencies'];
Expand Down Expand Up @@ -701,6 +706,14 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan
continue;
}
seen.add(identity);
if (!emittedMarketplacePluginNames.has(entry)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.unresolved',
`Claude dependency ${JSON.stringify(entry)} has no marketplace, but the generated marketplace does not emit a plugin with that name.`,
'Declare the marketplace that provides this plugin, or remove the dependency; bare names resolve only within the generated marketplace.',
));
Comment on lines +709 to +714

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 Update all bare-dependency regression fixtures

When the full unit suite reaches a non-self bare dependency, this new branch reports claude.dependencies.unresolved because emittedMarketplacePluginNames contains only the generated plugin's own name. However, packages/agent-bundle/tests/api.test.ts:265-279 and packages/agent-bundle/tests/plugin-bundle.test.ts:623-637 still declare bare audit-logger dependencies and expect no diagnostics plus manifest emission, so pnpm test:unit will fail even though the narrowed Claude adapter suite was updated. Update those remaining fixtures/expectations to the new marketplace-qualified contract, or retain the prior behavior.

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.

Already addressed before this PR merged: commit 53bc86f ("test(claude): qualify unified dependency fixture") on this branch updated both fixtures to the marketplace-qualified contract. Verified on current main: tests/api.test.ts (lines 321–336) and tests/plugin-bundle.test.ts (lines 624–637) declare { marketplace: 'acme-shared', name: 'audit-logger' } / policy-kit, and pnpm test:unit passes in CI on #397 (d25a9c6).

continue;
}
document.push(entry);
continue;
}
Expand Down Expand Up @@ -756,7 +769,7 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan
));
continue;
}
if (name === model.metadata.name) {
if (name === model.metadata.name && marketplace === undefined) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.self',
`Claude plugin ${JSON.stringify(model.metadata.name)} cannot depend on itself.`,
Expand All @@ -774,6 +787,14 @@ const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan
continue;
}
seen.add(identity);
if (marketplace === undefined && !emittedMarketplacePluginNames.has(name)) {
diagnostics.push(dependencyDiagnostic(
'claude.dependencies.unresolved',
`Claude dependency ${JSON.stringify(name)} has no marketplace, but the generated marketplace does not emit a plugin with that name.`,
'Declare the marketplace that provides this plugin, or remove the dependency; bare names resolve only within the generated marketplace.',
));
continue;
}
document.push(Object.freeze({
...(typeof marketplace === 'string' ? { marketplace } : {}),
name,
Expand Down Expand Up @@ -984,7 +1005,7 @@ const planMarketplaceRelevance = (
));
}
const topic = declared['topic'];
if (topic !== undefined && (!isNonemptyString(topic) || topic.length > 64)) {
if (topic !== undefined && (!isNonemptyString(topic) || [...topic].length > 64)) {
diagnostics.push(marketplaceDiagnostic(
'claude.marketplace.plugin.relevance.topic.invalid',
'Claude marketplace plugin relevance topic must be a nonempty string of at most 64 characters.',
Expand Down Expand Up @@ -1941,48 +1962,53 @@ interface ClaudeUserConfigOptionPlan {
* Validates and allowlist-copies one option independently so the same closed
* declaration contract can be reused by a later channels.userConfig slice.
*/
const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserConfigOptionPlan => {
const planClaudeUserConfigOption = (
key: string,
declared: unknown,
path = 'userConfig',
): ClaudeUserConfigOptionPlan => {
const diagnostics: Diagnostic[] = [];
const codePrefix = `claude.${path}`;
if (!isPlainDataRecord(declared)) {
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.option.invalid',
`Claude userConfig option "${key}" must be an option declaration object.`,
`Replace userConfig.${key} with an object containing type, title, and description, then rebuild.`,
`${codePrefix}.option.invalid`,
`Claude ${path} option "${key}" must be an option declaration object.`,
`Replace ${path}.${key} with an object containing type, title, and description, then rebuild.`,
));
return { diagnostics };
}

for (const field of Object.keys(declared).sort()) {
if (userConfigOptionFieldSet.has(field)) continue;
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.field.unknown',
`Claude userConfig option "${key}" declares unknown field "${field}".`,
`Remove userConfig.${key}.${field} or replace it with a documented option field, then rebuild.`,
`${codePrefix}.field.unknown`,
`Claude ${path} option "${key}" declares unknown field "${field}".`,
`Remove ${path}.${key}.${field} or replace it with a documented option field, then rebuild.`,
));
}

const type = declared['type'];
if (!isUserConfigOptionType(type)) {
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.type.invalid',
`Claude userConfig option "${key}" requires type "string", "number", "boolean", "directory", or "file".`,
`Set userConfig.${key}.type to one of the five documented option types, then rebuild.`,
`${codePrefix}.type.invalid`,
`Claude ${path} option "${key}" requires type "string", "number", "boolean", "directory", or "file".`,
`Set ${path}.${key}.type to one of the five documented option types, then rebuild.`,
));
}
for (const field of ['title', 'description'] as const) {
if (typeof declared[field] === 'string' && declared[field].length > 0) continue;
diagnostics.push(userConfigDiagnostic(
`claude.userConfig.${field}.required`,
`Claude userConfig option "${key}" requires a nonempty ${field}.`,
`Set userConfig.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`,
`${codePrefix}.${field}.required`,
`Claude ${path} option "${key}" requires a nonempty ${field}.`,
`Set ${path}.${key}.${field} to the text Claude Code should show in its configuration dialog, then rebuild.`,
));
}
for (const field of ['sensitive', 'required'] as const) {
if (declared[field] === undefined || typeof declared[field] === 'boolean') continue;
diagnostics.push(userConfigDiagnostic(
`claude.userConfig.${field}.invalid`,
`Claude userConfig option "${key}" field "${field}" must be a boolean when provided.`,
`Set userConfig.${key}.${field} to true or false, or remove it, then rebuild.`,
`${codePrefix}.${field}.invalid`,
`Claude ${path} option "${key}" field "${field}" must be a boolean when provided.`,
`Set ${path}.${key}.${field} to true or false, or remove it, then rebuild.`,
));
}

Expand All @@ -1992,9 +2018,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC
(typeof multiple !== 'boolean' || (isUserConfigOptionType(type) && type !== 'string'))
) {
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.multiple.invalid',
`Claude userConfig option "${key}" may declare boolean field "multiple" only for type "string".`,
`Remove userConfig.${key}.multiple or change the option type to "string", then rebuild.`,
`${codePrefix}.multiple.invalid`,
`Claude ${path} option "${key}" may declare boolean field "multiple" only for type "string".`,
`Remove ${path}.${key}.multiple or change the option type to "string", then rebuild.`,
));
}

Expand All @@ -2004,19 +2030,19 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC
if (bound === undefined) continue;
if (typeof bound !== 'number' || !Number.isFinite(bound) || (isUserConfigOptionType(type) && type !== 'number')) {
diagnostics.push(userConfigDiagnostic(
`claude.userConfig.${field}.invalid`,
`Claude userConfig option "${key}" may declare finite numeric field "${field}" only for type "number".`,
`Remove userConfig.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`,
`${codePrefix}.${field}.invalid`,
`Claude ${path} option "${key}" may declare finite numeric field "${field}" only for type "number".`,
`Remove ${path}.${key}.${field} or use it with a number option and a finite numeric value, then rebuild.`,
));
continue;
}
bounds[field] = bound;
}
if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) {
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.bounds.invalid',
`Claude userConfig option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`,
`Set userConfig.${key}.min less than or equal to userConfig.${key}.max, then rebuild.`,
`${codePrefix}.bounds.invalid`,
`Claude ${path} option "${key}" has min ${String(bounds.min)} greater than max ${String(bounds.max)}.`,
`Set ${path}.${key}.min less than or equal to ${path}.${key}.max, then rebuild.`,
));
}

Expand Down Expand Up @@ -2050,17 +2076,17 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC
}
if (!validDefault) {
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.default.invalid',
`Claude userConfig option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`,
`Set userConfig.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`,
`${codePrefix}.default.invalid`,
`Claude ${path} option "${key}" has a default that does not match its type, multiple mode, or numeric bounds.`,
`Set ${path}.${key}.default to a valid ${type} value for this declaration, or remove it, then rebuild.`,
));
}
}
if (declared['sensitive'] === true && defaultValue !== undefined) {
diagnostics.push(userConfigDiagnostic(
'claude.userConfig.sensitive.default',
`Claude userConfig option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`,
`Remove userConfig.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`,
`${codePrefix}.sensitive.default`,
`Claude ${path} option "${key}" cannot combine sensitive: true with a manifest default because that would ship a secure-storage value in the plugin manifest.`,
`Remove ${path}.${key}.default and let Claude Code prompt for the sensitive value, then rebuild.`,
));
}

Expand Down Expand Up @@ -2311,7 +2337,11 @@ export const planClaudeChannels = (
`Rename one channels[${index}].userConfig option so every key remains unique after uppercasing, then rebuild.`,
));
}
const optionPlan = planClaudeUserConfigOption(key, userConfig[key]);
const optionPlan = planClaudeUserConfigOption(
key,
userConfig[key],
`channels[${index}].userConfig`,
);
diagnostics.push(...optionPlan.diagnostics);
if (optionPlan.value !== undefined) plannedUserConfig[key] = optionPlan.value;
}
Expand Down Expand Up @@ -2691,6 +2721,7 @@ export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan

const themeFields: ReadonlySet<string> = new Set(['base', 'name', 'overrides']);
const themeKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
const themeBasePresets: ReadonlySet<string> = new Set(['dark', 'light']);

interface ClaudeThemeDocument {
readonly document: Record<string, unknown>;
Expand Down Expand Up @@ -2757,6 +2788,11 @@ export const planClaudeThemes = (model: NormalizedPlugin): ClaudeThemesPlan => {
'claude.themes.base.required',
`Claude theme "${key}" requires a nonempty base preset.`,
));
} else if (!themeBasePresets.has(base)) {
diagnostics.push(errorDiagnostic(
'claude.themes.base.invalid',
`Claude theme "${key}" base must be one of the documented "dark" or "light" presets.`,
));
} else {
document['base'] = base;
}
Expand Down Expand Up @@ -2883,7 +2919,7 @@ export const planClaudeMonitors = (
`Claude monitors[${index}] command cannot reference \`\${user_config.*}\`; Claude Code runs monitor commands through a shell and rejects them instead of substituting the value, and monitor processes do not receive CLAUDE_PLUGIN_OPTION_ environment variables.`,
));
} else {
planned['command'] = command;
planned['command'] = expandClaudeToken(command);
}

const description = monitor['description'];
Expand Down Expand Up @@ -2985,7 +3021,7 @@ export const planClaudeArtifacts = (
diagnostics.push(...themes.diagnostics);
const monitors = planClaudeMonitors(model, targetName);
diagnostics.push(...monitors.diagnostics);
const dependencies = planClaudeDependencies(model);
const dependencies = planClaudeDependencies(model, new Set([model.metadata.name]));
diagnostics.push(...dependencies.diagnostics);
const generatedHooks = planHooks(model, targetName, hookContract);
diagnostics.push(...generatedHooks.diagnostics);
Expand Down Expand Up @@ -3241,7 +3277,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
'The pinned Claude plugin contract does not document the plugin-root output-styles surface.',
),
pluginCliLifecycle: unavailableCapability(distributionPolicy.pluginCliLifecycle.reason),
pluginInstallScopes: unavailableCapability(distributionPolicy.pluginInstallScopes.reason),
pluginInstallScopes: supportedCapability(evidence),
pluginReload: unavailableCapability(distributionPolicy.pluginReload.reason),
pluginTrustGates: unavailableCapability(distributionPolicy.pluginTrustGates.reason),
rules: unavailableCapability(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
"url": "https://code.claude.com/docs/en/plugins"
},
"theme.schema.json": {
"bytes": 458,
"sha256": "1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089",
"bytes": 451,
"sha256": "721aa9b0bc7c60cd9359343e5c7205ffbdfea82da312f601720c9dfaa2d8cf0d",
"url": "https://code.claude.com/docs/en/plugins-reference"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"$id": "https://agent-bundle.dev/schemas/claude/2.1.250/theme.schema.json",
"additionalProperties": false,
"properties": {
"base": { "minLength": 1, "type": "string" },
"base": { "enum": ["dark", "light"] },
"name": { "minLength": 1, "type": "string" },
"overrides": {
"additionalProperties": { "minLength": 1, "type": "string" },
Expand Down
35 changes: 29 additions & 6 deletions packages/agent-bundle/tests/adapter-capability-states.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ const claudeDistributionPolicyCapabilities = [
'marketplaceCliLifecycle',
] as const;

it('reports Claude plugin install scopes from the scoped installer implementation', () => {
const row = claudeCapabilityTable.plugin.distributionPolicy.pluginInstallScopes;
const capability = createDefaultRegistry().get('claude').capabilities.pluginInstallScopes;

expect(row).toMatchObject({
evidence: expect.arrayContaining([
expect.stringContaining('src/install/install.ts'),
]),
state: 'supported',
});
expect(capability).toMatchObject({
evidence: { observedVersion: '2.1.250', target: 'claude' },
state: 'supported',
});
});

it('records dated unavailable Claude distribution and policy capability rows', () => {
const registry = createDefaultRegistry();
const distributionPolicy = (
Expand All @@ -311,14 +327,21 @@ it('records dated unavailable Claude distribution and policy capability rows', (
expect(Object.keys(distributionPolicy).sort()).toEqual([...claudeDistributionPolicyCapabilities].sort());
for (const capability of claudeDistributionPolicyCapabilities) {
const row = distributionPolicy[capability];
expect(row.state).toBe('unavailable');
expect(row.reason.length).toBeGreaterThan(0);
expect(row.state).toBe(capability === 'pluginInstallScopes' ? 'supported' : 'unavailable');
if (capability !== 'pluginInstallScopes') expect(row.reason.length).toBeGreaterThan(0);
expect(row.evidence.length).toBeGreaterThan(0);
expect(row.evidence.every((line) => line.includes('retrieved 2026-09-02'))).toBe(true);
expect(registry.get('claude').capabilities[capability]).toEqual({
reason: row.reason,
state: 'unavailable',
});
if (capability === 'pluginInstallScopes') {
expect(registry.get('claude').capabilities[capability]).toMatchObject({
evidence: { observedVersion: '2.1.250', target: 'claude' },
state: 'supported',
});
} else {
expect(registry.get('claude').capabilities[capability]).toEqual({
reason: row.reason,
state: 'unavailable',
});
}
expect(registry.get('plugin').capabilities[capability]).toMatchObject({
state: 'unavailable',
});
Expand Down
Loading
Loading