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/packaged-host-installers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': minor
---

Generate package-relative host installer bins for publishable plugin packages and add the `agent-bundle prepack` inventory, freshness, bin-target, and version-agreement gate.
10 changes: 10 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,21 @@ gate a build, a validation, or a dev rebuild.
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree). |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7316` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health, and durable-state inventory. |
| `AB8xxx` | Development server configuration. |
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |

## npm prepack gate (`AB7010`–`AB7013`)

| Code | Meaning |
| --- | --- |
| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest/file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. |
| `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. |
| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. |
| `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. |

## Declaration generation (`AB4716`)

A `lib` entry with `dts` enabled compiles its source directory as its own
Expand Down
15 changes: 15 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ node-consumable package build under `dist/` — the outputs `package.json`
| `bin: { '<name>': './src/cli.ts' }` | `dist/bin/<name>.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. |
| `lib: { entry: './src/index.ts', dts: true }` | `dist/<stem>.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. |

- When package outputs and at least one Claude, Codex, or Cursor host pack are
built inside the project, the framework also emits one self-contained
package-relative installer. It is `dist/bin/<plugin-name>.js` when that name
is free, otherwise `dist/bin/<plugin-name>-install.js`. Declare the matching
`package.json` `bin` value. Its grammar is
`install <host> [--scope <scope>] [--json]`; help lists only built hosts.
The baked URL resolves the shipped artifact directory from `import.meta.url`,
never the caller's working directory, and delegates to the same
`installBundle` implementation as `agent-bundle install`.
- `agent-bundle prepack [--root <root>] [--output <artifact>] [--json]` runs
the release build and `npm pack --dry-run --json --ignore-scripts`, then
gates the exact package/artifact inventory, manifest hashes, package bin
targets, and release-version agreement. Use it as an npm `prepack` script;
`--ignore-scripts` prevents recursion and npm install never runs the host
installer.
- The package build runs for `agent-bundle build` (CLI, or
`build({ packageOutputs: true })` through the API) and inside the
`agent-bundle dev` rebuild loop (see “Dev-watch of the package build”
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ manifests at files inside those payloads without compiling them. Payload files c
| Command | Purpose |
| --- | --- |
| `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. |
| `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). |
| `agent-bundle install <host>` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, and `--json` supported). |
| `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. |
| `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. |
Expand Down Expand Up @@ -128,6 +129,15 @@ Cursor installation is user-scoped. Claude also accepts `--scope project` and
`--scope local`; Codex is user-scoped. A source-free artifact root is accepted
by `--from` when it contains the selected host target directory.

When package outputs ship one of those host packs, the build also emits a
package-relative installer bin. It uses the plugin name when no configured bin
claims it and `<plugin-name>-install` otherwise. Map that name to the generated
`dist/bin/*.js` file in `package.json`; consumers run
`<bin> install <host> [--scope <scope>] [--json]`. The executable locates the
artifact directory beside the installed package, so it works from
`node_modules` regardless of the current directory. No npm lifecycle performs
an installation.

## Developer workbench

`agent-bundle dev` serves a loopback-only prebuilt workbench. It shows project
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default defineConfig({
'event-ipc': './src/events/ipc.ts',
'event-project': './src/events/project.ts',
index: './src/index.ts',
'install-entry': './src/install-entry.ts',
'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
Expand Down
40 changes: 40 additions & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { execFile as executeFile } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';

import {
AGENT_STATE_DEFAULT_BUDGETS,
Expand All @@ -11,6 +13,11 @@ import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts';
import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts';
import { build as buildArtifact, type BuildResult } from './build/build.ts';
import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts';
import {
packInventoryDiagnostics,
packOutputFromJson,
type PackOutput,
} from './build/pack-inventory.ts';
import type { CapabilityState } from './core/capabilities.ts';
import { isInsideOrEqual } from './core/paths.ts';
import { emptyCompiledRouteGraph } from './routes/graph.ts';
Expand Down Expand Up @@ -333,6 +340,11 @@ export interface BuildProjectResult {
readonly projectContext: ProjectContext;
}

export interface PrepackResult {
readonly build: BuildProjectResult;
readonly pack: PackOutput;
}

export interface ArtifactOperationOptions extends ProjectOptions {
readonly artifact?: string;
}
Expand Down Expand Up @@ -716,6 +728,7 @@ export const build = async (options: BuildOptions): Promise<BuildProjectResult>
let packageBuild: PackageBuildResult | undefined;
if (packageOutputRoot !== undefined) {
packageBuild = await buildPackageOutputs({
...(isInsideOrEqual(prepared.root, output) ? { artifactRoot: output } : {}),
model,
projectRoot: prepared.root,
...(prepared.tools === undefined ? {} : { tools: prepared.tools }),
Expand All @@ -731,6 +744,33 @@ export const build = async (options: BuildOptions): Promise<BuildProjectResult>
});
};

const execFile = promisify(executeFile);

export const prepack = async (options: BuildOptions): Promise<PrepackResult> => {
const result = await build({ ...options, packageOutputs: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Choose a non-overlapping default for prepack

When agent-bundle prepack is invoked without --output, this forwards no output override, so build() selects dist; every project eligible for prepack also has a package build whose output is dist, causing the existing overlap check to throw AB4706 before packing. This makes the documented bare command and typical "prepack": "agent-bundle prepack" lifecycle unusable unless users discover and supply another output path, so prepack should provide a non-overlapping artifact default or make the option required.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5317ce619: package-output builds now use artifact/ as their non-overlapping fallback while ProjectService still honors an explicitly configured output.distPath; the documented bare agent-bundle prepack lifecycle is covered by regression tests and docs. Merged via #319.

if (result.packageBuild === undefined) {
throw new DiagnosticError([{
code: 'AB7010',
message: 'Prepack requires at least one framework-owned package output.',
recovery: 'Declare a package bin or lib entry before running agent-bundle prepack.',
severity: 'error',
}]);
}
const { stdout } = await execFile('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
cwd: resolve(options.root),
});
const pack = packOutputFromJson(stdout);
const diagnostics = await packInventoryDiagnostics({
artifactRoot: result.build.outputRoot,
model: result.model,
packageBuild: result.packageBuild,
packOutput: pack,
projectRoot: options.root,
});
if (diagnostics.length > 0) throw new DiagnosticError(diagnostics);
return deepFreeze({ build: result, pack });
};

/** Every eval refusal reaches a caller as one actionable diagnostic, never a raw service error. */
const evalDiagnostics: Readonly<Record<EvalServiceErrorCode, Readonly<{
readonly code: string;
Expand Down
19 changes: 19 additions & 0 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ export const cliEntryRuntimePath = (): string => {
throw new Error('Unable to locate the agent-bundle/cli-entry runtime module for generated CLI executables.');
};

export const installEntryRuntimeSpecifier = 'agent-bundle/install-entry';

export const installEntryRuntimePath = (): string => runtimeModulePath('install-entry');

export const generatedInstallBinEntrySource = (options: {
readonly artifactRelativeUrl: string;
readonly hosts: readonly ('claude' | 'codex' | 'cursor')[];
readonly name: string;
}): string => [
`import { runGeneratedInstallProcess } from ${JSON.stringify(installEntryRuntimeSpecifier)};`,
'',
'process.exitCode = await runGeneratedInstallProcess(process.argv.slice(2), Object.freeze({',
` artifactRelativeUrl: ${JSON.stringify(options.artifactRelativeUrl)},`,
` hosts: Object.freeze(${stableJson(options.hosts)}),`,
` name: ${JSON.stringify(options.name)},`,
'}));',
'',
].join('\n');

export interface GeneratedCliBinEntryOptions {
readonly commands: readonly CompiledCliCommand[];
readonly plugin: { readonly description?: string; readonly name: string; readonly version: string };
Expand Down
194 changes: 194 additions & 0 deletions packages/agent-bundle/src/build/pack-inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { createHash } from 'node:crypto';
import { lstat, readFile } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';

import type { NormalizedPlugin } from '../core/types.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { deepFreeze } from '../core/freeze.ts';
import { installSurfaceRequirements } from '../install/surface.ts';
import { artifactManifestName } from './emit.ts';
import { parseArtifactManifest } from './manifest.ts';
import type { PackageBuildResult } from './package-build.ts';

export interface PackOutputFile {
readonly path: string;
}

export interface PackOutput {
readonly filename: string;
readonly files: readonly PackOutputFile[];
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);

export const packOutputFromJson = (stdout: string): PackOutput => {
const parsed: unknown = JSON.parse(stdout);
const entries = Array.isArray(parsed)
? parsed
: isRecord(parsed)
? Object.values(parsed)
: undefined;
if (entries === undefined) {
throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.');
}
if (entries.length !== 1) {
throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`);
}
const [entry] = entries;
if (!isRecord(entry) || typeof entry.filename !== 'string' || !Array.isArray(entry.files)) {
throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.');
}
const files = entry.files.map((file) => {
if (!isRecord(file) || typeof file.path !== 'string') {
throw new TypeError('npm pack --json returned an invalid file entry.');
}
return Object.freeze({ path: file.path });
});
return Object.freeze({ filename: entry.filename, files: Object.freeze(files) });
};

const toPosixRelative = (root: string, path: string): string =>
relative(resolve(root), resolve(path)).replaceAll('\\', '/');

const exists = async (path: string): Promise<boolean> => {
try {
await lstat(path);
return true;
} catch (error) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
};

const jsonRecord = async (path: string): Promise<Readonly<Record<string, unknown>>> => {
const value: unknown = JSON.parse(await readFile(path, 'utf8'));
if (!isRecord(value)) throw new TypeError(`Expected a JSON object at ${JSON.stringify(path)}.`);
return value;
};

const hostManifestPaths = (target: string): readonly string[] => {
switch (target) {
case 'claude':
return Object.freeze(['.claude-plugin/plugin.json']);
case 'codex':
return Object.freeze(['.codex-plugin/plugin.json']);
case 'cursor':
return Object.freeze(['.cursor-plugin/plugin.json']);
case 'plugin':
return Object.freeze([
'.claude-plugin/plugin.json',
'.codex-plugin/plugin.json',
'.cursor-plugin/plugin.json',
]);
case 'portable':
return Object.freeze(['plugin.json']);
default:
return Object.freeze([]);
}
};

const binEntries = (value: unknown): readonly [string, string][] => {
if (typeof value === 'string') return Object.freeze([['bin', value] as const]);
if (!isRecord(value)) return Object.freeze([]);
return Object.freeze(Object.entries(value)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
.sort(([left], [right]) => left.localeCompare(right)));
};

const diagnostic = (code: string, message: string, recovery: string): Diagnostic => Object.freeze({
code,
message,
recovery,
severity: 'error',
});

export const packInventoryDiagnostics = async (options: {
readonly artifactRoot: string;
readonly model: NormalizedPlugin;
readonly packageBuild: PackageBuildResult;
readonly packOutput: PackOutput;
readonly projectRoot: string;
}): Promise<readonly Diagnostic[]> => {
const projectRoot = resolve(options.projectRoot);
const artifactRoot = resolve(options.artifactRoot);
const artifactPrefix = toPosixRelative(projectRoot, artifactRoot);
const packagePrefix = toPosixRelative(projectRoot, options.packageBuild.outputRoot);
const manifestPath = join(artifactRoot, artifactManifestName);
const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8'));
const packageDocument = await jsonRecord(join(projectRoot, 'package.json'));
const packed = new Set(options.packOutput.files.map((file) => file.path.replace(/^\.\//u, '')));
const expected = new Set<string>([
...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`),
`${artifactPrefix}/${artifactManifestName}`,
...manifest.files.map((file) => `${artifactPrefix}/${file.path}`),
...manifest.targets.flatMap((target) =>
installSurfaceRequirements(target.name).map((path) => `${artifactPrefix}/${target.name}/${path}`)),
]);
if (await exists(join(projectRoot, 'README.md'))) expected.add('README.md');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the README even when it is absent

If a publishable project has no README.md, this conditional omits it from the expected inventory, allowing prepack to succeed without the README that AB7010 and the documented inventory gate say is required. Add README.md unconditionally so a missing source README is diagnosed just like one omitted from the packed tarball.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5317ce619: README.md is now unconditionally part of the expected packed inventory, so a missing source README produces AB7010. The stricter gate and packed fixtures are covered by regression tests. Merged via #319.


const diagnostics: Diagnostic[] = [];
const missing = [...expected].filter((path) => !packed.has(path)).sort((left, right) => left.localeCompare(right));
if (missing.length > 0) {
diagnostics.push(diagnostic(
'AB7010',
`npm pack omits expected files: ${missing.map((path) => JSON.stringify(path)).join(', ')}.`,
'Add the exact paths (including dist and the artifact directory) to the package.json "files" allowlist.',
));
}

const stale: string[] = [];
for (const file of manifest.files) {
const bytes = await readFile(join(artifactRoot, file.path));
if (createHash('sha256').update(bytes).digest('hex') !== file.sha256) stale.push(`${artifactPrefix}/${file.path}`);
}
if (stale.length > 0) {
diagnostics.push(diagnostic(
'AB7011',
`Artifact files no longer match their manifest hashes: ${stale.sort().map((path) => JSON.stringify(path)).join(', ')}.`,
'Run agent-bundle prepack again without modifying generated artifacts.',
));
}

const invalidBins = binEntries(packageDocument.bin)
.filter(([, target]) => {
const normalized = target.replace(/^\.\//u, '');
return !normalized.startsWith(`${packagePrefix}/`) || normalized.startsWith('src/') || !packed.has(normalized);
});
if (invalidBins.length > 0) {
diagnostics.push(diagnostic(
'AB7012',
`package.json bins must name packed dist outputs: ${invalidBins.map(([name, target]) =>
`${JSON.stringify(name)} -> ${JSON.stringify(target)}`).join(', ')}.`,
'Point every package.json bin value at its generated file under dist/bin and include that file in "files".',
));
}

const versions: Array<readonly [string, unknown]> = [
['package.json', packageDocument.version],
['normalized plugin', options.model.metadata.version],
['artifact provenance', manifest.project.packageVersion],
];
for (const target of manifest.targets) {
for (const path of hostManifestPaths(target.name)) {
const absolute = join(artifactRoot, target.name, path);
if (await exists(absolute)) {
versions.push([`${target.name}/${path}`, (await jsonRecord(absolute)).version]);
}
}
}
const expectedVersion = options.model.metadata.version;
const disagreements = versions
.filter(([, version]) => version !== expectedVersion)
.map(([source, version]) => `${source}=${JSON.stringify(version)}`)
.sort((left, right) => left.localeCompare(right));
if (disagreements.length > 0) {
diagnostics.push(diagnostic(
'AB7013',
`Release versions disagree with normalized plugin version ${JSON.stringify(expectedVersion)}: ${disagreements.join(', ')}.`,
'Set package.json, plugin metadata, generated host manifests, and artifact provenance to one semantic version.',
));
}

return deepFreeze(diagnostics.sort((left, right) => left.code.localeCompare(right.code)));
};
Loading
Loading