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
6 changes: 6 additions & 0 deletions .changeset/scaffold-package-installers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"create-agent-bundle": minor
---

Adopt framework-generated package installers and prepack inventory validation
in the publishable CLI and MCP scaffold templates.
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
6 changes: 5 additions & 1 deletion docs/preview-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
Nothing is published to npm yet, deliberately: the current package names are
placeholders, and npm publishing is deferred until the final name is chosen
(it will then use [npm package provenance](https://docs.npmjs.com/generating-provenance-statements);
the manifests and release workflow are already wired for it). Until then
the publish step exports `NPM_CONFIG_PROVENANCE=true` and runs the packed
release gates before `changeset publish`). Before enabling that path, the
release owner must resolve the repository-wide `"access": "restricted"`
policy for `agent-bundle`, which does not currently override it with
`publishConfig.access`. Until then
pkg.pr.new is the release channel. Every CI package-preview run publishes real,
installable tarballs of all three publishable workspace packages to [pkg.pr.new](https://pkg.pr.new)
— a free continuous-release registry keyed by commit SHA and pull request.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"test:host-install:session:claude": "pnpm build && AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION=1 pnpm test:host-install:session",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "pnpm build && changeset publish",
"release": "pnpm check:release && changeset publish",
"preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/create-agent-bundle'",
"pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json",
"audit:release": "pnpm lint:package && attw --pack --profile esm-only packages/agent-bundle && node scripts/audit-packed-release.mjs",
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,8 @@ then Hooks and Scripts, then the interactive MCP App.

Run the complete local delivery gate with `pnpm check && pnpm check:release`.
`pnpm check:release` is release-only: its exact package-script components are
`pnpm pack:dry-run`, `pnpm audit:release`, and `pnpm test:packed`, and it does not replace
`pnpm check`.
`pnpm pack:dry-run`, `pnpm audit:release`, and `pnpm test:packed:release`, and it does not replace
`pnpm check`. `pnpm release` runs that release gate before `changeset publish`.
Native Claude/Codex smokes stay intentionally opt-in and skipped in ordinary CI.
npm publishing is deferred until the release owner picks the final package name/scope and
license; pkg.pr.new previews are the interim channel, and the first npm release will use npm
Expand Down
6 changes: 6 additions & 0 deletions packages/create-agent-bundle/templates/cli-tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ npm run check # validate + build + typecheck + test

# after a build
node dist/bin/my-agent-plugin.js World

# after publishing/installing the package
npx my-agent-plugin-install install cursor

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 Document a host built by the default scaffold

The template still targets only portable, codex, and claude, and the generated installer validates its arguments against exactly those built install hosts, so this new cursor command always exits with “Cannot install host ...; built hosts: claude, codex” for a default scaffold. The MCP README repeats the same invalid example; use claude or codex, or add cursor to the template targets.

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 3c2240169. Both generated template READMEs now demonstrate install claude, which is included in the default target set and is consistent with the installer-bin filtering.

```

Installing the npm package does not mutate any host; run the generated
`my-agent-plugin-install install <host>` command explicitly.

## Layout

- `agent-bundle.config.ts` — the one typed config; the CLI is also declared
Expand Down
5 changes: 4 additions & 1 deletion packages/create-agent-bundle/templates/cli-tool/package_json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
},
"files": [
"dist",
"artifact",
"README.md"
],
"bin": {
"my-agent-plugin": "./dist/bin/my-agent-plugin.js"
"my-agent-plugin": "./dist/bin/my-agent-plugin.js",
"my-agent-plugin-install": "./dist/bin/my-agent-plugin-install.js"

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 Omit installer bins for portable-only scaffolds

When a user selects the documented --targets portable option, planPackageEntries emits no installer because there is no installable host, but this unconditional bin entry still references dist/bin/<plugin>-install.js; agent-bundle prepack consequently raises AB7012 because that file was never generated. The MCP template has the same problem with its unconditional plugin-name installer bin, so both manifests need to omit the installer bin when the selected targets contain no Claude, Codex, Cursor, or unified plugin target.

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 3c2240169. The scaffolder now removes generated installer bin entries when the selected targets contain no installable host. A regression test covers portable-only CLI and MCP scaffolds while preserving the CLI package bin.

},
"exports": {
".": {
Expand All @@ -23,6 +25,7 @@
"build": "agent-bundle build --json --output artifact",
"check": "npm run validate && npm run build && npm run typecheck && npm run test",
"dev": "agent-bundle dev",
"prepack": "agent-bundle prepack --json --output artifact",
"test": "rstest tests",
"typecheck": "tsc -p tsconfig.json --noEmit",
"validate": "agent-bundle validate --json"
Expand Down
9 changes: 8 additions & 1 deletion packages/create-agent-bundle/templates/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@ npm run test # plain module tests
npm run test:routes # route-unit pool
npm run test:projection # in-memory MCP projection pool
npx agent-bundle mcp list --server status --target portable --artifact artifact

# after publishing/removing "private" and installing the package
npx my-agent-plugin install cursor
```

Installing the npm package does not mutate any host; run the generated
`my-agent-plugin install <host>` command explicitly.

## Layout

- `agent-bundle.config.ts` — plugin identity, targets, and project policy.
- `src/mcp/status/tools/report-status.tsx` — the complete MCP tool route.
- `src/scripts/check-status.ts` — an artifact script with a generated process envelope.
- `src/status.ts` — shared domain logic covered by `tests/`.
- `src/status.ts` — shared domain logic covered by `tests/` and published as
the package library export.
- `rstest.route-unit.config.ts`, `rstest.projection.config.ts` — the two
framework-generated test pools (see below).

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from 'agent-bundle';

export default defineConfig({
lib: './src/status.ts',
plugin: {
description: 'A stdio MCP server plugin scaffolded from the mcp-server template.',
name: 'my-agent-plugin',
Expand Down
15 changes: 15 additions & 0 deletions packages/create-agent-bundle/templates/mcp-server/package_json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@
"engines": {
"node": ">=22.19.0"
},
"files": [
"dist",
"artifact",
"README.md"
],
"bin": {
"my-agent-plugin": "./dist/bin/my-agent-plugin.js"
},
"exports": {
".": {
"types": "./dist/status.d.ts",
"import": "./dist/status.js"
}
},
"scripts": {
"build": "agent-bundle build --json --output artifact",
"check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection",
"dev": "agent-bundle dev",
"prepack": "agent-bundle prepack --json --output artifact",
"test": "rstest tests --exclude \"tests/{route-unit,projection}/**\"",
"test:projection": "rstest --config rstest.projection.config.ts",
"test:routes": "rstest --config rstest.route-unit.config.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { execFile as executeFile } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';

import { afterAll, expect, it } from '@rstest/core';

import { installedEnvironment, npmInstallArguments } from '../../agent-bundle/tests/support/shared-pack.ts';
import { installedEnvironment, npmInstallArguments, packOutputFromJson } from '../../agent-bundle/tests/support/shared-pack.ts';
import { cleanupScaffoldFixture, expectCleanValidate, npmRun, scaffoldProject } from './support/scaffold-fixture.ts';

const execFile = promisify(executeFile);
Expand All @@ -32,6 +33,7 @@ it.concurrent('scaffolds the mcp-server template and serves the conventional ent

const checked = await npmRun(projectRoot, 'check');
await expectCleanValidate(projectRoot);
await npmRun(projectRoot, 'prepack');

// The template's own harness pools ran inside `check`, and they are asserted
// positively — a silent `check` would also pass if the pools were dropped or
Expand Down Expand Up @@ -86,6 +88,7 @@ it.concurrent('scaffolds the cli-tool template with a framework-built bin, lib,
const checked = await npmRun(projectRoot, 'check');
expect(checked).toContain('tests/cli.test.ts');
await expectCleanValidate(projectRoot);
await npmRun(projectRoot, 'prepack');

// The src/cli.ts convention produced the executable package bin.
const bin = join(projectRoot, 'dist', 'bin', 'greeter.js');
Expand All @@ -105,4 +108,21 @@ it.concurrent('scaffolds the cli-tool template with a framework-built bin, lib,
await expect(execFile(process.execPath, [
join(projectRoot, 'artifact', 'portable', 'scripts', 'greeter.mjs'), 'World',
], { cwd: projectRoot, env: installedEnvironment() })).resolves.toMatchObject({ stdout: 'Hello, World!\n' });

const packDestination = await mkdtemp(join(tmpdir(), 'create-agent-bundle-cli-pack-'));
try {
const { stdout } = await execFile('npm', [
'pack', '--json', '--ignore-scripts', '--pack-destination', packDestination,
], { cwd: projectRoot, env: installedEnvironment() });
// packOutputFromJson handles both npm pack --json shapes (array and
// package-keyed object), unlike a bare array destructure.
const packedPaths = packOutputFromJson(stdout).files.map((file) => file.path);
expect(packedPaths).toContain('artifact/agent-bundle.manifest.json');
expect(packedPaths).toContain('artifact/portable/plugin.json');
expect(packedPaths).toContain('artifact/codex/.codex-plugin/plugin.json');
expect(packedPaths).toContain('artifact/claude/.claude-plugin/plugin.json');
expect(packedPaths).toContain('dist/bin/greeter-install.js');
} finally {
await rm(packDestination, { force: true, recursive: true });
}
}, 600_000);
63 changes: 62 additions & 1 deletion packages/create-agent-bundle/tests/scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,64 @@ describe('scaffold', () => {
}
});

it('scaffolds publishable package fields for templates with package builds', async () => {
const [cliTool, mcpServer] = await Promise.all([
scaffoldTemplate('cli-tool', { pluginName: 'greeter' }),
scaffoldTemplate('mcp-server', { pluginName: 'status-plugin' }),
]);
try {
const cliManifest = JSON.parse(await readFile(join(cliTool.root, 'package.json'), 'utf8')) as {
readonly bin: Record<string, string>;
readonly files: readonly string[];
readonly scripts: Record<string, string>;
};
expect(cliManifest.files).toEqual(['dist', 'artifact', 'README.md']);
expect(cliManifest.bin).toEqual({
greeter: './dist/bin/greeter.js',
'greeter-install': './dist/bin/greeter-install.js',
});
expect(cliManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact');

const mcpManifest = JSON.parse(await readFile(join(mcpServer.root, 'package.json'), 'utf8')) as {
readonly bin: Record<string, string>;
readonly exports: Record<string, { readonly import: string; readonly types: string }>;
readonly files: readonly string[];
readonly private: boolean;
readonly scripts: Record<string, string>;
};
expect(mcpManifest.private).toBe(true);
expect(mcpManifest.files).toEqual(['dist', 'artifact', 'README.md']);
expect(mcpManifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' });
expect(mcpManifest.exports).toEqual({
'.': { types: './dist/status.d.ts', import: './dist/status.js' },
});
expect(mcpManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact');
await expect(readFile(join(mcpServer.root, 'agent-bundle.config.ts'), 'utf8'))
.resolves.toContain("lib: './src/status.ts'");
} finally {
await Promise.all([
rm(cliTool.root, { force: true, recursive: true }),
rm(mcpServer.root, { force: true, recursive: true }),
]);
}
});

it('leaves the skills-only template without package-build packaging fields', async () => {
const { root } = await scaffoldTemplate('minimal');
try {
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as {
readonly bin?: unknown;
readonly files?: unknown;
readonly scripts: Record<string, string>;
};
expect(manifest.bin).toBeUndefined();
expect(manifest.files).toBeUndefined();
expect(manifest.scripts.prepack).toBeUndefined();
} finally {
await rm(root, { force: true, recursive: true });
}
});

for (const template of ['minimal', 'cli-tool'] as const) {
it(`scaffolds the ${template} template without a runtime tarball`, async () => {
const { root } = await scaffoldTemplate(template, { withRuntimeTarball: false });
Expand Down Expand Up @@ -135,7 +193,10 @@ describe('scaffold', () => {
if (files.includes('src/mcp/status/tools/report-status.tsx')) {
expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec));
}
expect(manifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' });
expect(manifest.bin).toEqual({
'status-plugin': './dist/bin/status-plugin.js',
'status-plugin-install': './dist/bin/status-plugin-install.js',
});
const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8');
expect(config).toContain("name: 'status-plugin'");
expect(config).toContain("'status-plugin': './src/cli.ts'");
Expand Down
Loading