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..876f5c1ed --- /dev/null +++ b/packages/create-agent-bundle/src/options.ts @@ -0,0 +1,266 @@ +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 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, ''); + 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 + .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; +}; + +/** `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..cb85e4811 --- /dev/null +++ b/packages/create-agent-bundle/tests/options.test.ts @@ -0,0 +1,165 @@ +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 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); + } + }); +}); + +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], });