diff --git a/.changeset/claude-adapter-review-batch-two.md b/.changeset/claude-adapter-review-batch-two.md new file mode 100644 index 000000000..2bbc83d74 --- /dev/null +++ b/.changeset/claude-adapter-review-batch-two.md @@ -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. 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 f316f8b56..d18b30e6a 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 @@ -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 `." ] }, "pluginReload": { diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 7c85131bb..83f373b58 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -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. */ @@ -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), }); @@ -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'); @@ -645,7 +647,10 @@ const dependencyDiagnostic = (code: string, message: string, recovery: string): recovery, }); -const planClaudeDependencies = (model: NormalizedPlugin): ClaudeDependenciesPlan => { +const planClaudeDependencies = ( + model: NormalizedPlugin, + emittedMarketplacePluginNames: ReadonlySet, +): ClaudeDependenciesPlan => { const extension = model.extensions[claudeName]; if (extension === undefined || !isDataRecord(extension.value)) return noDependenciesPlan; const declared = extension.value['dependencies']; @@ -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.', + )); + continue; + } document.push(entry); continue; } @@ -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.`, @@ -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, @@ -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.', @@ -1941,13 +1962,18 @@ 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 }; } @@ -1955,34 +1981,34 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC 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.`, )); } @@ -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.`, )); } @@ -2004,9 +2030,9 @@ 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; } @@ -2014,9 +2040,9 @@ const planClaudeUserConfigOption = (key: string, declared: unknown): ClaudeUserC } 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.`, )); } @@ -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.`, )); } @@ -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; } @@ -2691,6 +2721,7 @@ export const planClaudeSettings = (model: NormalizedPlugin): ClaudeSettingsPlan const themeFields: ReadonlySet = new Set(['base', 'name', 'overrides']); const themeKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const themeBasePresets: ReadonlySet = new Set(['dark', 'light']); interface ClaudeThemeDocument { readonly document: Record; @@ -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; } @@ -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']; @@ -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); @@ -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( diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 0eb080655..2b555e2c6 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -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" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json index 708e29366..8cf13ff95 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/theme.schema.json @@ -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" }, diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index dea954b5c..e6313e474 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -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 = ( @@ -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', }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index a4d1e39df..b038be468 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.16.0', + adapterRevision: '1.17.0', observedVersion: '2.1.250', schemas: [ { @@ -134,7 +134,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'theme', revision: '2.1.250', - sha256: '1ee6c8485855bf90402a1c527830efeca0643be7037ca2ff3e178c51e5973089', + sha256: '721aa9b0bc7c60cd9359343e5c7205ffbdfea82da312f601720c9dfaa2d8cf0d', }, ], }); diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index a68a51832..0f799d882 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -262,7 +262,7 @@ it('accepts the public claude.dependencies config surface and plans its manifest " plugin: { name: 'api-fixture', version: '1.0.0' },", " targets: ['claude'],", ' claude: {', - " dependencies: ['audit-logger', { name: 'policy-kit', version: '^2.0', marketplace: 'acme-shared' }],", + " dependencies: [{ name: 'audit-logger', marketplace: 'acme-shared' }, { name: 'policy-kit', version: '^2.0', marketplace: 'acme-shared' }],", ' },', '};', '', @@ -276,7 +276,7 @@ it('accepts the public claude.dependencies config surface and plans its manifest 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: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ]); } finally { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 0f229cdb1..f2d39265e 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -7,7 +7,7 @@ import { expect, it } from '@rstest/core'; import { claudeAdapter } from '../src/adapters/claude.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; -import type { NormalizedPlugin } from '../src/core/types.ts'; +import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip; @@ -584,7 +584,7 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n try { const written = await writeClaudeArtifact(root, withClaudeExperimental({ monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + command: `node ${pathTokens.pluginRoot}/scripts/watch.mjs`, description: 'Watch the review queue.', name: 'review-queue', when: 'always', @@ -600,6 +600,9 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n 'monitors/monitors.json', 'themes/dracula.json', ])); + expect(JSON.parse( + await readFile(join(root, 'monitors', 'monitors.json'), 'utf8'), + )[0].command).toBe('node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs'); const validation = await runClaudeValidation(root, root); expect(validation.code, validation.output).toBe(0); @@ -883,8 +886,8 @@ nativeIt('accepts emitted Claude plugin dependencies under strict native validat try { await writeClaudeArtifact(root, withClaudeDependencies([ - 'audit-logger', - { name: 'secrets-vault', version: '~2.1.0' }, + { marketplace: 'acme-shared', name: 'audit-logger' }, + { marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' }, ])); const validation = await runClaudeValidation(root, root); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 252279e5f..6b1147baa 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -643,6 +643,19 @@ it('enriches the generated Claude marketplace with the complete authored catalog await validateDocuments('claude', writeContents(model, 'claude')); }); +it('counts Claude marketplace relevance topics by Unicode code point', () => { + const topic = '😀'.repeat(64); + const model = withClaudeMarketplace(plugin, { + plugin: { relevance: { signals: { cli: ['git'] }, topic } }, + }); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics).toEqual([]); + expect(JSON.parse( + writeContents(model, 'claude')['.claude-plugin/marketplace.json']!, + ).plugins[0].relevance.topic).toBe(topic); +}); + it.each([ ['./plugins/review-tools', undefined], ['review-tools', { pluginRoot: './plugins' }], @@ -1454,7 +1467,7 @@ it.each([ server: 'stdio', userConfig: { bot_token: { description: 'Token.', title: 'Token', type: 'secret' } }, }], - code: 'claude.userConfig.type.invalid', + code: 'claude.channels[0].userConfig.type.invalid', label: 'an invalid per-channel option declaration', }, ])('rejects $label without emitting channels', ({ channels, code }) => { @@ -1925,7 +1938,7 @@ it('emits no Claude settings document when the host config declares none', () => it('emits validated Claude themes and monitors at their default experimental locations', () => { const model = withClaudeExperimental(plugin, { monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', + command: `node ${pathTokens.pluginRoot}/scripts/watch.mjs`, description: 'Watch the review queue.', name: 'review-queue', when: 'on-skill-invoke:review', @@ -1985,6 +1998,7 @@ it.each([ { code: 'claude.themes.field.unknown', label: 'an unknown theme field', themes: { dark: { base: 'dark', typo: true } } }, { code: 'claude.themes.base.required', label: 'a missing theme base', themes: { dark: { name: 'Dark' } } }, { code: 'claude.themes.base.required', label: 'an empty theme base', themes: { dark: { base: '' } } }, + { code: 'claude.themes.base.invalid', label: 'an unknown theme base preset', themes: { dark: { base: 'drak' } } }, { code: 'claude.themes.name.invalid', label: 'an empty theme display name', themes: { dark: { base: 'dark', name: '' } } }, { code: 'claude.themes.overrides.invalid', label: 'a non-object overrides map', themes: { dark: { base: 'dark', overrides: [] } } }, { code: 'claude.themes.overrides.value.invalid', label: 'a non-string override', themes: { dark: { base: 'dark', overrides: { error: 7 } } } }, @@ -2030,6 +2044,8 @@ it('pins and registers the closed Claude theme and monitor schemas', async () => const validateMonitors = validator.compile(monitorsModule.default); expect(validateTheme({ base: 'dark', name: 'Dracula', overrides: { claude: '#bd93f9' } })).toBe(true); + expect(validateTheme({ base: 'light', name: 'Paper' })).toBe(true); + expect(validateTheme({ base: 'drak', name: 'Typo' })).toBe(false); expect(validateTheme({ base: 'dark', typo: true })).toBe(false); expect(validateTheme({ name: 'No base' })).toBe(false); expect(validateTheme({ base: 'dark', overrides: { error: 7 } })).toBe(false); @@ -2049,8 +2065,8 @@ it('pins and registers the closed Claude theme and monitor schemas', async () => 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: 'review-tools', version: '*' }, + { marketplace: 'acme-shared', 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); @@ -2060,8 +2076,8 @@ it('emits Claude plugin dependencies in authored order with closed object keys a 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: 'review-tools', version: '*' }, + { marketplace: 'acme-shared', 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'); @@ -2086,6 +2102,8 @@ it.each([ label: 'a duplicate cross-marketplace name', }, { dependencies: ['review-tools'], code: 'claude.dependencies.self', label: 'a self dependency' }, + { dependencies: ['audit-logger'], code: 'claude.dependencies.unresolved', label: 'an unresolvable bare-name dependency' }, + { dependencies: [{ name: 'audit-logger', version: '^2.0' }], code: 'claude.dependencies.unresolved', label: 'an unresolvable same-marketplace dependency object' }, { 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' }, @@ -2108,6 +2126,9 @@ it.each([ '^2.0', '>=1.4', '=2.1.0', + '*', + 'x', + 'X', '2.x', '1.2.3 - 2.0.0', '>=2.0 <3.0', diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 8900548a0..8c2f95d17 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -621,7 +621,7 @@ it('emits Claude-only dependencies from the unified plugin target', () => { target: 'claude', value: { dependencies: [ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ], }, @@ -633,7 +633,7 @@ it('emits Claude-only dependencies from the unified plugin target', () => { expect(plan.diagnostics).toEqual([]); expect(JSON.parse(documents['.claude-plugin/plugin.json']!).dependencies).toEqual([ - 'audit-logger', + { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, ]); expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('dependencies');