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