diff --git a/.changeset/claude-plugin-bin.md b/.changeset/claude-plugin-bin.md new file mode 100644 index 000000000..a09361fbe --- /dev/null +++ b/.changeset/claude-plugin-bin.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add `claude.bin` for byte-faithful Claude Code plugin executables, preserving executable modes in emitted plugin-root `bin/` directories. 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 e4f56c250..dc5154750 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 @@ -47,6 +47,13 @@ }, "observedCliVersion": "2.1.250", "plugin": { + "bin": { + "bareCommands": true, + "bashPath": true, + "directory": "bin", + "enabledOnly": true, + "organizationDistributionProhibited": true + }, "commands": true, "devtools": { "details": true, @@ -101,6 +108,9 @@ "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.", "Plugin developer tools reference: `claude plugin validate ` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin commands/ as Markdown files with optional YAML frontmatter fields description, argument-hint, allowed-tools, model, and disable-model-invocation; the filename stem is the plugin-namespaced command name.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin-root bin/ as \"Plugin executables added to PATH\", with executables \"Invokable as bare command in Bash tool\"; its file-locations table says: \"Executables added to the Bash tool's PATH and invokable as bare commands while the plugin is enabled. You can't include this directory in a plugin you distribute through claude.ai organization settings\".", + "2026-09-01: https://code.claude.com/docs/en/plugins documents plugin-root bin/ as \"Executables added to the Bash tool's PATH while the plugin is enabled. You can't include this directory in a plugin you distribute through claude.ai organization settings\".", + "2026-09-01: Claude Code 2.1.257 `claude plugin validate --strict` accepts an emitted plugin containing an executable plugin-root bin/ command.", "`claude plugin validate --strict` promotes tolerated warnings such as unrecognized or near-miss fields and non-object experimental/metadata values to exit failure; the reference recommends strict mode in CI.", "Development tools include `claude --plugin-dir plugin list --json` for registration proof, `claude plugin details ` for component inventory and host-owned token estimates, `claude plugin tag`, and `claude --debug` for loading diagnostics.", "https://code.claude.com/docs/en/hooks documents SubagentStart when Agent spawns a subagent and SubagentStop when it finishes; both match agent_type, including anchored plugin-scoped identifiers such as ^my-plugin:reviewer$.", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 3e49ba846..7f756d036 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -54,6 +54,7 @@ import { validateModernMcpDocument, withPluginRootEnvAnchor, type TargetAdapter, + type TargetArtifactCopy, type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; @@ -94,6 +95,8 @@ export interface ClaudeLspServerConfig { * the portable LSP component kind stays deferred. */ export interface ClaudeHostConfig extends AgentBundleHostConfig { + /** Project-authored directory copied to the plugin-root `bin/` executable convention. */ + readonly bin?: string; readonly lspServers?: Readonly>; } @@ -146,7 +149,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.5.0', + adapterRevision: '1.6.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -435,6 +438,106 @@ export const planClaudeLsp = (model: NormalizedPlugin): ClaudeLspPlan => { return { diagnostics, ...(valid ? { document: servers } : {}), sourceInputs: inputs }; }; +interface ClaudeBinPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly entries: readonly TargetArtifactCopy[]; +} + +const planClaudeBin = (model: NormalizedPlugin, targetName: string): ClaudeBinPlan => { + const diagnostics: Diagnostic[] = []; + const entries: TargetArtifactCopy[] = []; + for (const bin of model.hostBins ?? []) { + if (bin.target !== targetName) continue; + if (bin.issue !== undefined) { + switch (bin.issue) { + case 'missing': + diagnostics.push({ + ...errorDiagnostic( + 'claude.bin.directory.missing', + `Claude bin directory ${JSON.stringify(bin.source)} does not exist.`, + ), + recovery: 'Create the configured Claude bin directory and add at least one executable, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + break; + case 'empty': + diagnostics.push({ + ...errorDiagnostic( + 'claude.bin.directory.empty', + `Claude bin directory ${JSON.stringify(bin.source)} contains no files.`, + ), + recovery: 'Add at least one file to the configured Claude bin directory, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + break; + case 'not-directory': + diagnostics.push({ + ...errorDiagnostic( + 'claude.bin.directory.invalid', + `Claude bin source ${JSON.stringify(bin.source)} must name a directory.`, + ), + recovery: 'Set claude.bin to a nonempty directory path relative to the config file, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + break; + case 'outside': + diagnostics.push({ + ...errorDiagnostic( + 'claude.bin.directory.outside', + `Claude bin directory ${JSON.stringify(bin.source)} must resolve inside the project root.`, + ), + recovery: 'Move the executable directory inside the project and update claude.bin, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + break; + case 'source-error': + diagnostics.push({ + ...errorDiagnostic('claude.bin.source.error', 'Claude bin source resolution failed.'), + recovery: 'Correct the claude.bin declaration so the adapter can read it, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + break; + case 'source-invalid': + diagnostics.push({ + ...errorDiagnostic('claude.bin.source.invalid', 'Claude bin must be a nonempty directory path.'), + recovery: 'Set claude.bin to a nonempty directory path relative to the config file, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + break; + default: { + const exhaustive: never = bin.issue; + return exhaustive; + } + } + continue; + } + const nonExecutable = bin.files.filter((file) => + !file.executable && !file.relativePath.includes('/')); + if (nonExecutable.length > 0) { + diagnostics.push({ + ...errorDiagnostic( + 'claude.bin.executable.required', + `Claude bin top-level file${nonExecutable.length === 1 ? '' : 's'} ${nonExecutable + .map((file) => JSON.stringify(file.relativePath)) + .join(', ')} must be executable.`, + ), + recovery: 'Run chmod +x on every top-level file in the configured Claude bin directory, then rebuild.', + sourcePath: bin.provenance.sourcePath, + }); + continue; + } + entries.push(...bin.files.map((file): TargetArtifactCopy => ({ + bytes: file.bytes, + kind: 'copy', + prebuilt: true, + relativePath: `bin/${file.relativePath}`, + source: file.source, + sourceInputs: sourceInputs(bin.provenance.sourcePath, file.source), + }))); + } + return deepFreeze({ diagnostics, entries }); +}; + export interface ClaudeArtifactPlanOptions { /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ readonly targetName?: string; @@ -459,6 +562,8 @@ export const planClaudeArtifacts = ( if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); const lsp = planClaudeLsp(model); diagnostics.push(...lsp.diagnostics); + const bin = planClaudeBin(model, targetName); + diagnostics.push(...bin.diagnostics); const generatedHooks = planHooks(model, targetName, hookContract); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { @@ -520,6 +625,7 @@ export const planClaudeArtifacts = ( ...basePlan, entries: sortedEntries([ ...basePlan.entries, + ...bin.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), }), model, targetName === 'plugin' ? 'plugin' : 'claude'); @@ -527,6 +633,7 @@ export const planClaudeArtifacts = ( const artifactLayout: TargetArtifactLayout = Object.freeze({ ...standardArtifactLayout, + bin: 'bin', commands: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands', @@ -538,6 +645,15 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ artifactLayout, capabilities: Object.freeze({ ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), + bin: capabilityStateFromSupport( + capabilityTable.plugin.bin.directory === 'bin' && + capabilityTable.plugin.bin.bashPath && + capabilityTable.plugin.bin.bareCommands && + capabilityTable.plugin.bin.enabledOnly && + capabilityTable.plugin.bin.organizationDistributionProhibited, + evidence, + 'The pinned Claude plugin contract does not document the plugin-root bin executable surface.', + ), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, evidence, @@ -571,6 +687,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: claudeName, + binSource: (config: Readonly) => config.claude?.bin, nativeHookSource: (config: Readonly) => config.claude?.nativeHooks, plan: planClaudeArtifacts, }); diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index fa587fc20..232c21923 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -1,7 +1,7 @@ import { createTargetDiagnostics } from './diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; -import type { NormalizedHook, NormalizedPlugin } from '../core/types.ts'; +import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../core/types.ts'; import { allMcpPathTokenFields, createMcpPathTokenResolver, @@ -183,7 +183,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.4.0', + adapterRevision: '1.5.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 @@ -212,6 +212,7 @@ const mcpRuntime = createTargetMcpRuntime({ const artifactLayout: TargetArtifactLayout = Object.freeze({ assets: standardArtifactLayout.assets, + bin: 'bin', commands: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands' }), hookWrappers: standardArtifactLayout.hookWrappers, mcpApps: standardArtifactLayout.mcpApps, @@ -225,6 +226,8 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginName, 'Agent plugin bundle'); interface AgentsDocumentOptions { + /** True when the Claude half emitted plugin-root executables. */ + readonly bin: boolean; /** True when the Claude half emitted conventional command prompts. */ readonly commands: boolean; /** True when the Claude half of this bundle emitted `.lsp.json`. */ @@ -267,6 +270,11 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.', ] : []), + ...(options.bin + ? [ + '- `bin/` — Claude Code executables added to the Bash tool PATH while the plugin is enabled; Codex and Cursor have no declared bin surface.', + ] + : []), ...(options.rules ? [ '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', @@ -478,6 +486,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { entries.push(...ruleWriteEntries(model, isSelected)); entries.push({ content: agentsDocument(model, { + bin: entries.some((entry) => entry.relativePath.startsWith('bin/')), commands: selectedCommands.length > 0, lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), rules: selectedRules.length > 0, @@ -546,6 +555,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ artifactLayout, capabilities: Object.freeze({ ...compositeEventCapabilities, + bin: unavailableCapability( + 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', + ), commands: intersectCapabilityStates( intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), cursorAdapter.capabilities.commands!, @@ -586,5 +598,6 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ metadata, mcpRuntime, name: pluginName, + binSource: (config: Readonly) => config.claude?.bin, plan, }); diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index 917ff628e..e44529f67 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -4,6 +4,7 @@ import { dataArrayValues } from '../core/strict-json.ts'; import type { AgentBundleConfig, NormalizationConfigExtension, + NormalizationHostBinSource, NormalizationNativeHookSource, NormalizationTargetRegistry, } from '../core/types.ts'; @@ -31,6 +32,7 @@ import { deepFreeze } from '../core/freeze.ts'; const sha256Pattern = /^[0-9a-f]{64}$/; type NativeHookSource = NonNullable; +type BinSource = NonNullable; const emptyArtifactValidation: TargetArtifactValidationContract = deepFreeze({ documents: [], @@ -194,6 +196,9 @@ const snapshotArtifactLayout = ( const assets = layout.assets === undefined ? undefined : requireNonempty(layout.assets, 'artifact layout assets namespace'); + const bin = layout.bin === undefined + ? undefined + : requireNonempty(layout.bin, 'artifact layout bin namespace'); const skills = layout.skills === undefined ? undefined : requireNonempty(layout.skills, 'artifact layout skills namespace'); @@ -202,6 +207,9 @@ const snapshotArtifactLayout = ( if (assets !== undefined && !isSafeArtifactDirectory(assets)) { throw new Error('Target adapter artifact layout assets namespace must be a safe single namespace.'); } + if (bin !== undefined && !isSafeArtifactDirectory(bin)) { + throw new Error('Target adapter artifact layout bin namespace must be a safe single namespace.'); + } if (skills !== undefined && !isSafeArtifactDirectory(skills)) { throw new Error('Target adapter artifact layout skills namespace must be a safe single namespace.'); } @@ -216,6 +224,7 @@ const snapshotArtifactLayout = ( } return Object.freeze({ ...(assets === undefined ? {} : { assets }), + ...(bin === undefined ? {} : { bin }), ...(commands === undefined ? {} : { commands }), ...(hookWrappers === undefined ? {} : { hookWrappers }), ...(mcpApps === undefined ? {} : { mcpApps }), @@ -311,6 +320,14 @@ const snapshotNativeHookSource = (adapter: TargetAdapter): NativeHookSource | un return source; }; +const snapshotBinSource = (adapter: TargetAdapter): BinSource | undefined => { + const source = adapter.binSource; + if (source !== undefined && typeof source !== 'function') { + throw new Error('Target adapter bin source must be a function.'); + } + return source; +}; + const snapshotHookContract = (adapter: TargetAdapter): TargetHookContract | undefined => { const hookContract = adapter.hookContract; if (capabilityIsSupported(adapter.capabilities.hooks) && hookContract === undefined) { @@ -393,6 +410,7 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #adapters = new Map(); readonly #artifactLayouts = new Map(); readonly #artifactValidations = new Map(); + readonly #binSources = new Map(); readonly #defaults: string[] = []; readonly #extensions = new Map(); readonly #hookContracts = new Map(); @@ -411,6 +429,7 @@ export class TargetRegistry implements NormalizationTargetRegistry { assertCapabilityContract(adapter); const metadata = snapshotMetadata(adapter.metadata); const artifactValidation = snapshotArtifactValidation(adapter, metadata); + const binSource = snapshotBinSource(adapter); const nativeHookSource = snapshotNativeHookSource(adapter); const hookContract = snapshotHookContract(adapter); const mcpRuntime = snapshotMcpRuntime(adapter); @@ -420,6 +439,9 @@ export class TargetRegistry implements NormalizationTargetRegistry { this.#artifactValidations.set(adapter.name, artifactValidation); this.#artifactLayouts.set(adapter.name, artifactLayout); this.#metadata.set(adapter.name, metadata); + if (binSource !== undefined) { + this.#binSources.set(adapter.name, binSource); + } if (extension !== undefined) { this.#extensions.set(extension.key, Object.freeze({ key: extension.key, @@ -497,6 +519,28 @@ export class TargetRegistry implements NormalizationTargetRegistry { return Object.freeze([...this.#extensions.values()]); } + binSources( + config: Readonly, + targetNames: readonly string[], + ): readonly NormalizationHostBinSource[] { + const sources: NormalizationHostBinSource[] = []; + for (const target of [...this.#binSources.keys()].sort((left, right) => left.localeCompare(right))) { + if (!targetNames.includes(target)) continue; + const adapter = this.#adapters.get(target)!; + try { + const source = this.#binSources.get(target)!.call(adapter, config); + if (typeof source === 'string' && source.trim().length > 0) { + sources.push(Object.freeze({ source, target })); + } else if (source !== undefined) { + sources.push(Object.freeze({ issue: 'invalid', target })); + } + } catch { + sources.push(Object.freeze({ issue: 'error', target })); + } + } + return Object.freeze(sources); + } + nativeHookSources( config: Readonly, targetNames: readonly string[], diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 8e1750a7b..8f0ec941a 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -408,6 +408,7 @@ const invalidMcpDocumentIssues: readonly TargetArtifactDocumentIssue[] = deepFre */ export interface TargetArtifactLayout { readonly assets?: string; + readonly bin?: string; readonly commands?: TargetArtifactOutputLayout; readonly hookWrappers?: TargetArtifactOutputLayout; readonly mcpApps?: TargetArtifactOutputLayout; @@ -499,6 +500,7 @@ export interface TargetAdapter { readonly metadata: TargetAdapterMetadata; readonly mcpRuntime?: TargetMcpRuntimeContract; readonly name: string; + binSource?(config: Readonly): string | undefined; nativeHookSource?(config: Readonly): string | undefined; plan(model: NormalizedPlugin): TargetArtifactPlan; } diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 04050e884..e800648c5 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -443,6 +443,7 @@ const isTargetArtifactPath = ( const hookContract = registry.hookContract(target); const mcpRuntime = registry.mcpRuntime(target); return isRecursiveArtifactPath(relativePath, layout.assets) || + isRecursiveArtifactPath(relativePath, layout.bin) || isDirectOutputLayoutPath(relativePath, layout.commands) || isDirectOutputLayoutPath(relativePath, layout.hookWrappers) || isDirectOutputLayoutPath(relativePath, layout.mcpApps) || diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 421da1dae..d689a25b0 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -48,6 +48,7 @@ export type { AgentBundlePortableConfig, AgentBundlePrebuiltEntry, NormalizationConfigExtension, + NormalizationHostBinSource, NormalizationTargetRegistry, NormalizedCommand, AgentBundleMcpApp, @@ -56,6 +57,8 @@ export type { McpTransport, NormalizedConfigExtension, NormalizedMetadata, + NormalizedHostBin, + NormalizedHostBinFile, NormalizedMcpApp, NormalizedMcpServer, NormalizedPayload, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index e4dd6656f..e2bacd7d3 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { basename, extname, posix, relative, resolve, sep } from 'node:path'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -38,6 +38,8 @@ import type { NormalizedCommand, NormalizedConfigExtension, NormalizedHook, + NormalizedHostBin, + NormalizedHostBinFile, NormalizedLibEntry, NormalizedMcpApp, NormalizedMcpServer, @@ -296,6 +298,7 @@ export const normalizePackageBuild = ( export const reservedPayloadDestinations = Object.freeze(new Set([ 'AGENTS.md', 'assets', + 'bin', 'commands', 'hooks', 'mcp', @@ -538,6 +541,76 @@ const normalizeNativeHooks = async ( return nativeHooks; }; +const enumerateHostBinFiles = async (source: string): Promise => { + const files: NormalizedHostBinFile[] = []; + const visit = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + await visit(path); + continue; + } + if (!entry.isFile()) continue; + const metadata = await stat(path); + files.push({ + bytes: metadata.size, + executable: (metadata.mode & 0o111) !== 0, + relativePath: relative(source, path).replaceAll('\\', '/'), + source: path, + }); + } + }; + await visit(source); + return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); +}; + +const normalizeHostBins = async ( + loaded: LoadedConfig, + targetNames: readonly string[], + registry: NormalizationTargetRegistry, +): Promise => { + const provenance: SourceProvenance = { kind: 'config', sourcePath: loaded.configPath }; + const bins: NormalizedHostBin[] = []; + for (const binSource of registry.binSources?.(loaded.config, targetNames) ?? []) { + if ('issue' in binSource) { + bins.push({ + files: [], + issue: `source-${binSource.issue}`, + provenance: { ...provenance }, + source: loaded.configPath, + target: binSource.target, + }); + continue; + } + const source = resolve(dirname(loaded.configPath), binSource.source); + if (!isInside(loaded.context.projectRoot, source)) { + bins.push({ files: [], issue: 'outside', provenance: { ...provenance }, source, target: binSource.target }); + continue; + } + let metadata; + try { + metadata = await stat(source); + } catch { + bins.push({ files: [], issue: 'missing', provenance: { ...provenance }, source, target: binSource.target }); + continue; + } + if (!metadata.isDirectory()) { + bins.push({ files: [], issue: 'not-directory', provenance: { ...provenance }, source, target: binSource.target }); + continue; + } + const files = await enumerateHostBinFiles(source); + bins.push({ + files, + ...(files.length === 0 ? { issue: 'empty' as const } : {}), + provenance: { ...provenance }, + source, + target: binSource.target, + }); + } + return bins; +}; + const normalizeMcpServer = ( name: string, server: AgentBundleMcpServer, @@ -1038,6 +1111,7 @@ export const normalizeProject = async ( // remains the host-facing declared version during the migration. const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot); const version = resolvePluginVersion(loaded.config.plugin.version, packageIdentity.packageVersion); + const hostBins = await normalizeHostBins(loaded, targetNames, registry); const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); const payloads = normalizePayloads(loaded, discovered, targetNames); const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); @@ -1064,6 +1138,7 @@ export const normalizeProject = async ( ...(commands.length === 0 ? {} : { commands }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), extensions: normalizeExtensions(loaded, registry, configProvenance), + ...(hostBins.length === 0 ? {} : { hostBins }), metadata: { ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 6fcc878cc..193c20e29 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1392,11 +1392,17 @@ const validatePayload = ( const sources: { name: string; source: string }[] = []; for (const [name, declaration] of Object.entries(configured)) { if (!isSafePayloadName(name) || reservedPayloadDestinations.has(name)) { - diagnostics.push(sourceDiagnostic( + const diagnostic = sourceDiagnostic( 'AB4741', `Payload destination ${JSON.stringify(name)} must be a safe directory name outside the compiler-owned artifact namespaces.`, loaded.configPath, - )); + ); + diagnostics.push(name === 'bin' + ? { + ...diagnostic, + recovery: 'Rename the payload destination; use claude.bin to declare Claude Code plugin executables.', + } + : diagnostic); } const entry = payloadDeclarationEntry(declaration); if (entry === undefined) { @@ -2121,6 +2127,12 @@ export const validateModel = ( recordOutput(posix.join(target.name, payload.name, file.relativePath), file.source, target.name); } } + for (const bin of model.hostBins ?? []) { + if (bin.target !== target.name) continue; + for (const file of bin.files) { + recordOutput(posix.join(target.name, 'bin', file.relativePath), file.source, target.name); + } + } } return diagnostics; diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index dbef4c044..6e08d0703 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -267,6 +267,10 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ ...(model.providers ?? []).map((provider) => provider.source), ...(model.rules ?? []).flatMap((rule) => [rule.provenance.sourcePath, rule.source]), ...Object.values(model.extensions).map((extension) => extension.provenance.sourcePath), + ...(model.hostBins ?? []).flatMap((bin) => [ + bin.provenance.sourcePath, + ...bin.files.map((file) => file.source), + ]), ...model.targets.map((target) => target.provenance.sourcePath), // A prebuilt hook's source is its payload file, which may not exist yet // (the payload comes from the consumer's own build step); its bytes join @@ -373,6 +377,19 @@ export const canonicalizeNormalizedModel = ( ...extension, provenance: canonicalProvenance(root, extension.provenance), }])), + ...(detached.hostBins === undefined + ? {} + : { + hostBins: detached.hostBins.map((bin) => ({ + ...bin, + files: bin.files.map((file) => ({ + ...file, + source: canonicalCompilerPath(root, file.source, 'Host bin file source path'), + })), + provenance: canonicalProvenance(root, bin.provenance), + source: canonicalCompilerPath(root, bin.source, 'Host bin source path'), + })), + }), hooks: detached.hooks.map((hook) => ({ ...hook, provenance: canonicalProvenance(root, hook.provenance), diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index b891b80fc..f0e7eb8f1 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -499,6 +499,26 @@ export interface NormalizedPayload { readonly targets: readonly string[]; } +/** One file enumerated from a host-native plugin executable directory. */ +export interface NormalizedHostBinFile { + readonly bytes: number; + readonly executable: boolean; + /** POSIX path relative to the declared host bin directory. */ + readonly relativePath: string; + /** Absolute source file path. */ + readonly source: string; +} + +/** One adapter-declared host-native plugin executable directory. */ +export interface NormalizedHostBin { + readonly files: readonly NormalizedHostBinFile[]; + readonly issue?: 'empty' | 'missing' | 'not-directory' | 'outside' | 'source-error' | 'source-invalid'; + readonly provenance: SourceProvenance; + /** Absolute source directory path. */ + readonly source: string; + readonly target: string; +} + export interface NormalizedNativeHook { readonly document?: unknown; readonly issue?: 'missing' | 'parse' | 'source-error' | 'source-invalid'; @@ -553,6 +573,8 @@ export interface NormalizedPlugin { */ readonly commands?: readonly NormalizedCommand[]; readonly extensions: Readonly>; + /** Adapter-declared host-native executable directories, enumerated during normalization. */ + readonly hostBins?: readonly NormalizedHostBin[]; readonly hooks: readonly NormalizedHook[]; readonly marketplace?: true; readonly metadata: NormalizedMetadata; @@ -610,7 +632,25 @@ export type NormalizationNativeHookSource = | NormalizationNativeHookDocument | NormalizationNativeHookSourceError; +export interface NormalizationHostBinDocument { + readonly source: string; + readonly target: string; +} + +export interface NormalizationHostBinSourceError { + readonly issue: 'error' | 'invalid'; + readonly target: string; +} + +export type NormalizationHostBinSource = + | NormalizationHostBinDocument + | NormalizationHostBinSourceError; + export interface NormalizationTargetRegistry { + binSources?( + config: Readonly, + targetNames: readonly string[], + ): readonly NormalizationHostBinSource[]; capabilityState?(name: string, capability: string): CapabilityState | undefined; configExtensions(): readonly NormalizationConfigExtension[]; defaultTargetNames(): readonly string[]; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 08147e951..84f7d4646 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { configuredPayloadRoots, discoverProject } from '../config/discover.ts'; @@ -278,7 +278,7 @@ export const snapshotProjectSource = async ( root: string, configPath: string, outputRoots: readonly string[] = [], - payloadRoots: readonly string[] = [], + additionalSourceRoots: readonly string[] = [], ): Promise => { const requestedRoot = resolve(root); const resolvedRoot = await realpath(requestedRoot); @@ -287,13 +287,13 @@ export const snapshotProjectSource = async ( relativeSourcePath(requestedRoot, requestedConfigPath); const resolvedConfigPath = await realpath(requestedConfigPath); relativeSourcePath(resolvedRoot, resolvedConfigPath); - // Declared prebuilt payload files join the identity even though payload - // directories are ignored for source discovery: the artifact packages + // Declared prebuilt payload and host-bin files join the identity even when + // their directories are ignored for source discovery: the artifact packages // their exact bytes, so the project revision must change with them. const sources = new Set([ resolvedConfigPath, ...(await sourcePaths(resolvedRoot, resolvedOutputRoots)), - ...(await payloadSourcePaths(resolvedRoot, payloadRoots)), + ...(await payloadSourcePaths(resolvedRoot, additionalSourceRoots)), ]); const inputs = Object.freeze((await Promise.all([...sources].map((source) => sourceInput(resolvedRoot, source)))) .sort((left, right) => left.path.localeCompare(right.path))); @@ -738,10 +738,15 @@ export class ProjectService { return failedPreparation('AB7000', 'Unable to load project source.', configPath, 'project.invalid-source', snapshot); } - const payloadRoots = configuredPayloadRoots(root, loaded.config); + const targetNames = loaded.context.selectedTargets.length > 0 + ? loaded.context.selectedTargets + : (loaded.config.targets ?? registry.defaultTargetNames()); + const hostBinRoots = (registry.binSources?.(loaded.config, targetNames) ?? []) + .flatMap((source) => 'source' in source ? [resolve(dirname(loaded.configPath), source.source)] : []); + const additionalSourceRoots = [...configuredPayloadRoots(root, loaded.config), ...hostBinRoots]; let snapshot: ProjectSourceSnapshot; try { - snapshot = await snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots); + snapshot = await snapshotProjectSource(root, loaded.configPath, outputRoots, additionalSourceRoots); } catch { return failedPreparation('AB7003', 'Unable to snapshot project source.', loaded.configPath, 'project.invalid-source'); } @@ -791,7 +796,7 @@ export class ProjectService { ); } const snapshotSource = (): Promise => - snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots); + snapshotProjectSource(root, loaded.configPath, outputRoots, additionalSourceRoots); if (hasErrors(sourceDiagnostics)) { const source = sourceStatus(sourceDiagnostics, snapshot.revision, root); log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root }); diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index ac1ac3994..21859350d 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -109,6 +109,27 @@ it('reports Claude LSP support and honest unavailable composite coverage', () => expect(registry.supports('plugin', 'lsp')).toBe(false); }); +it('reports Claude bin support without inventing coverage on other native hosts', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities.bin).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities.bin).toMatchObject({ + reason: expect.stringContaining('Claude-only bin'), + state: 'unavailable', + }); + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities.bin).toBeUndefined(); + } + expect(registry.supports('claude', 'bin')).toBe(true); + expect(registry.supports('plugin', 'bin')).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 23a7a43a1..2beeae7ed 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.5.0', + adapterRevision: '1.6.0', observedVersion: '2.1.250', schemas: [ { @@ -149,6 +149,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.5.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { @@ -262,17 +263,20 @@ it('returns frozen, detached metadata snapshots while retaining the original ada expect(() => (snapshot.schemas[0] as { name: string }).name = 'changed').toThrow(); }); -it('snapshots canonical immutable artifact output suffixes and rejects malformed layouts', () => { +it('snapshots canonical immutable artifact output suffixes and recursive namespaces and rejects malformed layouts', () => { const allowedSuffixes = ['.mjs', '.sh']; const registered = adapter('custom'); const registry = new TargetRegistry().register({ ...registered, - artifactLayout: { scripts: { allowedSuffixes, directory: 'scripts' } }, + artifactLayout: { bin: 'bin', scripts: { allowedSuffixes, directory: 'scripts' } }, }); const layout = registry.artifactLayout('custom'); allowedSuffixes.push('.py'); - expect(layout).toEqual({ scripts: { allowedSuffixes: ['.mjs', '.sh'], directory: 'scripts' } }); + expect(layout).toEqual({ + bin: 'bin', + scripts: { allowedSuffixes: ['.mjs', '.sh'], directory: 'scripts' }, + }); expect(Object.isFrozen(layout)).toBe(true); expect(Object.isFrozen(layout.scripts)).toBe(true); expect(Object.isFrozen(layout.scripts?.allowedSuffixes)).toBe(true); diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 11f84846b..a62fe0f3c 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -35,7 +35,7 @@ const createFifo = async (path: string): Promise => new Promise((resolvePr interface ArtifactFixtureFile { readonly contents: string; - readonly kind: 'bundle' | 'copy' | 'generated'; + readonly kind: 'bundle' | 'copy' | 'generated' | 'prebuilt'; readonly mode?: number; readonly path: string; } @@ -183,6 +183,7 @@ const customRegistry = (validate = validateCustomDocument): TargetRegistry => ne }, artifactLayout: { assets: 'assets', + bin: 'bin', commands: { allowedSuffixes: ['.md'], directory: 'commands' }, rules: { allowedSuffixes: ['.mdc'], directory: 'rules' }, scripts: { allowedSuffixes: ['.json', '.mjs', '.sh'], directory: 'scripts' }, @@ -807,6 +808,21 @@ it('admits nested project assets in the target-owned recursive asset namespace', } }); +it('admits executable commands and nested support files in a recursive bin namespace', async () => { + const files = [ + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, + { contents: '#!/usr/bin/env sh\n', kind: 'prebuilt' as const, mode: 0o751, path: 'custom/bin/review-tool' }, + { contents: '{"enabled":true}\n', kind: 'prebuilt' as const, path: 'custom/bin/lib/config.json' }, + ]; + const root = await writeArtifact(files, true, [customManifestTarget]); + + try { + await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it.each([ ['a missing copied resource', '[missing resource](references/missing.md)'], ['a percent-encoded path that escapes the Skill root', '[escape resource](..%2Fdocument.json)'], diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index ec16bb1b3..350748453 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -1,11 +1,12 @@ import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; 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'; const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip; @@ -20,10 +21,25 @@ const runClaudeValidation = async (cwd: string, marketplace: string): Promise { +nativeIt('accepts an emitted Claude plugin with bin under strict native validation', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-marketplace-')); + const sourceRoot = join(root, 'authored-bin'); + const source = join(sourceRoot, 'review-tool'); + const outputRoot = join(root, 'plugin'); + const executable = '#!/usr/bin/env sh\nprintf "reviewed\\n"\n'; const model: NormalizedPlugin = { extensions: {}, + hostBins: [{ + files: [{ + bytes: Buffer.byteLength(executable), + executable: true, + relativePath: 'review-tool', + source, + }], + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: sourceRoot, + target: 'claude', + }], hooks: [], marketplace: true, mcpServers: [], @@ -45,13 +61,14 @@ nativeIt('accepts the emitted Claude marketplace under strict native validation' }; try { - for (const entry of claudeAdapter.plan(model).entries) { - if (entry.kind !== 'write') continue; - const output = join(root, entry.relativePath); - await mkdir(join(output, '..'), { recursive: true }); - await writeFile(output, entry.content); - } - await expect(runClaudeValidation(root, join(root, '.claude-plugin', 'marketplace.json'))).resolves.toBe(0); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(source, executable); + await chmod(source, 0o751); + await emitPlanEntries({ entries: claudeAdapter.plan(model).entries, root: outputRoot }); + await expect(runClaudeValidation( + outputRoot, + join(outputRoot, '.claude-plugin', 'marketplace.json'), + )).resolves.toBe(0); } 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 af1b12ccc..91ba0ab06 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core'; import { cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emitPlanEntries } from '../src/build/emit.ts'; import { build } from './support/build.ts'; import { pathTokens, pluginRootEnvAnchor, type NormalizedPlugin } from '../src/core/types.ts'; @@ -100,6 +101,25 @@ const withClaudeLsp = ( }, }); +const withClaudeBin = ( + model: NormalizedPlugin, + files: NonNullable[number]['files'], + options: { + readonly issue?: NonNullable[number]['issue']; + readonly source?: string; + readonly target?: string; + } = {}, +): NormalizedPlugin => ({ + ...model, + hostBins: [{ + files, + ...(options.issue === undefined ? {} : { issue: options.issue }), + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: options.source ?? '/workspace/tools', + target: options.target ?? 'claude', + }], +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -484,6 +504,112 @@ it('emits Claude LSP configuration and expands only the four documented token fi expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); }); +it('plans Claude bin files as byte-faithful prebuilt copies with complete provenance', () => { + const model = withClaudeBin(plugin, [ + { + bytes: 37, + executable: true, + relativePath: 'review-tool', + source: '/workspace/tools/review-tool', + }, + { + bytes: 12, + executable: false, + relativePath: 'lib/config.json', + source: '/workspace/tools/lib/config.json', + }, + ]); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics).toEqual([]); + expect(plan.entries.filter((entry) => entry.relativePath.startsWith('bin/'))).toEqual([ + { + bytes: 12, + kind: 'copy', + prebuilt: true, + relativePath: 'bin/lib/config.json', + source: '/workspace/tools/lib/config.json', + sourceInputs: ['/workspace/agent-bundle.config.ts', '/workspace/tools/lib/config.json'], + }, + { + bytes: 37, + kind: 'copy', + prebuilt: true, + relativePath: 'bin/review-tool', + source: '/workspace/tools/review-tool', + sourceInputs: ['/workspace/agent-bundle.config.ts', '/workspace/tools/review-tool'], + }, + ]); +}); + +it.each([ + { + code: 'claude.bin.directory.missing', + issue: 'missing' as const, + recovery: 'Create the configured Claude bin directory and add at least one executable, then rebuild.', + }, + { + code: 'claude.bin.directory.empty', + issue: 'empty' as const, + recovery: 'Add at least one file to the configured Claude bin directory, then rebuild.', + }, +])('diagnoses a Claude bin directory that is $issue without emitting it', ({ code, issue, recovery }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeBin(plugin, [], { issue })); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ code, recovery, severity: 'error' })); + expect(plan.entries.some((entry) => entry.relativePath.startsWith('bin/'))).toBe(false); +}); + +it('requires every top-level Claude bin file to be executable while allowing nested support files', () => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeBin(plugin, [ + { + bytes: 12, + executable: false, + relativePath: 'review-tool', + source: '/workspace/tools/review-tool', + }, + { + bytes: 12, + executable: false, + relativePath: 'lib/config.json', + source: '/workspace/tools/lib/config.json', + }, + ])); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code: 'claude.bin.executable.required', + recovery: 'Run chmod +x on every top-level file in the configured Claude bin directory, then rebuild.', + severity: 'error', + })); + expect(plan.entries.some((entry) => entry.relativePath.startsWith('bin/'))).toBe(false); +}); + +it('preserves the executable mode when emitting a Claude bin copy entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-bin-')); + const source = join(root, 'authored', 'review-tool'); + const output = join(root, 'output'); + await mkdir(join(root, 'authored'), { recursive: true }); + await writeFile(source, '#!/usr/bin/env sh\nprintf "reviewed\\n"\n'); + await chmod(source, 0o751); + + try { + const plan = createDefaultRegistry().get('claude').plan(withClaudeBin(plugin, [{ + bytes: (await stat(source)).size, + executable: true, + relativePath: 'review-tool', + source, + }], { source: join(root, 'authored') })); + await emitPlanEntries({ + entries: plan.entries.filter((entry) => entry.relativePath.startsWith('bin/')), + root: output, + }); + + expect((await stat(join(output, 'bin', 'review-tool'))).mode & 0o777).toBe(0o751); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('pins all documented Claude plugin-manifest LSP declaration forms', async () => { const schema = (await import('../src/adapters/schemas/claude/plugin.schema.json', { with: { type: 'json' }, diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 3bf4b994a..8cd4a3975 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -1,4 +1,7 @@ import { expect, it } from '@rstest/core'; +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import * as normalizeModule from '../src/config/normalize.ts'; import { @@ -204,6 +207,91 @@ it('normalizes the typed Claude LSP source surface through the strict JSON exten expect(Object.isFrozen(extension?.value)).toBe(true); }); +it('enumerates claude.bin relative to the config file into immutable executable metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-bin-normalize-')); + const configDir = join(root, 'configs'); + const binRoot = join(root, 'tools'); + const executable = join(binRoot, 'review-tool'); + const support = join(binRoot, 'lib', 'config.json'); + const executableContents = '#!/usr/bin/env sh\nprintf "reviewed\\n"\n'; + const supportContents = '{"enabled":true}\n'; + await mkdir(join(binRoot, 'lib'), { recursive: true }); + await mkdir(configDir, { recursive: true }); + await Promise.all([ + writeFile(executable, executableContents), + writeFile(support, supportContents), + ]); + await chmod(executable, 0o751); + const loaded: LoadedConfig = { + ...loadedProject({ + claude: { bin: '../tools' }, + plugin: { name: 'claude-bin-fixture', version: '1.0.0' }, + targets: ['claude'], + }, { root }), + configPath: join(configDir, 'agent-bundle.config.ts'), + }; + + try { + const model = await normalizeProject(loaded, { skills: [] }, createDefaultRegistry()); + + expect(model.hostBins).toEqual([{ + files: [ + { + bytes: Buffer.byteLength(supportContents), + executable: false, + relativePath: 'lib/config.json', + source: support, + }, + { + bytes: Buffer.byteLength(executableContents), + executable: true, + relativePath: 'review-tool', + source: executable, + }, + ], + provenance: { kind: 'config', sourcePath: loaded.configPath }, + source: binRoot, + target: 'claude', + }]); + expect(Object.isFrozen(model.hostBins)).toBe(true); + expect(Object.isFrozen(model.hostBins?.[0])).toBe(true); + expect(Object.isFrozen(model.hostBins?.[0]?.files)).toBe(true); + expect(Object.isFrozen(model.hostBins?.[0]?.files[0])).toBe(true); + + const pluginModel = await normalizeProject({ + ...loaded, + config: { ...loaded.config, targets: ['plugin'] }, + }, { skills: [] }, createDefaultRegistry()); + expect(pluginModel.hostBins?.[0]?.target).toBe('plugin'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it.each([ + { code: 'claude.bin.directory.missing', create: false, issue: 'missing' as const }, + { code: 'claude.bin.directory.empty', create: true, issue: 'empty' as const }, +])('normalizes and diagnoses a claude.bin directory that is $issue', async ({ code, create, issue }) => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-bin-diagnostic-')); + const binRoot = join(root, 'tools'); + if (create) await mkdir(binRoot, { recursive: true }); + const loaded = loadedProject({ + claude: { bin: './tools' }, + plugin: { name: 'claude-bin-diagnostic', version: '1.0.0' }, + targets: ['claude'], + }, { root }); + + try { + const model = await normalizeProject(loaded, { skills: [] }, createDefaultRegistry()); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(model.hostBins?.[0]).toMatchObject({ files: [], issue, source: binRoot, target: 'claude' }); + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ code, severity: 'error' })); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('rejects non-JSON values in registered config extensions before normalization', async () => { class ExtensionClass { readonly enabled = true; diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 47611e721..8e0f7e138 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -268,6 +268,35 @@ it('emits Claude-only LSP configuration at the shared composite root', () => { expect(documents['AGENTS.md']).toContain('claude --debug'); }); +it('emits the Claude bin directory from the unified plugin target', () => { + const model: NormalizedPlugin = { + ...bundleModel, + hostBins: [{ + files: [{ + bytes: 37, + executable: true, + relativePath: 'review-tool', + source: '/workspace/tools/review-tool', + }], + provenance: { kind: 'config', sourcePath: configPath }, + source: '/workspace/tools', + target: 'plugin', + }], + }; + const plan = planBundle(model); + + expect(plan.diagnostics).toEqual([]); + expect(plan.entries.filter((entry) => entry.relativePath.startsWith('bin/'))).toEqual([{ + bytes: 37, + kind: 'copy', + prebuilt: true, + relativePath: 'bin/review-tool', + source: '/workspace/tools/review-tool', + sourceInputs: [configPath, '/workspace/tools/review-tool'], + }]); + expect(writeContents(model)['AGENTS.md']).toContain('`bin/`'); +}); + it('emits each shared surface exactly once with no duplicate artifact paths', () => { const plan = planBundle(bundleModel); const paths = plan.entries.map((entry) => entry.relativePath); diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index 6edfacafd..961bf4b20 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -193,6 +193,7 @@ it('reports the prebuilt payload source diagnostics', async () => { ].join('\n'), payload: [ ' payload: {', + " bin: './built/app',", " 'mcp-apps': './built/app',", " absent: './built/never-built',", " runtime: { source: './built/runtime', targets: ['claude'] },", @@ -204,7 +205,9 @@ it('reports the prebuilt payload source diagnostics', async () => { const result = await validate({ root }); const codes = result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity] as const); // The reserved destination name. - expect(codes).toContainEqual(['AB4741', 'error']); + expect(codes.filter(([code]) => code === 'AB4741')).toHaveLength(2); + expect(result.diagnostics.find((diagnostic) => + diagnostic.code === 'AB4741' && diagnostic.message.includes('"bin"'))?.recovery).toContain('claude.bin'); // The not-yet-built payload directory warns instead of failing validation. expect(codes).toContainEqual(['AB4743', 'warning']); // A prebuilt entry outside every declared payload, and one whose payload