Skip to content

Commit 67a2160

Browse files
fix(adapters): pin the cliBin layout to bin/.mjs at registration; changeset PR reference
Codex review on #419: check the supported-`cli`-without-layout invariant before the empty-layout early return, reject a `cliBin` layout that names any directory but `bin` or omits `.mjs` (the compiler emits the routed CLI only there), and end the changeset summary with the PR reference.
1 parent 70c43bb commit 67a2160

7 files changed

Lines changed: 88 additions & 36 deletions

File tree

‎.changeset/387-artifact-routed-cli.md‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22
"agent-bundle": minor
33
---
44

5-
Emit the routed CLI (`src/cli/**`) into host artifacts, not only the npm
6-
package build. Every target whose adapter publishes the new `cli` capability
7-
(all built-in targets: `claude`, `codex`, `cursor`, `portable`, `plugin`)
8-
now receives `bin/<plugin-name>.mjs` — the same compiled command graph as
9-
`dist/bin/<plugin-name>.js`, run as `node <plugin-root>/bin/<plugin-name>.mjs`
10-
— plus `bin/<plugin-name>-flight.mjs` when any command renders, so installed
11-
skills, hooks, and script routes can invoke the CLI without a separate npm
12-
install. Script routes reach it as the `../bin/<plugin-name>.mjs` sibling of
13-
their own `import.meta.url`; skills and hooks reach it through the plugin-root
14-
token. The artifact manifest, validation (`cliBin` layout), `inspect`
15-
component accounting (`cli` kind), and `inspect --bundler` all know the new
16-
`bin/` directory. A target without the capability omits the bin and reports
17-
`AB4765`; a host-emitted file colliding with the bin path is `AB4766`. The
18-
package build's own bin emission is unchanged.
5+
Emit the routed CLI (`src/cli/**`) into every host artifact as
6+
`<target>/bin/<plugin-name>.mjs` (plus `bin/<plugin-name>-flight.mjs` when a
7+
command renders), not only into the npm package build, so installed skills,
8+
hooks, and script routes can run it with `node <plugin-root>/bin/<plugin-name>.mjs`.
9+
Every built-in target publishes the new `cli` adapter capability that admits
10+
the bin; `inspect` accounts for it as a `cli` component, `inspect --bundler`
11+
and the artifact manifest list it, and artifact validation admits the `cliBin`
12+
layout. A target without the capability omits the bin with `AB4765`; a
13+
host-emitted file colliding with the bin path fails the build with `AB4766`.
14+
Script routes reach the bin as their `../bin/<plugin-name>.mjs` sibling;
15+
skills and hooks reach it through the plugin-root token. The package build's
16+
`dist/bin/<plugin-name>.js` is unchanged. (#419)

‎docs/entry-conventions.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,10 @@ or skipped with the host's `cli` capability judgment), `inspect --bundler`
433433
dumps each target's `bin/` composition beside its scripts, and the artifact
434434
manifest records both files with `bundle` provenance naming every command
435435
route. Artifact validation admits the `bin/` layout only for adapters that
436-
declare it (`cliBin`); an adapter that publishes a supported `cli` capability
437-
without that layout is rejected at registration. A target without the
436+
declare it (`cliBin`); because the compiler emits the CLI at exactly
437+
`bin/<plugin-name>.mjs`, an adapter that publishes a supported `cli`
438+
capability without that layout, or with a `cliBin` layout naming another
439+
directory or omitting `.mjs`, is rejected at registration. A target without the
438440
capability omits the bin and reports `AB4765`; a host-emitted file at the
439441
same path (a Claude `claude.bin` directory shipping `<plugin-name>.mjs`) is
440442
`AB4766`. The package build's `dist/bin/<plugin-name>.js` is unchanged.

‎packages/agent-bundle/src/adapters/portable.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import pluginSchema from './schemas/portable/plugin.schema.json' with { type: 'j
3737
import {
3838
createAdapterValidator,
3939
payloadCopyEntries,
40+
routedCliBinLayout,
4041
schemaDescriptorsFrom,
4142
sourceInputs,
4243
validateJsonSchemaDocument,
@@ -610,7 +611,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({
610611
artifactValidation,
611612
artifactLayout: Object.freeze({
612613
assets: 'assets',
613-
cliBin: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'bin' }),
614+
cliBin: routedCliBinLayout,
614615
mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }),
615616
mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }),
616617
rootDocuments: Object.freeze(['INSTALL.md', 'install.mjs']),

‎packages/agent-bundle/src/adapters/registry.ts‎

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,17 @@ import { cursorAdapter } from './cursor.ts';
1616
import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts';
1717
import { portableAdapter } from './portable.ts';
1818
import { pluginAdapter } from './plugin.ts';
19-
import type {
20-
TargetAdapter,
21-
TargetArtifactDocumentContract,
22-
TargetArtifactDocumentValidator,
23-
TargetArtifactLayout,
24-
TargetArtifactOutputLayout,
25-
TargetArtifactSchemaContract,
26-
TargetArtifactValidationContract,
27-
TargetAdapterMetadata,
28-
TargetSchemaDescriptor,
19+
import {
20+
routedCliBinLayout,
21+
type TargetAdapter,
22+
type TargetArtifactDocumentContract,
23+
type TargetArtifactDocumentValidator,
24+
type TargetArtifactLayout,
25+
type TargetArtifactOutputLayout,
26+
type TargetArtifactSchemaContract,
27+
type TargetArtifactValidationContract,
28+
type TargetAdapterMetadata,
29+
type TargetSchemaDescriptor,
2930
} from './types.ts';
3031
import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts';
3132
import { deepFreeze } from '../core/freeze.ts';
@@ -181,12 +182,31 @@ const snapshotArtifactLayout = (
181182
hookContract: TargetHookContract | undefined,
182183
mcpRuntime: TargetMcpRuntimeContract | undefined,
183184
): TargetArtifactLayout => {
185+
// A supported `cli` capability promises a home for the compiled routed CLI,
186+
// and the compiler emits it at exactly one place (`bin/<name>.mjs`), so the
187+
// promise is checked before any early return and against that fixed layout.
188+
const cliSupported = capabilityIsSupported(adapter.capabilities[cliBinCapability]);
189+
const missingCliBinLayout = (): Error =>
190+
new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`);
184191
const declaredLayout = adapter.artifactLayout;
185-
if (declaredLayout === undefined) return emptyArtifactLayout;
192+
if (declaredLayout === undefined) {
193+
if (cliSupported) throw missingCliBinLayout();
194+
return emptyArtifactLayout;
195+
}
186196
const layout = record(declaredLayout);
187197
if (layout === undefined) throw new Error('Target adapter artifact layout must be a record.');
188198

189199
const cliBin = layout.cliBin === undefined ? undefined : snapshotOutputLayout(layout.cliBin, 'routed CLI bin');
200+
if (cliBin === undefined && cliSupported) throw missingCliBinLayout();
201+
if (
202+
cliBin !== undefined &&
203+
(cliBin.directory !== routedCliBinLayout.directory ||
204+
!routedCliBinLayout.allowedSuffixes.every((suffix) => cliBin.allowedSuffixes.includes(suffix)))
205+
) {
206+
throw new Error(
207+
`Target adapter "${adapter.name}" routed CLI bin layout must use directory ${JSON.stringify(routedCliBinLayout.directory)} and admit ${routedCliBinLayout.allowedSuffixes.map((suffix) => JSON.stringify(suffix)).join(', ')}; the compiler emits the routed CLI only there.`,
208+
);
209+
}
190210
const commands = layout.commands === undefined ? undefined : snapshotOutputLayout(layout.commands, 'commands');
191211
const hookWrappers = layout.hookWrappers === undefined
192212
? undefined
@@ -235,9 +255,6 @@ const snapshotArtifactLayout = (
235255
if (skills !== undefined && !capabilityIsSupported(adapter.capabilities.skills)) {
236256
throw new Error(`Target adapter "${adapter.name}" declares Skill layout without skills capability.`);
237257
}
238-
if (cliBin === undefined && capabilityIsSupported(adapter.capabilities[cliBinCapability])) {
239-
throw new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`);
240-
}
241258
return Object.freeze({
242259
...(assets === undefined ? {} : { assets }),
243260
...(bin === undefined ? {} : { bin }),

‎packages/agent-bundle/src/adapters/types.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,14 +435,27 @@ export interface TargetArtifactLayout {
435435
readonly workflows?: string;
436436
}
437437

438+
/**
439+
* The one layout the compiler emits the routed CLI into (#387):
440+
* `bin/<plugin-name>.mjs` plus `bin/<plugin-name>-flight.mjs`. Every adapter
441+
* that publishes a supported `cli` capability must declare a `cliBin` layout
442+
* naming this directory and admitting this suffix; the registry rejects any
443+
* other spelling because the compiler would otherwise emit files its own
444+
* artifact validation rejects.
445+
*/
446+
export const routedCliBinLayout: TargetArtifactOutputLayout = Object.freeze({
447+
allowedSuffixes: Object.freeze(['.mjs']),
448+
directory: 'bin',
449+
});
450+
438451
/**
439452
* Direct-file artifact layout shared by every plugin-shaped target adapter
440453
* (Claude, Codex): hook wrappers, MCP apps/entries, scripts, and skills all
441454
* land in the same target-agnostic directories with the same suffix policy.
442455
*/
443456
export const standardArtifactLayout: TargetArtifactLayout = Object.freeze({
444457
assets: 'assets',
445-
cliBin: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'bin' }),
458+
cliBin: routedCliBinLayout,
446459
hookWrappers: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'hooks' }),
447460
mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }),
448461
mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }),

‎packages/agent-bundle/src/build/cli-bins.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
22

33
import { cliBinCapability } from '../adapters/capability-state.ts';
44
import type { TargetRegistry } from '../adapters/registry.ts';
5-
import type { TargetArtifactEntry } from '../adapters/types.ts';
5+
import { routedCliBinLayout, type TargetArtifactEntry } from '../adapters/types.ts';
66
import type { Diagnostic } from '../core/diagnostics.ts';
77
import type { AgentBundleToolsConfig, NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts';
88
import type { AgentBundleMeta } from '../meta.ts';
@@ -28,7 +28,8 @@ import { buildWithRslib, type RslibEntry } from './rslib.ts';
2828
* separate npm install. The package build's own bin emission is untouched.
2929
*/
3030

31-
export const cliBinDirectory = 'bin';
31+
/** The one directory the compiler emits the routed CLI into; the registry pins every `cliBin` layout to it. */
32+
export const cliBinDirectory: string = routedCliBinLayout.directory;
3233

3334
/** The artifact-relative executable path for one routed-CLI bin. */
3435
export const cliBinArtifactPath = (name: string): string => `${cliBinDirectory}/${name}.mjs`;

‎packages/agent-bundle/tests/adapter-capability-states.test.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -906,18 +906,38 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i
906906
}
907907

908908
// A supported `cli` row promises a place for the executable, so an adapter
909-
// without the layout cannot register; one that publishes no row stays valid
910-
// and simply hosts no bin.
909+
// without the layout — or with no artifact layout at all — cannot register;
910+
// one that publishes no row stays valid and simply hosts no bin.
911911
const cursor = registry.get('cursor');
912912
const { cliBin: _cliBin, ...layoutWithoutBin } = cursor.artifactLayout!;
913913
expect(() => new TargetRegistry().register({ ...cursor, artifactLayout: layoutWithoutBin }))
914914
.toThrow(/supported cli capability without a routed CLI bin layout/u);
915+
const { artifactLayout: _layout, ...cursorWithoutLayout } = cursor;
916+
expect(() => new TargetRegistry().register(cursorWithoutLayout))
917+
.toThrow(/supported cli capability without a routed CLI bin layout/u);
915918
const { cli: _cli, ...capabilitiesWithoutCli } = cursor.capabilities;
916919
expect(() => new TargetRegistry().register({
917920
...cursor,
918921
artifactLayout: layoutWithoutBin,
919922
capabilities: capabilitiesWithoutCli,
920923
})).not.toThrow();
924+
925+
// The compiler emits the routed CLI at exactly `bin/<name>.mjs`, so a
926+
// `cliBin` layout naming any other directory or omitting `.mjs` is rejected
927+
// instead of producing files artifact validation would reject.
928+
for (const cliBin of [
929+
{ allowedSuffixes: ['.mjs'], directory: 'cli' },
930+
{ allowedSuffixes: ['.js'], directory: 'bin' },
931+
]) {
932+
expect(() => new TargetRegistry().register({
933+
...cursor,
934+
artifactLayout: { ...layoutWithoutBin, cliBin },
935+
})).toThrow(/routed CLI bin layout must use directory "bin" and admit "\.mjs"/u);
936+
}
937+
expect(() => new TargetRegistry().register({
938+
...cursor,
939+
artifactLayout: { ...layoutWithoutBin, cliBin: { allowedSuffixes: ['.js', '.mjs'], directory: 'bin' } },
940+
})).not.toThrow();
921941
});
922942

923943
it('rejects a malformed inspection component capability when the adapter registers', () => {

0 commit comments

Comments
 (0)