Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claude-plugin-bin.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <dir>` 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 <dir> --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 <dir> plugin list --json` for registration proof, `claude plugin details <name>` 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$.",
Expand Down
119 changes: 118 additions & 1 deletion packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
validateModernMcpDocument,
withPluginRootEnvAnchor,
type TargetAdapter,
type TargetArtifactCopy,
type TargetArtifactLayout,
type TargetArtifactPlan,
} from './types.ts';
Expand Down Expand Up @@ -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<Record<string, ClaudeLspServerConfig>>;
}

Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -520,13 +625,15 @@ export const planClaudeArtifacts = (
...basePlan,
entries: sortedEntries([
...basePlan.entries,
...bin.entries,
...commandWriteEntries(model, isSelected, claudeCommandMarkdown),
]),
}), model, targetName === 'plugin' ? 'plugin' : 'claude');
};

const artifactLayout: TargetArtifactLayout = Object.freeze({
...standardArtifactLayout,
bin: 'bin',
commands: Object.freeze({
allowedSuffixes: Object.freeze(['.md']),
directory: 'commands',
Expand All @@ -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,
Expand Down Expand Up @@ -571,6 +687,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: claudeName,
binSource: (config: Readonly<AgentBundleConfig>) => config.claude?.bin,
nativeHookSource: (config: Readonly<AgentBundleConfig>) => config.claude?.nativeHooks,
plan: planClaudeArtifacts,
});
17 changes: 15 additions & 2 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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`. */
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!,
Expand Down Expand Up @@ -586,5 +598,6 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: pluginName,
binSource: (config: Readonly<AgentBundleConfig>) => config.claude?.bin,
plan,
});
44 changes: 44 additions & 0 deletions packages/agent-bundle/src/adapters/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dataArrayValues } from '../core/strict-json.ts';
import type {
AgentBundleConfig,
NormalizationConfigExtension,
NormalizationHostBinSource,
NormalizationNativeHookSource,
NormalizationTargetRegistry,
} from '../core/types.ts';
Expand Down Expand Up @@ -31,6 +32,7 @@ import { deepFreeze } from '../core/freeze.ts';

const sha256Pattern = /^[0-9a-f]{64}$/;
type NativeHookSource = NonNullable<TargetAdapter['nativeHookSource']>;
type BinSource = NonNullable<TargetAdapter['binSource']>;

const emptyArtifactValidation: TargetArtifactValidationContract = deepFreeze({
documents: [],
Expand Down Expand Up @@ -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');
Expand All @@ -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.');
}
Expand All @@ -216,6 +224,7 @@ const snapshotArtifactLayout = (
}
return Object.freeze({
...(assets === undefined ? {} : { assets }),
...(bin === undefined ? {} : { bin }),
...(commands === undefined ? {} : { commands }),
...(hookWrappers === undefined ? {} : { hookWrappers }),
...(mcpApps === undefined ? {} : { mcpApps }),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -393,6 +410,7 @@ export class TargetRegistry implements NormalizationTargetRegistry {
readonly #adapters = new Map<string, TargetAdapter>();
readonly #artifactLayouts = new Map<string, TargetArtifactLayout>();
readonly #artifactValidations = new Map<string, TargetArtifactValidationContract>();
readonly #binSources = new Map<string, BinSource>();
readonly #defaults: string[] = [];
readonly #extensions = new Map<string, NormalizationConfigExtension>();
readonly #hookContracts = new Map<string, TargetHookContract>();
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -497,6 +519,28 @@ export class TargetRegistry implements NormalizationTargetRegistry {
return Object.freeze([...this.#extensions.values()]);
}

binSources(
config: Readonly<AgentBundleConfig>,
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<AgentBundleConfig>,
targetNames: readonly string[],
Expand Down
Loading
Loading