From dc70d6708a43fdecd9fc38bebb7c579c82104231 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 05:38:18 +0000 Subject: [PATCH 1/2] feat: create-agent-bundle scaffolding package (RFC #50 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm create agent-bundle` / `npx create-agent-bundle` scaffolds a ready-to-run plugin project from one of three checked-in templates — minimal (skills-only), mcp-server (conventional src/mcp/.ts factory entry plus an artifact script), and cli-tool (src/cli.ts bin convention plus a src/index.ts library export). Prompt-driven (name, template, host targets) with full non-interactive flags (--template, --targets, --package-manager, --no-install, --framework-version); scaffolded projects pin agent-bundle to the pkg.pr.new preview of the same commit the scaffolder shipped from, and validate with zero diagnostics including the AB473x convention nudges. The packed e2e drives the installed scaffolder tarball and each scaffolded project's own check against the real agent-bundle tarball. --- .changeset/create-agent-bundle-scaffolder.md | 16 ++ README.md | 14 +- docs/preview-packages.md | 11 + package.json | 10 +- packages/create-agent-bundle/README.md | 59 ++++ .../bin/create-agent-bundle.js | 7 + packages/create-agent-bundle/package.json | 48 ++++ packages/create-agent-bundle/rslib.config.ts | 30 ++ packages/create-agent-bundle/src/framework.ts | 29 ++ packages/create-agent-bundle/src/index.ts | 137 +++++++++ packages/create-agent-bundle/src/options.ts | 261 ++++++++++++++++++ packages/create-agent-bundle/src/scaffold.ts | 106 +++++++ .../templates/cli-tool/README.md | 37 +++ .../templates/cli-tool/agent-bundle.config.ts | 17 ++ .../templates/cli-tool/gitignore | 4 + .../templates/cli-tool/package_json | 36 +++ .../templates/cli-tool/src/cli.ts | 29 ++ .../templates/cli-tool/src/index.ts | 12 + .../templates/cli-tool/tests/cli.test.ts | 22 ++ .../templates/cli-tool/tsconfig.json | 21 ++ .../templates/mcp-server/README.md | 37 +++ .../mcp-server/agent-bundle.config.ts | 21 ++ .../templates/mcp-server/gitignore | 4 + .../templates/mcp-server/package_json | 26 ++ .../templates/mcp-server/src/mcp/status.ts | 28 ++ .../mcp-server/src/scripts/check-status.ts | 16 ++ .../templates/mcp-server/src/status.ts | 16 ++ .../templates/mcp-server/tests/status.test.ts | 11 + .../templates/mcp-server/tsconfig.json | 21 ++ .../templates/minimal/README.md | 29 ++ .../templates/minimal/agent-bundle.config.ts | 11 + .../templates/minimal/gitignore | 4 + .../templates/minimal/package_json | 24 ++ .../minimal/skills/getting-started/SKILL.md | 31 +++ .../templates/minimal/tests/skill.test.ts | 14 + .../templates/minimal/tsconfig.json | 20 ++ .../tests/framework.test.ts | 24 ++ .../create-agent-bundle/tests/options.test.ts | 155 +++++++++++ .../tests/scaffold-packed.e2e.test.ts | 190 +++++++++++++ .../tests/scaffold.test.ts | 144 ++++++++++ .../create-agent-bundle/tsconfig.build.json | 9 + packages/create-agent-bundle/tsconfig.json | 13 + pnpm-lock.yaml | 15 + rstest.config.ts | 2 + rstest.integration-tests.ts | 11 + rstest.unit.config.ts | 4 +- 46 files changed, 1778 insertions(+), 8 deletions(-) create mode 100644 .changeset/create-agent-bundle-scaffolder.md create mode 100644 packages/create-agent-bundle/README.md create mode 100644 packages/create-agent-bundle/bin/create-agent-bundle.js create mode 100644 packages/create-agent-bundle/package.json create mode 100644 packages/create-agent-bundle/rslib.config.ts create mode 100644 packages/create-agent-bundle/src/framework.ts create mode 100644 packages/create-agent-bundle/src/index.ts create mode 100644 packages/create-agent-bundle/src/options.ts create mode 100644 packages/create-agent-bundle/src/scaffold.ts create mode 100644 packages/create-agent-bundle/templates/cli-tool/README.md create mode 100644 packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts create mode 100644 packages/create-agent-bundle/templates/cli-tool/gitignore create mode 100644 packages/create-agent-bundle/templates/cli-tool/package_json create mode 100644 packages/create-agent-bundle/templates/cli-tool/src/cli.ts create mode 100644 packages/create-agent-bundle/templates/cli-tool/src/index.ts create mode 100644 packages/create-agent-bundle/templates/cli-tool/tests/cli.test.ts create mode 100644 packages/create-agent-bundle/templates/cli-tool/tsconfig.json create mode 100644 packages/create-agent-bundle/templates/mcp-server/README.md create mode 100644 packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts create mode 100644 packages/create-agent-bundle/templates/mcp-server/gitignore create mode 100644 packages/create-agent-bundle/templates/mcp-server/package_json create mode 100644 packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts create mode 100644 packages/create-agent-bundle/templates/mcp-server/src/scripts/check-status.ts create mode 100644 packages/create-agent-bundle/templates/mcp-server/src/status.ts create mode 100644 packages/create-agent-bundle/templates/mcp-server/tests/status.test.ts create mode 100644 packages/create-agent-bundle/templates/mcp-server/tsconfig.json create mode 100644 packages/create-agent-bundle/templates/minimal/README.md create mode 100644 packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts create mode 100644 packages/create-agent-bundle/templates/minimal/gitignore create mode 100644 packages/create-agent-bundle/templates/minimal/package_json create mode 100644 packages/create-agent-bundle/templates/minimal/skills/getting-started/SKILL.md create mode 100644 packages/create-agent-bundle/templates/minimal/tests/skill.test.ts create mode 100644 packages/create-agent-bundle/templates/minimal/tsconfig.json create mode 100644 packages/create-agent-bundle/tests/framework.test.ts create mode 100644 packages/create-agent-bundle/tests/options.test.ts create mode 100644 packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts create mode 100644 packages/create-agent-bundle/tests/scaffold.test.ts create mode 100644 packages/create-agent-bundle/tsconfig.build.json create mode 100644 packages/create-agent-bundle/tsconfig.json diff --git a/.changeset/create-agent-bundle-scaffolder.md b/.changeset/create-agent-bundle-scaffolder.md new file mode 100644 index 000000000..661b58f57 --- /dev/null +++ b/.changeset/create-agent-bundle-scaffolder.md @@ -0,0 +1,16 @@ +--- +"create-agent-bundle": minor +--- + +New package: the `create-agent-bundle` scaffolder (RFC #50 Phase 3). +`npm create agent-bundle` / `npx create-agent-bundle` emits a ready-to-run +plugin project from one of three checked-in templates — `minimal` +(skills-only), `mcp-server` (one conventional `src/mcp/.ts` +factory entry plus an artifact script), and `cli-tool` (the `src/cli.ts` bin +convention plus a `src/index.ts` library export). Interactive prompts cover +name, template, and host targets, with full non-interactive flags +(`--template`, `--targets`, `--package-manager`, `--no-install`, +`--framework-version`). Scaffolded projects pin `agent-bundle` to the +pkg.pr.new preview of the same commit the scaffolder shipped from, carry a +`check` gate (validate + build + typecheck + test), and validate with zero +diagnostics, including the `AB473x` convention nudges. diff --git a/README.md b/README.md index 0a88dc37f..f57889115 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,19 @@ Use a PR number or the SHA of a commit whose package-preview run succeeded (ever ## Quick start -Describe the plugin in `agent-bundle.config.ts` at the project root: +The fastest start is the scaffolder — it prompts for a name, a template +(`minimal`, `mcp-server`, or `cli-tool`), and the host targets, then emits a +project that already passes its own `check`: + +```sh +npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@ my-plugin +``` + +(`npm create agent-bundle` once npm releases exist. See the +[create-agent-bundle README](packages/create-agent-bundle/README.md) for +templates and flags.) + +Or describe the plugin by hand in `agent-bundle.config.ts` at the project root: ```ts import { defineConfig } from 'agent-bundle/config'; diff --git a/docs/preview-packages.md b/docs/preview-packages.md index ec142f2a8..073ef20f2 100644 --- a/docs/preview-packages.md +++ b/docs/preview-packages.md @@ -18,6 +18,17 @@ npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@1 npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/rsc-runtime@1 ``` +The `create-agent-bundle` scaffolder is published to the same channel and is +meant to be run rather than installed: + +```sh +npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@ my-plugin +``` + +A scaffolded project pins `agent-bundle` to the preview of the same commit +the scaffolder came from, so both sides of the pairing rule below hold +automatically. + `@1` resolves to the last preview published for PR #1 — commit `5685521` at the time of its merge, which is the state that landed on `main`. diff --git a/package.json b/package.json index 73fa4590f..f3ef963bd 100644 --- a/package.json +++ b/package.json @@ -8,28 +8,28 @@ }, "packageManager": "pnpm@11.23.0", "scripts": { - "build": "pnpm --filter agent-bundle build && pnpm --filter @agent-bundle/rsc-runtime build", - "lint:package": "publint packages/agent-bundle", + "build": "pnpm --filter agent-bundle build && pnpm --filter @agent-bundle/rsc-runtime build && pnpm --filter create-agent-bundle build", + "lint:package": "publint packages/agent-bundle && publint packages/create-agent-bundle", "test": "pnpm test:unit && pnpm test:integration", "test:unit": "rstest --config rstest.unit.config.ts", "test:integration": "pnpm build && pnpm test:integration:run", "test:integration:run": "AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.integration.config.ts", "test:watch": "rstest --config rstest.config.ts --watch", "lint": "rslint .", - "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json", + "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json", "check": "pnpm build && pnpm test:unit && pnpm test:integration:run && pnpm lint && pnpm typecheck", "docs:runtime-topology": "node scripts/rsc-runtime-topology.mjs --root . --output docs/architecture/rsc-runtime-workbench.md", "eval:spot": "pnpm build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec rstest run tests/micro-eval.spot.test.ts --config rstest.config.ts", "check:runtime-topology": "node scripts/rsc-runtime-topology.mjs --root . --output docs/architecture/rsc-runtime-workbench.md --check", "test:examples:browser": "rstest --config rstest.config.ts packages/workbench/tests/examples-real.e2e.test.ts", - "test:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/release-audit.test.ts packages/agent-bundle/tests/packed-consumer.test.ts packages/agent-bundle/tests/dev-workbench-packaging.test.ts packages/agent-bundle/tests/public-api-packed.test.ts packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts packages/agent-bundle/tests/packed-native-smoke.test.ts packages/workbench/tests/packed-release.e2e.test.ts", + "test:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/release-audit.test.ts packages/agent-bundle/tests/packed-consumer.test.ts packages/agent-bundle/tests/dev-workbench-packaging.test.ts packages/agent-bundle/tests/public-api-packed.test.ts packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts packages/agent-bundle/tests/packed-native-smoke.test.ts packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts packages/workbench/tests/packed-release.e2e.test.ts", "test:packed:native": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-native-smoke.test.ts", "test:packed:native:claude": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CLAUDE_SMOKE=1 pnpm test:packed:native", "test:packed:native:codex": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CODEX_SMOKE=1 pnpm test:packed:native", "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm build && changeset publish", - "preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime'", + "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", "check:release": "pnpm pack:dry-run && pnpm audit:release && pnpm test:packed", diff --git a/packages/create-agent-bundle/README.md b/packages/create-agent-bundle/README.md new file mode 100644 index 000000000..4a4e43aa2 --- /dev/null +++ b/packages/create-agent-bundle/README.md @@ -0,0 +1,59 @@ +# create-agent-bundle + +Scaffold a new [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle) +plugin project from a checked-in template: one `agent-bundle.config.ts`, the +entry-file conventions, a passing test, and a delivery gate, ready to run. + +```sh +npm create agent-bundle@latest my-plugin +# or +npx create-agent-bundle my-plugin --template mcp-server +``` + +Until the first npm release is cut, install the scaffolder from the +[pkg.pr.new preview channel](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md) +instead of the npm registry: + +```sh +npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@ my-plugin +``` + +Interactive runs prompt for the project name, the template, and the host +targets. A run that names both a directory and a template is treated as +scripted and asks nothing — the remaining values fall back to their defaults. + +## Options + +| Flag | Meaning | +| --- | --- | +| `-d, --dir ` | Project directory (also the first positional argument). `foo/bar` scaffolds into `foo/bar` and names the package `bar`; `@scope/name` keeps the scoped package name. | +| `-t, --template ` | `minimal`, `mcp-server`, or `cli-tool`. | +| `--targets ` | Comma-separated host targets: `portable`, `claude`, `codex`, `cursor`, `plugin`. Default: `portable,codex,claude`. | +| `--package-manager ` | `npm`, `pnpm`, `yarn`, or `bun`. Default: detected from the invoking client. | +| `--no-install` | Skip installing dependencies after scaffolding. | +| `--framework-version ` | Pin the project's `agent-bundle` dependency to this spec (a version, a tarball path, or a URL). | +| `-h, --help` | Show usage. | + +## Templates + +| Template | What you get | +| --- | --- | +| `minimal` | A skills-only plugin: one Skill directory and nothing else. | +| `mcp-server` | A stdio MCP server through the `src/mcp/.ts` convention (factory export, framework lifecycle shell) plus one artifact script. | +| `cli-tool` | An installable CLI through the `src/cli.ts` bin convention plus a `src/index.ts` library export with declarations. | + +Every template ships a `check` script (validate + build + typecheck + test) +and validates with zero diagnostics — including the `AB473x` migration +nudges, because the templates are written against the entry conventions from +the start. + +## The framework dependency + +Scaffolded projects pin `agent-bundle` to an exact +[pkg.pr.new](https://pkg.pr.new) preview tarball. Without +`--framework-version`, the pin is derived from this scaffolder's own preview +version: pkg.pr.new publishes every workspace package of one commit under the +same `-preview-` suffix, so the scaffolder and the framework it pins +always come from the same commit. A non-preview build of the scaffolder has +no derivable default (the `agent-bundle` name on npm currently belongs to an +unrelated project) and requires `--framework-version` explicitly. diff --git a/packages/create-agent-bundle/bin/create-agent-bundle.js b/packages/create-agent-bundle/bin/create-agent-bundle.js new file mode 100644 index 000000000..daef05de7 --- /dev/null +++ b/packages/create-agent-bundle/bin/create-agent-bundle.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import process from 'node:process'; + +import { runCli } from '../dist/index.js'; + +process.exitCode = await runCli(process.argv.slice(2)); diff --git a/packages/create-agent-bundle/package.json b/packages/create-agent-bundle/package.json new file mode 100644 index 000000000..db486eaa5 --- /dev/null +++ b/packages/create-agent-bundle/package.json @@ -0,0 +1,48 @@ +{ + "name": "create-agent-bundle", + "version": "0.0.0", + "description": "Scaffold a new agent-bundle plugin project from a checked-in template.", + "license": "MIT", + "keywords": [ + "agent-bundle", + "create", + "scaffold", + "cli" + ], + "homepage": "https://github.com/ScriptedAlchemy/agent-bundle#readme", + "bugs": { + "url": "https://github.com/ScriptedAlchemy/agent-bundle/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ScriptedAlchemy/agent-bundle.git", + "directory": "packages/create-agent-bundle" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "files": [ + "bin", + "dist", + "templates", + "README.md" + ], + "bin": { + "create-agent-bundle": "./bin/create-agent-bundle.js" + }, + "scripts": { + "build": "rslib build", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@clack/prompts": "1.7.0", + "@rslib/core": "0.23.2", + "@rstest/core": "0.11.10", + "@types/node": "26.4.0" + } +} diff --git a/packages/create-agent-bundle/rslib.config.ts b/packages/create-agent-bundle/rslib.config.ts new file mode 100644 index 000000000..c8334305a --- /dev/null +++ b/packages/create-agent-bundle/rslib.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from '@rslib/core'; + +/** + * Single self-contained ESM bundle: `@clack/prompts` is a devDependency so + * Rslib inlines it and the published package has zero runtime dependencies, + * the same shape `create-rstack` ships (its npm tarball declares no + * dependencies and bundles the prompt toolkit). + */ +export default defineConfig({ + lib: [ + { + bundle: true, + dts: false, + format: 'esm', + syntax: 'es2022', + }, + ], + output: { + cleanDistPath: true, + filenameHash: false, + target: 'node', + }, + root: import.meta.dirname, + source: { + entry: { + index: './src/index.ts', + }, + tsconfigPath: './tsconfig.build.json', + }, +}); diff --git a/packages/create-agent-bundle/src/framework.ts b/packages/create-agent-bundle/src/framework.ts new file mode 100644 index 000000000..ffac71cf2 --- /dev/null +++ b/packages/create-agent-bundle/src/framework.ts @@ -0,0 +1,29 @@ +import { UsageError } from './options.ts'; + +const previewPattern = /-preview-([0-9a-f]{7,40})$/u; + +export const previewFrameworkSpec = (sha: string): string => + `https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@${sha}`; + +/** + * Resolve the dependency spec the scaffolded project pins `agent-bundle` to. + * + * `--framework-version` wins verbatim (a version, a `file:` tarball, or any + * URL npm accepts). Otherwise the sha is derived from this scaffolder's own + * preview version: pkg.pr.new publishes every workspace package of one + * commit under the same `-preview-` string, so the paired + * `agent-bundle` preview of the very build that shipped this scaffolder is + * always the right default. There is no derivable default outside a preview + * build — the `agent-bundle` name on npm belongs to an unrelated project, so + * falling back to a semver range would install the wrong package. + */ +export const resolveFrameworkSpec = (ownVersion: string, flag: string | undefined): string => { + if (flag !== undefined && flag.trim() !== '') return flag.trim(); + const preview = previewPattern.exec(ownVersion); + if (preview !== null) return previewFrameworkSpec(preview[1]!); + throw new UsageError( + `This build of create-agent-bundle (${ownVersion}) is not a pkg.pr.new preview, so it cannot derive ` + + 'a default agent-bundle version. Pass --framework-version — for example ' + + '--framework-version https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@.', + ); +}; diff --git a/packages/create-agent-bundle/src/index.ts b/packages/create-agent-bundle/src/index.ts new file mode 100644 index 000000000..f716bd8cc --- /dev/null +++ b/packages/create-agent-bundle/src/index.ts @@ -0,0 +1,137 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cancel, intro, isCancel, log, multiselect, note, outro, select, text } from '@clack/prompts'; + +import { resolveFrameworkSpec } from './framework.ts'; +import { + UsageError, + helpText, + parseFlags, + resolveOptions, + type ParsedFlags, + type Prompter, + type ResolvedOptions, +} from './options.ts'; +import { assertScaffoldTarget, scaffold } from './scaffold.ts'; + +/** Cancelled prompts end the run quietly with exit code 0, as create-rstack does. */ +const checkCancel = (value: T | symbol): T => { + if (isCancel(value)) { + cancel('Operation cancelled.'); + process.exit(0); + } + return value as T; +}; + +const clackPrompter: Prompter = { + multiselect: async (options) => checkCancel(await multiselect({ + initialValues: [...options.initialValues], + message: options.message, + options: options.options.map((option) => ({ ...option })), + required: false, + })), + select: async (options) => checkCancel(await select({ + message: options.message, + options: options.options.map((option) => ({ ...option })), + })), + text: async (options) => checkCancel(await text({ + defaultValue: options.defaultValue, + message: options.message, + placeholder: options.placeholder, + })), +}; + +/** + * The version must be read from disk at run time, not inlined at build time: + * pkg.pr.new rewrites the manifest version to `-preview-` when + * it packs the preview tarball, and that suffix is what pairs the scaffolded + * project with the matching agent-bundle preview. + */ +const ownVersion = async (): Promise => { + const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { + readonly version: string; + }; + return manifest.version; +}; + +const runInstall = async (options: ResolvedOptions, targetDirectory: string): Promise => { + log.step(`Installing dependencies with ${options.packageManager}...`); + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(options.packageManager, ['install'], { cwd: targetDirectory, stdio: 'inherit' }); + child.on('error', rejectPromise); + child.on('close', (code) => { resolvePromise(code ?? 1); }); + }); +}; + +const nextSteps = (options: ResolvedOptions): string => { + const steps = [`cd ${options.targetDir}`]; + if (!options.install) steps.push(`${options.packageManager} install`); + steps.push(`${options.packageManager} run dev`, `${options.packageManager} run check`); + return steps.map((step, index) => `${index + 1}. ${step}`).join('\n'); +}; + +export const runCli = async (argv: readonly string[]): Promise<0 | 1 | 2> => { + let flags: ParsedFlags; + try { + flags = parseFlags(argv); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`${error.message}\n\n${helpText}`); + return 2; + } + throw error; + } + if (flags.help) { + process.stdout.write(helpText); + return 0; + } + + const version = await ownVersion(); + intro(`create-agent-bundle ${version}`); + try { + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; + const options = await resolveOptions(flags, { + interactive, + prompter: clackPrompter, + userAgent: process.env['npm_config_user_agent'], + }); + const frameworkSpec = resolveFrameworkSpec(version, options.frameworkVersion); + const targetDirectory = resolve(process.cwd(), options.targetDir); + await assertScaffoldTarget(targetDirectory, options.targetDir); + + const templateRoot = fileURLToPath(new URL(`../templates/${options.template}`, import.meta.url)); + const files = await scaffold({ + frameworkSpec, + packageName: options.packageName, + pluginName: options.pluginName, + targetDirectory, + targets: options.targets, + templateRoot, + }); + log.success(`Scaffolded the ${options.template} template into ${options.targetDir} (${files.length} files).`); + log.info(`agent-bundle is pinned to ${frameworkSpec} — see docs/preview-packages.md in the repository for the preview channel.`); + + if (options.install) { + const exitCode = await runInstall(options, targetDirectory); + if (exitCode !== 0) { + log.warn(`${options.packageManager} install failed (exit code ${exitCode}). Run "${options.packageManager} install" in ${options.targetDir} manually.`); + outro('Scaffolded, but dependencies are not installed.'); + return 1; + } + } + + note(nextSteps(options), 'Next steps'); + outro('Project ready.'); + return 0; + } catch (error) { + if (error instanceof UsageError) { + cancel(error.message); + return 2; + } + cancel(error instanceof Error ? error.message : String(error)); + return 1; + } +}; diff --git a/packages/create-agent-bundle/src/options.ts b/packages/create-agent-bundle/src/options.ts new file mode 100644 index 000000000..e0aa7fc84 --- /dev/null +++ b/packages/create-agent-bundle/src/options.ts @@ -0,0 +1,261 @@ +import { basename } from 'node:path'; +import { parseArgs } from 'node:util'; + +export const templateNames = ['minimal', 'mcp-server', 'cli-tool'] as const; +export type TemplateName = (typeof templateNames)[number]; + +export const targetNames = ['portable', 'claude', 'codex', 'cursor', 'plugin'] as const; +export type TargetName = (typeof targetNames)[number]; + +/** The target set every in-repo example ships with. */ +export const defaultTargets: readonly TargetName[] = ['portable', 'codex', 'claude']; + +export const packageManagers = ['npm', 'pnpm', 'yarn', 'bun'] as const; +export type PackageManager = (typeof packageManagers)[number]; + +/** A user-input problem: reported with usage help and exit code 2, never a stack. */ +export class UsageError extends Error {} + +export interface ParsedFlags { + readonly directory?: string; + readonly frameworkVersion?: string; + readonly help: boolean; + readonly install: boolean; + readonly packageManager?: PackageManager; + readonly targets?: readonly TargetName[]; + readonly template?: TemplateName; +} + +export const templateSummaries: Readonly> = { + 'cli-tool': 'an installable CLI: src/cli.ts bin convention plus a src/index.ts library export', + 'mcp-server': 'a stdio MCP server: one conventional src/mcp/.ts entry plus a script', + minimal: 'a skills-only plugin: one Skill and nothing else', +}; + +export const helpText = `Usage: create-agent-bundle [dir] [options] + +Scaffold a new agent-bundle plugin project. + +Options: + -d, --dir create the project in this directory + -t, --template project template: ${templateNames.join(', ')} + --targets comma-separated host targets: ${targetNames.join(', ')} + (default: ${defaultTargets.join(',')}) + --package-manager ${packageManagers.join(', ')} (default: detected from the invoking client) + --no-install skip installing dependencies after scaffolding + --framework-version agent-bundle dependency spec to pin (version, tarball path, or URL); + defaults to the pkg.pr.new preview paired with this scaffolder build + -h, --help show this help + +Templates: +${templateNames.map((name) => ` ${name.padEnd(12)}${templateSummaries[name]}`).join('\n')} +`; + +const isTemplateName = (value: string): value is TemplateName => + (templateNames as readonly string[]).includes(value); + +const isTargetName = (value: string): value is TargetName => + (targetNames as readonly string[]).includes(value); + +const isPackageManager = (value: string): value is PackageManager => + (packageManagers as readonly string[]).includes(value); + +const parseTargets = (raw: string): readonly TargetName[] => { + const entries = raw.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''); + if (entries.length === 0) { + throw new UsageError(`--targets needs at least one of: ${targetNames.join(', ')}.`); + } + const targets: TargetName[] = []; + for (const entry of entries) { + if (!isTargetName(entry)) { + throw new UsageError(`Unknown target "${entry}". Valid targets: ${targetNames.join(', ')}.`); + } + if (!targets.includes(entry)) targets.push(entry); + } + return targets; +}; + +export const parseFlags = (argv: readonly string[]): ParsedFlags => { + let parsed: ReturnType>; + try { + parsed = parseArgs({ + allowPositionals: true, + args: [...argv], + options: { + dir: { short: 'd', type: 'string' }, + 'framework-version': { type: 'string' }, + help: { short: 'h', type: 'boolean' }, + 'no-install': { type: 'boolean' }, + 'package-manager': { type: 'string' }, + targets: { type: 'string' }, + template: { short: 't', type: 'string' }, + }, + }); + } catch (error) { + throw new UsageError(error instanceof Error ? error.message : String(error)); + } + if (parsed.positionals.length > 1) { + throw new UsageError('Pass at most one directory argument.'); + } + + const directory = parsed.values.dir ?? parsed.positionals[0]; + const template = parsed.values.template; + if (template !== undefined && !isTemplateName(template)) { + throw new UsageError(`Unknown template "${template}". Valid templates: ${templateNames.join(', ')}.`); + } + const packageManager = parsed.values['package-manager']; + if (packageManager !== undefined && !isPackageManager(packageManager)) { + throw new UsageError(`Unknown package manager "${packageManager}". Valid values: ${packageManagers.join(', ')}.`); + } + + return { + ...(directory === undefined ? {} : { directory }), + ...(parsed.values['framework-version'] === undefined ? {} : { frameworkVersion: parsed.values['framework-version'] }), + help: parsed.values.help === true, + install: parsed.values['no-install'] !== true, + ...(packageManager === undefined ? {} : { packageManager }), + ...(parsed.values.targets === undefined ? {} : { targets: parseTargets(parsed.values.targets) }), + ...(template === undefined ? {} : { template }), + }; +}; + +export interface ProjectName { + readonly packageName: string; + readonly pluginName: string; + readonly targetDir: string; +} + +/** + * `create-rstack` name semantics: `foo/bar` scaffolds into `/foo/bar` + * and names the package `bar`; `@scope/foo` keeps the full scoped name as + * the package name. The plugin name additionally drops the scope and is + * sanitized to agent-bundle's safe package-output shape so the `src/cli.ts` + * bin convention always applies. + */ +export const formatProjectName = (input: string): ProjectName => { + const formatted = input.trim().replace(/\/+$/u, ''); + const packageName = formatted.startsWith('@') ? formatted : basename(formatted); + return { packageName, pluginName: pluginNameFrom(packageName), targetDir: formatted }; +}; + +const pluginNameFrom = (packageName: string): string => { + const bare = packageName.startsWith('@') + ? packageName.slice(packageName.indexOf('/') + 1) + : packageName; + const cleaned = bare + .replace(/[^a-zA-Z0-9._-]+/gu, '-') + .replace(/^[^a-zA-Z0-9]+/u, '') + .replace(/[^a-zA-Z0-9]+$/u, ''); + return cleaned === '' ? 'my-agent-plugin' : cleaned; +}; + +/** `create-rstack` reads the invoking client from `npm_config_user_agent`. */ +export const detectPackageManager = (userAgent: string | undefined): PackageManager => { + const name = userAgent?.split(' ')[0]?.split('/')[0] ?? ''; + return isPackageManager(name) ? name : 'npm'; +}; + +export interface Prompter { + multiselect(options: { + readonly initialValues: readonly string[]; + readonly message: string; + readonly options: readonly { readonly hint?: string; readonly label: string; readonly value: string }[]; + }): Promise; + select(options: { + readonly message: string; + readonly options: readonly { readonly hint?: string; readonly label: string; readonly value: string }[]; + }): Promise; + text(options: { + readonly defaultValue: string; + readonly message: string; + readonly placeholder: string; + }): Promise; +} + +export interface ResolvedOptions { + readonly frameworkVersion?: string; + readonly install: boolean; + readonly packageManager: PackageManager; + readonly packageName: string; + readonly pluginName: string; + readonly targetDir: string; + readonly targets: readonly TargetName[]; + readonly template: TemplateName; +} + +/** + * Fill missing values with prompts when interactive; fail with a usage error + * otherwise. Like `create-rstack`, a run that names both a directory and a + * template on the command line is treated as scripted and asks nothing — + * remaining values fall back to their defaults. + */ +export const resolveOptions = async ( + flags: ParsedFlags, + context: { readonly interactive: boolean; readonly prompter: Prompter; readonly userAgent: string | undefined }, +): Promise => { + const scripted = flags.directory !== undefined && flags.template !== undefined; + const interactive = context.interactive && !scripted; + + let directory = flags.directory; + if (directory === undefined) { + if (!interactive) { + throw new UsageError('A project directory is required. Pass one as the first argument, e.g. `create-agent-bundle my-plugin`.'); + } + directory = await context.prompter.text({ + defaultValue: 'my-agent-plugin', + message: 'Project name or path', + placeholder: 'my-agent-plugin', + }); + } + if (directory.trim().replace(/\/+$/u, '') === '') { + throw new UsageError('The project directory must not be empty.'); + } + + let template = flags.template; + if (template === undefined) { + if (!interactive) { + throw new UsageError(`A template is required in non-interactive runs. Pass --template <${templateNames.join('|')}>.`); + } + template = await context.prompter.select({ + message: 'Select a template', + options: templateNames.map((name) => ({ hint: templateSummaries[name], label: name, value: name })), + }) as TemplateName; + } + + let targets = flags.targets; + if (targets === undefined) { + if (!interactive) { + targets = defaultTargets; + } else { + const selected = await context.prompter.multiselect({ + initialValues: [...defaultTargets], + message: 'Select host targets (space to toggle, enter to confirm)', + options: targetNames.map((name) => ({ label: name, value: name })), + }); + if (selected.length === 0) { + throw new UsageError('Select at least one host target.'); + } + targets = selected.filter((value): value is TargetName => (targetNames as readonly string[]).includes(value)); + } + } + + return { + ...(flags.frameworkVersion === undefined ? {} : { frameworkVersion: flags.frameworkVersion }), + install: flags.install, + packageManager: flags.packageManager ?? detectPackageManager(context.userAgent), + ...formatProjectName(directory), + targets, + template, + }; +}; diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts new file mode 100644 index 000000000..ecd6d961e --- /dev/null +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -0,0 +1,106 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { UsageError, type TargetName } from './options.ts'; + +/** + * The literal project name every template is written under. Templates stay + * valid, buildable projects as checked in; scaffolding replaces the token in + * every emitted file. + */ +export const placeholderName = 'my-agent-plugin'; + +/** + * The rename table `create-rstack` uses, extended by one entry: npm strips + * `.gitignore` from published tarballs, so templates check the file in + * without the dot, and template manifests are checked in as `package_json` + * so the published scaffolder carries no nested `package.json` (publint + * flags nested manifest fields as ignored by Node.js). Scaffolding restores + * the real names. + */ +const renamedEntries: Readonly> = { + gitignore: '.gitignore', + package_json: 'package.json', +}; + +const defaultTargetsLiteral = "targets: ['portable', 'codex', 'claude']"; + +export interface ScaffoldRequest { + readonly frameworkSpec: string; + readonly packageName: string; + readonly pluginName: string; + readonly targetDirectory: string; + readonly targets: readonly TargetName[]; + readonly templateRoot: string; +} + +/** The target directory must be absent, empty, or hold nothing but `.git`. */ +export const assertScaffoldTarget = async (targetDirectory: string, displayName: string): Promise => { + let entries: readonly string[]; + try { + entries = await readdir(targetDirectory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + if (entries.some((entry) => entry !== '.git')) { + throw new UsageError(`Target directory "${displayName}" is not empty. Choose a new directory or empty it first.`); + } +}; + +interface TemplateManifest { + dependencies?: Record; + devDependencies?: Record; + name?: string; +} + +const rewriteManifest = (contents: string, request: ScaffoldRequest): string => { + const manifest = JSON.parse(contents) as TemplateManifest; + manifest.name = request.packageName; + for (const section of [manifest.dependencies, manifest.devDependencies]) { + if (section === undefined) continue; + for (const [dependency, range] of Object.entries(section)) { + if (range === 'workspace:*') section[dependency] = request.frameworkSpec; + } + } + return `${JSON.stringify(manifest, null, 2)}\n`; +}; + +const rewriteConfigTargets = (contents: string, targets: readonly TargetName[]): string => { + if (!contents.includes(defaultTargetsLiteral)) { + throw new Error(`Template drift: agent-bundle.config.ts no longer contains \`${defaultTargetsLiteral}\`.`); + } + return contents.replace(defaultTargetsLiteral, `targets: [${targets.map((target) => `'${target}'`).join(', ')}]`); +}; + +/** + * Copy one template directory into the target, substituting the placeholder + * project name in every file, rewriting `package.json` (real package name, + * `workspace:*` framework placeholder pinned to the resolved spec) and the + * config's target list. Returns the emitted project-relative paths, sorted. + */ +export const scaffold = async (request: ScaffoldRequest): Promise => { + const emitted: string[] = []; + const copyDirectory = async (from: string, to: string, relative: string): Promise => { + await mkdir(to, { recursive: true }); + for (const entry of await readdir(from, { withFileTypes: true })) { + const name = renamedEntries[entry.name] ?? entry.name; + const source = join(from, entry.name); + const destination = join(to, name); + const relativePath = relative === '' ? name : `${relative}/${name}`; + if (entry.isDirectory()) { + await copyDirectory(source, destination, relativePath); + continue; + } + let contents = (await readFile(source, 'utf8')).replaceAll(placeholderName, request.pluginName); + if (relativePath === 'package.json') contents = rewriteManifest(contents, request); + if (relativePath === 'agent-bundle.config.ts') contents = rewriteConfigTargets(contents, request.targets); + await writeFile(destination, contents); + emitted.push(relativePath); + } + }; + await copyDirectory(request.templateRoot, request.targetDirectory, ''); + // Code-unit order, not localeCompare: the emitted inventory must be stable + // across machines and locales. + return emitted.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +}; diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md new file mode 100644 index 000000000..3fca76565 --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -0,0 +1,37 @@ +# my-agent-plugin + +A command-line tool and library built with +[agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). There is no +second bundler config and no hand-written bin shim: the `src/cli.ts` +convention makes the executable `dist/bin/my-agent-plugin.js`, the +`src/index.ts` convention makes the library export with declarations, and one +`agent-bundle build` produces both alongside the host artifacts. + +## Commands + +```sh +npm run dev # local workbench with live rebuilds +npm run build # dist/ package build + host artifacts in artifact/ +npm run check # validate + build + typecheck + test + +# after a build +node dist/bin/my-agent-plugin.js World +``` + +## Layout + +- `agent-bundle.config.ts` — the one typed config; the CLI is also declared + as a script so it ships inside every host artifact. +- `src/cli.ts` — the whole CLI entry: export `main`, the framework generates + the process envelope and the executable bundle. +- `src/index.ts` — the library export (`dist/index.js` + `dist/index.d.ts`). +- `tests/` — run with `npm run test`. + +## The agent-bundle dependency + +agent-bundle has no npm release yet; this project pins a +[pkg.pr.new](https://pkg.pr.new) preview tarball of it. To move to a newer +preview (or a real release once one exists), change the `agent-bundle` entry +in `devDependencies` — see +[Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md) +for the URL forms. diff --git a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts new file mode 100644 index 000000000..8d97aa0fc --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'agent-bundle'; + +export default defineConfig({ + plugin: { + description: 'A command-line tool scaffolded from the cli-tool template.', + name: 'my-agent-plugin', + version: '0.1.0', + }, + // One CLI bundle, two destinations: `src/cli.ts` is the package bin by + // convention, and declaring it as a script also ships it inside every + // host artifact. `src/index.ts` becomes the library export with + // declarations, also by convention. + scripts: { + 'my-agent-plugin': './src/cli.ts', + }, + targets: ['portable', 'codex', 'claude'], +}); diff --git a/packages/create-agent-bundle/templates/cli-tool/gitignore b/packages/create-agent-bundle/templates/cli-tool/gitignore new file mode 100644 index 000000000..fb774fd70 --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +artifact/ +.agent-bundle/ diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json new file mode 100644 index 000000000..3203b454c --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/package_json @@ -0,0 +1,36 @@ +{ + "name": "my-agent-plugin", + "version": "0.1.0", + "description": "A command-line tool and library built with agent-bundle.", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "files": [ + "dist", + "README.md" + ], + "bin": { + "my-agent-plugin": "./dist/bin/my-agent-plugin.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "agent-bundle build --json --output artifact", + "check": "npm run validate && npm run build && npm run typecheck && npm run test", + "dev": "agent-bundle dev", + "test": "rstest tests", + "typecheck": "tsc -p tsconfig.json --noEmit", + "validate": "agent-bundle validate --json" + }, + "devDependencies": { + "@rstest/core": "0.11.10", + "@types/node": "26.4.0", + "agent-bundle": "workspace:*", + "typescript": "7.0.2" + } +} diff --git a/packages/create-agent-bundle/templates/cli-tool/src/cli.ts b/packages/create-agent-bundle/templates/cli-tool/src/cli.ts new file mode 100644 index 000000000..4a97fb07a --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/src/cli.ts @@ -0,0 +1,29 @@ +import { greet } from './index.js'; + +const usage = 'Usage: my-agent-plugin \n'; + +/** Injectable writer so tests can capture output without a child process. */ +export const runCli = ( + argv: readonly string[], + write: (line: string) => void = (line) => { process.stdout.write(line); }, +): 0 | 2 => { + const [name, ...rest] = argv; + if (name === '--help' || name === '-h') { + write(usage); + return 0; + } + if (name === undefined || rest.length > 0) { + write(usage); + return 2; + } + write(`${greet(name).message}\n`); + return 0; +}; + +/** + * `agent-bundle build` detects the `main` export and generates the process + * envelope around it. The same module is the package bin (`src/cli.ts` + * convention → `dist/bin/my-agent-plugin.js`) and, because the config also + * declares it as a script, an executable inside every host artifact. + */ +export const main = async (argv: readonly string[]): Promise => runCli(argv); diff --git a/packages/create-agent-bundle/templates/cli-tool/src/index.ts b/packages/create-agent-bundle/templates/cli-tool/src/index.ts new file mode 100644 index 000000000..5edd2e855 --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/src/index.ts @@ -0,0 +1,12 @@ +/** The package's library export: emitted to dist/ with declarations by the `src/index.ts` convention. */ + +export interface Greeting { + readonly message: string; + readonly name: string; +} + +export const greet = (name: string): Greeting => { + const trimmed = name.trim(); + if (trimmed === '') throw new Error('A name is required.'); + return { message: `Hello, ${trimmed}!`, name: trimmed }; +}; diff --git a/packages/create-agent-bundle/templates/cli-tool/tests/cli.test.ts b/packages/create-agent-bundle/templates/cli-tool/tests/cli.test.ts new file mode 100644 index 000000000..7c0629973 --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/tests/cli.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from '@rstest/core'; + +import { runCli } from '../src/cli.js'; +import { greet } from '../src/index.js'; + +describe('my-agent-plugin', () => { + it('greets a name and exits zero', () => { + const lines: string[] = []; + expect(runCli(['World'], (line) => lines.push(line))).toBe(0); + expect(lines).toEqual(['Hello, World!\n']); + }); + + it('prints usage and exits 2 without arguments', () => { + const lines: string[] = []; + expect(runCli([], (line) => lines.push(line))).toBe(2); + expect(lines[0]).toContain('Usage:'); + }); + + it('rejects blank names in the library export', () => { + expect(() => greet(' ')).toThrow('A name is required.'); + }); +}); diff --git a/packages/create-agent-bundle/templates/cli-tool/tsconfig.json b/packages/create-agent-bundle/templates/cli-tool/tsconfig.json new file mode 100644 index 000000000..d94375c56 --- /dev/null +++ b/packages/create-agent-bundle/templates/cli-tool/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": [ + "node" + ], + "verbatimModuleSyntax": true + }, + "include": [ + "agent-bundle.config.ts", + "src/**/*.ts", + "tests/**/*.ts" + ] +} diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md new file mode 100644 index 000000000..2fe6edb90 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/README.md @@ -0,0 +1,37 @@ +# my-agent-plugin + +A stdio MCP server plugin built with [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). +The server entry is the convention `src/mcp/status.ts`: it default-exports a +server factory, and the build wraps it in the framework stdio lifecycle shell +(console-to-stderr guard, signal handling, stdin-EOF exit, heartbeat) — no +hand-rolled bootstrap. + +## Commands + +```sh +npm run dev # local workbench with live rebuilds +npm run build # write host artifacts to artifact/ +npm run check # validate + build + typecheck + test + +# after a build: run, list, or invoke the server from the artifact +npx agent-bundle mcp run --server status --target portable --artifact artifact +npx agent-bundle mcp list --server status --target portable --artifact artifact +``` + +## Layout + +- `agent-bundle.config.ts` — declares the `status` server and one script. +- `src/mcp/status.ts` — the conventional stdio entry (a factory export is the + whole file). +- `src/scripts/check-status.ts` — an artifact script; its `main` export gets + the generated process envelope. +- `src/status.ts` — shared domain logic, covered by `tests/`. + +## The agent-bundle dependency + +agent-bundle has no npm release yet; this project pins a +[pkg.pr.new](https://pkg.pr.new) preview tarball of it. To move to a newer +preview (or a real release once one exists), change the `agent-bundle` entry +in `devDependencies` — see +[Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md) +for the URL forms. diff --git a/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts b/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts new file mode 100644 index 000000000..e57231df3 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'agent-bundle'; + +export default defineConfig({ + mcp: { + servers: { + // No `entry` needed: the conventional stdio entry `src/mcp/status.ts` + // supplies it, and its default-exported factory runs under the + // framework lifecycle shell. + status: {}, + }, + }, + plugin: { + description: 'A stdio MCP server plugin scaffolded from the mcp-server template.', + name: 'my-agent-plugin', + version: '0.1.0', + }, + scripts: { + 'check-status': './src/scripts/check-status.ts', + }, + targets: ['portable', 'codex', 'claude'], +}); diff --git a/packages/create-agent-bundle/templates/mcp-server/gitignore b/packages/create-agent-bundle/templates/mcp-server/gitignore new file mode 100644 index 000000000..fb774fd70 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +artifact/ +.agent-bundle/ diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json new file mode 100644 index 000000000..f2bc4dabf --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/package_json @@ -0,0 +1,26 @@ +{ + "name": "my-agent-plugin", + "version": "0.1.0", + "private": true, + "description": "A stdio MCP server plugin built with agent-bundle.", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "build": "agent-bundle build --json --output artifact", + "check": "npm run validate && npm run build && npm run typecheck && npm run test", + "dev": "agent-bundle dev", + "test": "rstest tests", + "typecheck": "tsc -p tsconfig.json --noEmit", + "validate": "agent-bundle validate --json" + }, + "devDependencies": { + "@modelcontextprotocol/server": "2.0.0", + "@rstest/core": "0.11.10", + "@types/node": "26.4.0", + "agent-bundle": "workspace:*", + "typescript": "7.0.2", + "zod": "4.4.3" + } +} diff --git a/packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts b/packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts new file mode 100644 index 000000000..da2210d7f --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts @@ -0,0 +1,28 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +import { reportStatus } from '../status.js'; + +export const createStatusServer = (): McpServer => { + const server = new McpServer({ name: 'my-agent-plugin', version: '0.1.0' }); + + server.registerTool('report-status', { + description: 'Report the readiness of one service.', + inputSchema: z.object({ service: z.string().min(1) }), + }, async ({ service }) => { + const report = reportStatus(service); + return { + content: [{ text: report.summary, type: 'text' }], + structuredContent: { ...report }, + }; + }); + + return server; +}; + +/** + * Default-exported server factory: `agent-bundle build` detects it and wraps + * this entry in the framework stdio lifecycle shell (console-to-stderr guard, + * SIGINT/SIGTERM handling, stdin-EOF exit, bounded shutdown, heartbeat). + */ +export default createStatusServer; diff --git a/packages/create-agent-bundle/templates/mcp-server/src/scripts/check-status.ts b/packages/create-agent-bundle/templates/mcp-server/src/scripts/check-status.ts new file mode 100644 index 000000000..a31e305a6 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/src/scripts/check-status.ts @@ -0,0 +1,16 @@ +import { reportStatus } from '../status.js'; + +/** + * `agent-bundle build` detects the `main` export and generates the process + * envelope (argv, awaiting, numeric-return exit-code adoption) around it. + */ +export const main = async (argv: readonly string[]): Promise => { + const service = argv[0] ?? 'docs'; + const report = reportStatus(service); + if (report.status !== 'healthy') { + process.stderr.write(`${report.summary}\n`); + return 1; + } + process.stdout.write(`${report.summary}\n`); + return 0; +}; diff --git a/packages/create-agent-bundle/templates/mcp-server/src/status.ts b/packages/create-agent-bundle/templates/mcp-server/src/status.ts new file mode 100644 index 000000000..5c4875ea1 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/src/status.ts @@ -0,0 +1,16 @@ +/** Domain logic shared by the MCP server, the artifact script, and the tests. */ + +export interface StatusReport { + readonly service: string; + readonly status: 'healthy' | 'unknown'; + readonly summary: string; +} + +const knownServices: readonly string[] = ['docs', 'api']; + +export const reportStatus = (service: string): StatusReport => { + if (!knownServices.includes(service)) { + return { service, status: 'unknown', summary: `${service} is not a known service.` }; + } + return { service, status: 'healthy', summary: `${service} is ready.` }; +}; diff --git a/packages/create-agent-bundle/templates/mcp-server/tests/status.test.ts b/packages/create-agent-bundle/templates/mcp-server/tests/status.test.ts new file mode 100644 index 000000000..f6c9d1ce0 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/tests/status.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from '@rstest/core'; + +import { reportStatus } from '../src/status.js'; + +it('reports a known service as healthy', () => { + expect(reportStatus('docs')).toEqual({ service: 'docs', status: 'healthy', summary: 'docs is ready.' }); +}); + +it('reports an unknown service without inventing readiness', () => { + expect(reportStatus('billing')).toMatchObject({ status: 'unknown' }); +}); diff --git a/packages/create-agent-bundle/templates/mcp-server/tsconfig.json b/packages/create-agent-bundle/templates/mcp-server/tsconfig.json new file mode 100644 index 000000000..d94375c56 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": [ + "node" + ], + "verbatimModuleSyntax": true + }, + "include": [ + "agent-bundle.config.ts", + "src/**/*.ts", + "tests/**/*.ts" + ] +} diff --git a/packages/create-agent-bundle/templates/minimal/README.md b/packages/create-agent-bundle/templates/minimal/README.md new file mode 100644 index 000000000..be8ec9499 --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/README.md @@ -0,0 +1,29 @@ +# my-agent-plugin + +A skills-only agent plugin built with [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). +One `agent-bundle.config.ts` describes the plugin; the compiler emits installable +artifacts for Claude Code, Codex, and Cursor, plus a portable layout. + +## Commands + +```sh +npm run dev # local workbench with live rebuilds +npm run build # write host artifacts to artifact/ +npm run check # validate + build + typecheck + test +``` + +## Layout + +- `agent-bundle.config.ts` — the one typed config. +- `skills/getting-started/` — a Skill: `SKILL.md` frontmatter plus optional + `references/` and `assets/`. +- `tests/` — run with `npm run test`. + +## The agent-bundle dependency + +agent-bundle has no npm release yet; this project pins a +[pkg.pr.new](https://pkg.pr.new) preview tarball of it. To move to a newer +preview (or a real release once one exists), change the `agent-bundle` entry +in `devDependencies` — see +[Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md) +for the URL forms. diff --git a/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts b/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts new file mode 100644 index 000000000..9438c11b8 --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'agent-bundle'; + +export default defineConfig({ + plugin: { + description: 'A skills-only agent plugin scaffolded from the minimal template.', + name: 'my-agent-plugin', + version: '0.1.0', + }, + skills: ['skills/getting-started'], + targets: ['portable', 'codex', 'claude'], +}); diff --git a/packages/create-agent-bundle/templates/minimal/gitignore b/packages/create-agent-bundle/templates/minimal/gitignore new file mode 100644 index 000000000..fb774fd70 --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +artifact/ +.agent-bundle/ diff --git a/packages/create-agent-bundle/templates/minimal/package_json b/packages/create-agent-bundle/templates/minimal/package_json new file mode 100644 index 000000000..a00d014ce --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/package_json @@ -0,0 +1,24 @@ +{ + "name": "my-agent-plugin", + "version": "0.1.0", + "private": true, + "description": "A skills-only agent plugin built with agent-bundle.", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "build": "agent-bundle build --json --output artifact", + "check": "npm run validate && npm run build && npm run typecheck && npm run test", + "dev": "agent-bundle dev", + "test": "rstest tests", + "typecheck": "tsc -p tsconfig.json --noEmit", + "validate": "agent-bundle validate --json" + }, + "devDependencies": { + "@rstest/core": "0.11.10", + "@types/node": "26.4.0", + "agent-bundle": "workspace:*", + "typescript": "7.0.2" + } +} diff --git a/packages/create-agent-bundle/templates/minimal/skills/getting-started/SKILL.md b/packages/create-agent-bundle/templates/minimal/skills/getting-started/SKILL.md new file mode 100644 index 000000000..94729dac3 --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/skills/getting-started/SKILL.md @@ -0,0 +1,31 @@ +--- +name: getting-started +description: Explains what this plugin provides and how to extend it with new Skills. +--- +# Getting started + +## When to use + +Use this Skill when someone asks what this plugin can do, or how to add a new +capability to it. + +## What this plugin provides + +This plugin currently ships one Skill — this one. It was scaffolded from the +`minimal` template of `create-agent-bundle`, which is the smallest complete +agent-bundle project: one config, one Skill, and a delivery gate. + +## How to add a Skill + +1. Create `skills//SKILL.md` with `name` and `description` + frontmatter. The `name` must match the directory name. +2. Add supporting material under `references/` (read-only context) and + `assets/` (files the agent fills in or copies). +3. List the new directory in the `skills` array of `agent-bundle.config.ts`. +4. Run the project's `check` script: it validates the config, builds every + host artifact, and runs the tests. + +## Final report requirements + +When answering with this Skill, name the Skills the plugin currently ships +and cite the exact files a new Skill needs. diff --git a/packages/create-agent-bundle/templates/minimal/tests/skill.test.ts b/packages/create-agent-bundle/templates/minimal/tests/skill.test.ts new file mode 100644 index 000000000..2caedaa5a --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/tests/skill.test.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; + +import { expect, it } from '@rstest/core'; + +const skillPath = new URL('../skills/getting-started/SKILL.md', import.meta.url); + +it('keeps the getting-started Skill frontmatter aligned with its directory', async () => { + const contents = await readFile(skillPath, 'utf8'); + const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(contents); + expect(frontmatter).not.toBeNull(); + expect(frontmatter![1]).toContain('name: getting-started'); + expect(frontmatter![1]).toMatch(/description: \S/u); + expect(contents).toContain('# Getting started'); +}); diff --git a/packages/create-agent-bundle/templates/minimal/tsconfig.json b/packages/create-agent-bundle/templates/minimal/tsconfig.json new file mode 100644 index 000000000..fb5b826a4 --- /dev/null +++ b/packages/create-agent-bundle/templates/minimal/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": [ + "node" + ], + "verbatimModuleSyntax": true + }, + "include": [ + "agent-bundle.config.ts", + "tests/**/*.ts" + ] +} diff --git a/packages/create-agent-bundle/tests/framework.test.ts b/packages/create-agent-bundle/tests/framework.test.ts new file mode 100644 index 000000000..af1f35c69 --- /dev/null +++ b/packages/create-agent-bundle/tests/framework.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@rstest/core'; + +import { resolveFrameworkSpec } from '../src/framework.ts'; +import { UsageError } from '../src/options.ts'; + +describe('resolveFrameworkSpec', () => { + it('derives the paired pkg.pr.new preview from the scaffolder preview version', () => { + expect(resolveFrameworkSpec('0.0.0-preview-da5df1d', undefined)) + .toBe('https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@da5df1d'); + expect(resolveFrameworkSpec('0.1.0-preview-66a7961c1b59f24c2baa11e8efd0c9422712c900', undefined)) + .toBe('https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@66a7961c1b59f24c2baa11e8efd0c9422712c900'); + }); + + it('lets --framework-version win verbatim', () => { + expect(resolveFrameworkSpec('0.0.0-preview-da5df1d', 'file:/tmp/agent-bundle.tgz')) + .toBe('file:/tmp/agent-bundle.tgz'); + expect(resolveFrameworkSpec('0.0.0', ' 0.2.0 ')).toBe('0.2.0'); + }); + + it('refuses to guess outside a preview build (the npm agent-bundle name is unrelated)', () => { + expect(() => resolveFrameworkSpec('0.0.0', undefined)).toThrow(UsageError); + expect(() => resolveFrameworkSpec('0.0.0', undefined)).toThrow('--framework-version'); + }); +}); diff --git a/packages/create-agent-bundle/tests/options.test.ts b/packages/create-agent-bundle/tests/options.test.ts new file mode 100644 index 000000000..b81bf7265 --- /dev/null +++ b/packages/create-agent-bundle/tests/options.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + UsageError, + defaultTargets, + detectPackageManager, + formatProjectName, + parseFlags, + resolveOptions, + type Prompter, +} from '../src/options.ts'; + +const unusedPrompter: Prompter = { + multiselect: () => { throw new Error('multiselect must not be prompted'); }, + select: () => { throw new Error('select must not be prompted'); }, + text: () => { throw new Error('text must not be prompted'); }, +}; + +describe('parseFlags', () => { + it('reads the directory from the positional argument or --dir', () => { + expect(parseFlags(['my-plugin']).directory).toBe('my-plugin'); + expect(parseFlags(['--dir', 'other']).directory).toBe('other'); + expect(parseFlags(['-d', 'short']).directory).toBe('short'); + }); + + it('parses template, targets, package manager, install, and framework version', () => { + const flags = parseFlags([ + 'my-plugin', + '--template', 'cli-tool', + '--targets', 'portable, claude,portable', + '--package-manager', 'pnpm', + '--no-install', + '--framework-version', 'file:/tmp/agent-bundle.tgz', + ]); + expect(flags).toMatchObject({ + directory: 'my-plugin', + frameworkVersion: 'file:/tmp/agent-bundle.tgz', + install: false, + packageManager: 'pnpm', + targets: ['portable', 'claude'], + template: 'cli-tool', + }); + }); + + it('rejects unknown flags, templates, targets, and package managers', () => { + expect(() => parseFlags(['--bogus'])).toThrow(UsageError); + expect(() => parseFlags(['-t', 'fancy'])).toThrow('Unknown template "fancy"'); + expect(() => parseFlags(['--targets', 'portable,web'])).toThrow('Unknown target "web"'); + expect(() => parseFlags(['--targets', ' , '])).toThrow('--targets needs at least one'); + expect(() => parseFlags(['--package-manager', 'cargo'])).toThrow('Unknown package manager "cargo"'); + expect(() => parseFlags(['one', 'two'])).toThrow('at most one directory'); + }); +}); + +describe('formatProjectName', () => { + it('keeps simple names and takes the basename of paths', () => { + expect(formatProjectName('foo')).toEqual({ packageName: 'foo', pluginName: 'foo', targetDir: 'foo' }); + expect(formatProjectName('foo/bar/')).toEqual({ packageName: 'bar', pluginName: 'bar', targetDir: 'foo/bar' }); + expect(formatProjectName('./foo/bar')).toEqual({ packageName: 'bar', pluginName: 'bar', targetDir: './foo/bar' }); + }); + + it('keeps scoped package names but drops the scope from the plugin name', () => { + expect(formatProjectName('@scope/tool')).toEqual({ + packageName: '@scope/tool', + pluginName: 'tool', + targetDir: '@scope/tool', + }); + }); + + it('sanitizes the plugin name to the safe package-output shape', () => { + expect(formatProjectName('my plugin!').pluginName).toBe('my-plugin'); + expect(formatProjectName('--weird--').pluginName).toBe('weird'); + }); +}); + +describe('detectPackageManager', () => { + it('reads the invoking client from the npm user agent', () => { + expect(detectPackageManager('pnpm/11.23.0 npm/? node/v22.19.0 linux x64')).toBe('pnpm'); + expect(detectPackageManager('yarn/4.5.0 npm/? node/v22.19.0')).toBe('yarn'); + expect(detectPackageManager('npm/11.0.0 node/v22.19.0')).toBe('npm'); + }); + + it('defaults to npm for missing or unknown agents', () => { + expect(detectPackageManager(undefined)).toBe('npm'); + expect(detectPackageManager('cargo/1.0.0')).toBe('npm'); + }); +}); + +describe('resolveOptions', () => { + it('requires a directory and a template when not interactive', async () => { + await expect(resolveOptions(parseFlags([]), { + interactive: false, prompter: unusedPrompter, userAgent: undefined, + })).rejects.toThrow('A project directory is required'); + await expect(resolveOptions(parseFlags(['my-plugin']), { + interactive: false, prompter: unusedPrompter, userAgent: undefined, + })).rejects.toThrow('A template is required'); + }); + + it('treats directory + template flags as a scripted run and asks nothing', async () => { + const resolved = await resolveOptions(parseFlags(['my-plugin', '--template', 'minimal']), { + interactive: true, prompter: unusedPrompter, userAgent: 'pnpm/11.23.0 npm/? node/v22.19.0', + }); + expect(resolved).toEqual({ + install: true, + packageManager: 'pnpm', + packageName: 'my-plugin', + pluginName: 'my-plugin', + targetDir: 'my-plugin', + targets: defaultTargets, + template: 'minimal', + }); + }); + + it('prompts for missing values in interactive runs', async () => { + const asked: string[] = []; + const prompter: Prompter = { + multiselect: async (options) => { asked.push(options.message); return ['portable', 'cursor']; }, + select: async (options) => { asked.push(options.message); return 'mcp-server'; }, + text: async (options) => { asked.push(options.message); return '@scope/status-plugin'; }, + }; + const resolved = await resolveOptions(parseFlags([]), { interactive: true, prompter, userAgent: undefined }); + expect(resolved).toMatchObject({ + packageManager: 'npm', + packageName: '@scope/status-plugin', + pluginName: 'status-plugin', + targets: ['portable', 'cursor'], + template: 'mcp-server', + }); + expect(asked).toHaveLength(3); + }); + + it('rejects an empty interactive target selection', async () => { + const prompter: Prompter = { + ...unusedPrompter, + multiselect: async () => [], + select: async () => 'minimal', + text: async () => 'my-plugin', + }; + await expect(resolveOptions(parseFlags([]), { interactive: true, prompter, userAgent: undefined })) + .rejects.toThrow('at least one host target'); + }); + + it('respects explicit flags over prompts and detection', async () => { + const resolved = await resolveOptions( + parseFlags(['dir', '-t', 'cli-tool', '--targets', 'plugin', '--package-manager', 'bun', '--no-install']), + { interactive: true, prompter: unusedPrompter, userAgent: 'pnpm/11.23.0' }, + ); + expect(resolved).toMatchObject({ + install: false, + packageManager: 'bun', + targets: ['plugin'], + template: 'cli-tool', + }); + }); +}); diff --git a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts new file mode 100644 index 000000000..1bca9a9e3 --- /dev/null +++ b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts @@ -0,0 +1,190 @@ +import { execFile as executeFile } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } 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'; + +const execFile = promisify(executeFile); +const workspaceRoot = process.cwd(); + +const installedEnvironment = (): NodeJS.ProcessEnv => { + const { NODE_PATH: _nodePath, ...environment } = process.env; + return environment; +}; + +interface PackedFixture { + readonly frameworkTarball: string; + readonly root: string; + readonly runnerRoot: string; + readonly scaffolderBin: string; +} + +/** + * Build and `npm pack` agent-bundle and create-agent-bundle once (the + * packed-consumer mechanism: copy the package, `rslib build --dist-path` + * into the copy, pack the copy), then install the scaffolder tarball into a + * clean runner project. Every template test drives the installed bin and + * pins the framework with `--framework-version file:`, so the run + * never depends on pkg.pr.new. + */ +const packFixture = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-e2e-')); + const pack = async (packageName: string): Promise => { + const packageRoot = join(workspaceRoot, 'packages', packageName); + const packedRoot = join(root, `packed-${packageName}`); + await cp(packageRoot, packedRoot, { recursive: true }); + await execFile(join(workspaceRoot, 'node_modules', '.bin', 'rslib'), [ + 'build', '--config', join(packageRoot, 'rslib.config.ts'), '--dist-path', join(packedRoot, 'dist'), + ], { cwd: workspaceRoot, env: installedEnvironment() }); + const { stdout } = await execFile('npm', ['pack', '--json', '--pack-destination', root], { + cwd: packedRoot, + env: installedEnvironment(), + }); + return join(root, (JSON.parse(stdout) as [{ readonly filename: string }])[0].filename); + }; + const frameworkTarball = await pack('agent-bundle'); + const scaffolderTarball = await pack('create-agent-bundle'); + + const runnerRoot = join(root, 'runner'); + await mkdir(runnerRoot, { recursive: true }); + await writeFile(join(runnerRoot, 'package.json'), '{"name":"scaffold-runner","type":"module","private":true}\n'); + await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', scaffolderTarball], { + cwd: runnerRoot, + env: installedEnvironment(), + }); + return { + frameworkTarball, + root, + runnerRoot, + scaffolderBin: join(runnerRoot, 'node_modules', '.bin', 'create-agent-bundle'), + }; +}; + +let fixturePromise: Promise | undefined; +const fixture = (): Promise => { + fixturePromise ??= packFixture(); + return fixturePromise; +}; + +afterAll(async () => { + if (fixturePromise === undefined) return; + const { root } = await fixture(); + await rm(root, { force: true, recursive: true }); +}); + +const scaffoldProject = async ( + template: string, + projectName: string, + extraArguments: readonly string[], +): Promise => { + const { frameworkTarball, runnerRoot, scaffolderBin } = await fixture(); + await execFile(scaffolderBin, [ + projectName, + '--template', template, + '--targets', 'portable,codex,claude', + '--package-manager', 'npm', + '--framework-version', `file:${frameworkTarball}`, + ...extraArguments, + ], { cwd: runnerRoot, env: installedEnvironment() }); + return join(runnerRoot, projectName); +}; + +const npmRun = async (projectRoot: string, script: string): Promise<{ readonly stdout: string }> => + execFile('npm', ['run', script], { cwd: projectRoot, env: installedEnvironment() }); + +/** Zero diagnostics — including the informational AB473x migration nudges. */ +const expectCleanValidate = async (projectRoot: string): Promise => { + const cli = join(projectRoot, 'node_modules', '.bin', 'agent-bundle'); + const { stdout } = await execFile(cli, ['validate', '--json', '--root', projectRoot], { + cwd: projectRoot, + env: installedEnvironment(), + }); + const validated = JSON.parse(stdout) as { readonly diagnostics: readonly unknown[] }; + expect(validated.diagnostics).toEqual([]); +}; + +it('scaffolds the minimal template, auto-installs, and passes its own check', async () => { + // No --no-install: this run covers the scaffolder-driven `npm install` path. + const projectRoot = await scaffoldProject('minimal', 'minimal-project', []); + + const manifest = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) as { + readonly devDependencies: Record; + readonly name: string; + }; + expect(manifest.name).toBe('minimal-project'); + expect(manifest.devDependencies['agent-bundle']).toMatch(/^file:.*\.tgz$/u); + await expect(readFile(join(projectRoot, '.gitignore'), 'utf8')).resolves.toContain('node_modules/'); + + await npmRun(projectRoot, 'check'); + await expectCleanValidate(projectRoot); + await expect(readFile(join(projectRoot, 'artifact', 'portable', 'skills', 'getting-started', 'SKILL.md'), 'utf8')) + .resolves.toContain('# Getting started'); +}, 600_000); + +it('scaffolds the mcp-server template and serves the conventional entry from the artifact', async () => { + const projectRoot = await scaffoldProject('mcp-server', 'status-plugin', ['--no-install']); + await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund'], { + cwd: projectRoot, + env: installedEnvironment(), + }); + + await npmRun(projectRoot, 'check'); + await expectCleanValidate(projectRoot); + + const artifact = join(projectRoot, 'artifact'); + const manifest = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { + readonly mcpServers: { readonly status: { readonly args: readonly [string, ...string[]] } }; + }; + const entry = join(artifact, 'portable', manifest.mcpServers.status.args[0]); + // The factory export was wrapped in the framework stdio lifecycle shell. + await expect(readFile(entry, 'utf8')).resolves.toContain('stdio heartbeat'); + + const cli = join(projectRoot, 'node_modules', '.bin', 'agent-bundle'); + const { stdout: listed } = await execFile(cli, [ + 'mcp', 'list', '--json', '--root', projectRoot, '--artifact', artifact, '--target', 'portable', '--server', 'status', + ], { cwd: projectRoot, env: installedEnvironment() }); + expect(JSON.parse(listed)).toMatchObject({ tools: [{ name: 'report-status' }] }); + const { stdout: invoked } = await execFile(cli, [ + 'mcp', 'invoke', '--json', '--root', projectRoot, '--artifact', artifact, '--target', 'portable', + '--server', 'status', '--tool', 'report-status', '--input', '{"service":"docs"}', + ], { cwd: projectRoot, env: installedEnvironment() }); + expect(JSON.parse(invoked)).toMatchObject({ + result: { + content: [{ text: 'docs is ready.', type: 'text' }], + structuredContent: { service: 'docs', status: 'healthy' }, + }, + }); +}, 600_000); + +it('scaffolds the cli-tool template with a framework-built bin, lib, and artifact script', async () => { + const projectRoot = await scaffoldProject('cli-tool', 'greeter', ['--no-install']); + await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund'], { + cwd: projectRoot, + env: installedEnvironment(), + }); + + await npmRun(projectRoot, 'check'); + await expectCleanValidate(projectRoot); + + // The src/cli.ts convention produced the executable package bin. + const bin = join(projectRoot, 'dist', 'bin', 'greeter.js'); + expect((await stat(bin)).mode & 0o111).not.toBe(0); + expect((await readFile(bin, 'utf8')).startsWith('#!/usr/bin/env node\n')).toBe(true); + await expect(execFile(bin, ['World'], { cwd: projectRoot, env: installedEnvironment() })) + .resolves.toMatchObject({ stdout: 'Hello, World!\n' }); + + // The src/index.ts convention produced the library export with declarations. + const library = await import(pathToFileURL(join(projectRoot, 'dist', 'index.js')).href) as { + readonly greet: (name: string) => { readonly message: string }; + }; + expect(library.greet('World').message).toBe('Hello, World!'); + await expect(readFile(join(projectRoot, 'dist', 'index.d.ts'), 'utf8')).resolves.toContain('Greeting'); + + // The same CLI also shipped inside the host artifact as a script. + await expect(execFile(process.execPath, [ + join(projectRoot, 'artifact', 'portable', 'scripts', 'greeter.mjs'), 'World', + ], { cwd: projectRoot, env: installedEnvironment() })).resolves.toMatchObject({ stdout: 'Hello, World!\n' }); +}, 600_000); diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts new file mode 100644 index 000000000..bca0bb747 --- /dev/null +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -0,0 +1,144 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { UsageError, type TargetName } from '../src/options.ts'; +import { assertScaffoldTarget, placeholderName, scaffold } from '../src/scaffold.ts'; + +const templatesRoot = join(process.cwd(), 'packages', 'create-agent-bundle', 'templates'); + +const scaffoldTemplate = async ( + template: string, + overrides: Partial<{ packageName: string; pluginName: string; targets: readonly TargetName[] }> = {}, +): Promise<{ readonly files: readonly string[]; readonly root: string }> => { + const root = await mkdtemp(join(tmpdir(), `create-agent-bundle-${template}-`)); + const files = await scaffold({ + frameworkSpec: 'file:/tmp/agent-bundle-0.0.0.tgz', + packageName: overrides.packageName ?? 'status-plugin', + pluginName: overrides.pluginName ?? 'status-plugin', + targetDirectory: join(root, 'project'), + targets: overrides.targets ?? ['portable', 'codex', 'claude'], + templateRoot: join(templatesRoot, template), + }); + return { files, root: join(root, 'project') }; +}; + +describe('scaffold', () => { + it('emits the documented minimal inventory', async () => { + const { files, root } = await scaffoldTemplate('minimal'); + try { + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'skills/getting-started/SKILL.md', + 'tests/skill.test.ts', + 'tsconfig.json', + ]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('emits the documented mcp-server inventory', async () => { + const { files, root } = await scaffoldTemplate('mcp-server'); + try { + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'src/mcp/status.ts', + 'src/scripts/check-status.ts', + 'src/status.ts', + 'tests/status.test.ts', + 'tsconfig.json', + ]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('emits the documented cli-tool inventory', async () => { + const { files, root } = await scaffoldTemplate('cli-tool'); + try { + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'src/cli.ts', + 'src/index.ts', + 'tests/cli.test.ts', + 'tsconfig.json', + ]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('replaces every placeholder and pins the framework spec', async () => { + const { files, root } = await scaffoldTemplate('cli-tool', { + packageName: '@scope/status-plugin', + pluginName: 'status-plugin', + }); + try { + for (const file of files) { + const contents = await readFile(join(root, file), 'utf8'); + expect(contents).not.toContain(placeholderName); + expect(contents).not.toContain('workspace:*'); + } + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { + readonly bin: Record; + readonly devDependencies: Record; + readonly name: string; + }; + expect(manifest.name).toBe('@scope/status-plugin'); + expect(manifest.devDependencies['agent-bundle']).toBe('file:/tmp/agent-bundle-0.0.0.tgz'); + expect(manifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.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'"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('writes the selected targets into the config', async () => { + const { root } = await scaffoldTemplate('minimal', { targets: ['portable', 'cursor'] }); + try { + const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); + expect(config).toContain("targets: ['portable', 'cursor'],"); + expect(config).not.toContain("'codex'"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + +describe('assertScaffoldTarget', () => { + it('accepts a missing directory, an empty directory, and a lone .git', async () => { + const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-target-')); + try { + await expect(assertScaffoldTarget(join(root, 'absent'), 'absent')).resolves.toBeUndefined(); + await expect(assertScaffoldTarget(root, 'empty')).resolves.toBeUndefined(); + await mkdir(join(root, '.git')); + await expect(assertScaffoldTarget(root, 'git-only')).resolves.toBeUndefined(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('rejects a directory with real contents', async () => { + const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-target-')); + try { + await writeFile(join(root, 'existing.txt'), 'occupied'); + await expect(assertScaffoldTarget(root, 'occupied')).rejects.toThrow(UsageError); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/create-agent-bundle/tsconfig.build.json b/packages/create-agent-bundle/tsconfig.build.json new file mode 100644 index 000000000..11744d2a8 --- /dev/null +++ b/packages/create-agent-bundle/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/create-agent-bundle/tsconfig.json b/packages/create-agent-bundle/tsconfig.json new file mode 100644 index 000000000..64cbdaa00 --- /dev/null +++ b/packages/create-agent-bundle/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": [ + "node" + ] + }, + "include": [ + "rslib.config.ts", + "src/**/*.ts", + "tests/**/*.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b693f03a..f38d538c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,21 @@ importers: specifier: 8.18.1 version: 8.18.1 + packages/create-agent-bundle: + devDependencies: + '@clack/prompts': + specifier: 1.7.0 + version: 1.7.0 + '@rslib/core': + specifier: 0.23.2 + version: 0.23.2(typescript@7.0.2) + '@rstest/core': + specifier: 0.11.10 + version: 0.11.10 + '@types/node': + specifier: 26.4.0 + version: 26.4.0 + packages/rsc-runtime: dependencies: '@modelcontextprotocol/sdk': diff --git a/rstest.config.ts b/rstest.config.ts index 4ef65f74c..c295aef3d 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from '@rstest/core'; +import { templateTestFiles } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; export default defineConfig({ @@ -7,6 +8,7 @@ export default defineConfig({ include: [ 'packages/**/tests/**/*.test.ts', ], + exclude: [...templateTestFiles], // Several integration tests run Rslib, whose build cache and configured // output paths are process-shared. Keep those builds from racing each other. pool: { maxWorkers: 1 }, diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 0a77b127c..3adb612fa 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -82,5 +82,16 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/public-api-packed.test.ts', 'packages/agent-bundle/tests/release-audit.test.ts', 'packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts', + 'packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts', 'packages/workbench/tests/packed-release.e2e.test.ts', ]; + +/** + * Checked-in scaffolding templates ship their own test files; they run inside + * scaffolded projects (the packed e2e drives them through each project's + * `check`), never through the workspace pools, whose include glob would + * otherwise pick them up. + */ +export const templateTestFiles: readonly string[] = [ + 'packages/create-agent-bundle/templates/**', +]; diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index 098736c6b..f8b8c9f17 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from '@rstest/core'; -import { integrationTestFiles, packedTestFiles } from './rstest.integration-tests.ts'; +import { integrationTestFiles, packedTestFiles, templateTestFiles } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** Build-free, process-free tests only; safe on parallel workers. `pnpm test` runs this before the integration config. */ @@ -9,5 +9,5 @@ export default defineConfig({ include: [ 'packages/**/tests/**/*.test.ts', ], - exclude: [...integrationTestFiles, ...packedTestFiles], + exclude: [...integrationTestFiles, ...packedTestFiles, ...templateTestFiles], }); From 546a23ee8b4cc7325070cff8e11c8fa97e7b684d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 06:00:01 +0000 Subject: [PATCH 2/2] fix(create-agent-bundle): sanitize the plugin name to Cursor's lowercase kebab-case contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derived plugin name previously kept uppercase letters and mapped underscores through, which the Cursor adapter (and the unified plugin target) reject — a cursor/plugin target selection would scaffold a project whose own validate fails. The sanitizer now lowers to the strictest host contract (lowercase letters, digits, dots, hyphens, 64-char cap), which is also a valid safe package-output name, so every selectable target validates. Review finding on #62. --- packages/create-agent-bundle/src/options.ts | 15 ++++++++++----- .../create-agent-bundle/tests/options.test.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/create-agent-bundle/src/options.ts b/packages/create-agent-bundle/src/options.ts index e0aa7fc84..876f5c1ed 100644 --- a/packages/create-agent-bundle/src/options.ts +++ b/packages/create-agent-bundle/src/options.ts @@ -140,8 +140,11 @@ export interface ProjectName { * `create-rstack` name semantics: `foo/bar` scaffolds into `/foo/bar` * and names the package `bar`; `@scope/foo` keeps the full scoped name as * the package name. The plugin name additionally drops the scope and is - * sanitized to agent-bundle's safe package-output shape so the `src/cli.ts` - * bin convention always applies. + * sanitized to the strictest host contract — Cursor's lowercase kebab-case + * (`/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/`, at most 64 characters), which the + * unified `plugin` target enforces too and which is also a valid safe + * package-output name — so every selectable target validates and the + * `src/cli.ts` bin convention always applies. */ export const formatProjectName = (input: string): ProjectName => { const formatted = input.trim().replace(/\/+$/u, ''); @@ -154,9 +157,11 @@ const pluginNameFrom = (packageName: string): string => { ? packageName.slice(packageName.indexOf('/') + 1) : packageName; const cleaned = bare - .replace(/[^a-zA-Z0-9._-]+/gu, '-') - .replace(/^[^a-zA-Z0-9]+/u, '') - .replace(/[^a-zA-Z0-9]+$/u, ''); + .toLowerCase() + .replace(/[^a-z0-9.-]+/gu, '-') + .replace(/^[^a-z0-9]+/u, '') + .slice(0, 64) + .replace(/[^a-z0-9]+$/u, ''); return cleaned === '' ? 'my-agent-plugin' : cleaned; }; diff --git a/packages/create-agent-bundle/tests/options.test.ts b/packages/create-agent-bundle/tests/options.test.ts index b81bf7265..cb85e4811 100644 --- a/packages/create-agent-bundle/tests/options.test.ts +++ b/packages/create-agent-bundle/tests/options.test.ts @@ -67,9 +67,19 @@ describe('formatProjectName', () => { }); }); - it('sanitizes the plugin name to the safe package-output shape', () => { + it('sanitizes the plugin name to the strictest host contract (Cursor lowercase kebab-case)', () => { + // Mirrored from cursorNamePattern in packages/agent-bundle/src/adapters/cursor.ts, + // which the unified `plugin` target enforces as well. + const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; expect(formatProjectName('my plugin!').pluginName).toBe('my-plugin'); expect(formatProjectName('--weird--').pluginName).toBe('weird'); + expect(formatProjectName('My_App').pluginName).toBe('my-app'); + expect(formatProjectName('@scope/My.Tool').pluginName).toBe('my.tool'); + for (const input of ['My_App', 'projects/UPPER_case', '@scope/Dots.and_Under', `${'x'.repeat(80)}!`, '汉字']) { + const { pluginName } = formatProjectName(input); + expect(pluginName).toMatch(cursorNamePattern); + expect(pluginName.length).toBeLessThanOrEqual(64); + } }); });