From 8bba903ac00b944a3b74d9c7e64aa4832d20afb2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:51:52 +0000 Subject: [PATCH 1/2] perf(cli): load the Effect terminal runtime lazily to restore cold-start time agent-bundle --version / --help / argv errors: ~300 ms -> ~60 ms (pre-#505 level); create-agent-bundle --help: ~70 ms -> ~40 ms. The cost was module loading (effect + effect/Terminal + platform-node-shared ~250 ms), not runtime construction (~6 ms), so runCli now builds the terminal runtime on the first command write via a dynamic import of src/effect/cli-runtime.ts, and Commander's own text is written synchronously. The scaffolder splits its Effect/NodeServices/Clack program into scaffold-cli.ts the same way. cli.test.ts records module loads under module.registerHooks and fails if the trivial invocations resolve an effect module. --- .changeset/cli-cold-start-lazy-terminal.md | 6 + docs/effect-conventions.md | 115 +++++++--- packages/agent-bundle/src/cli.ts | 109 ++++++---- .../agent-bundle/src/effect/cli-runtime.ts | 43 ++++ packages/agent-bundle/src/effect/terminal.ts | 12 +- packages/agent-bundle/tests/cli.test.ts | 65 +++++- .../tests/support/cli-terminal.ts | 22 +- .../tests/support/record-module-loads.mjs | 29 +++ packages/create-agent-bundle/src/index.ts | 198 +++--------------- .../create-agent-bundle/src/scaffold-cli.ts | 159 ++++++++++++++ .../tests/cli-text.test.ts | 51 ++--- 11 files changed, 517 insertions(+), 292 deletions(-) create mode 100644 .changeset/cli-cold-start-lazy-terminal.md create mode 100644 packages/agent-bundle/src/effect/cli-runtime.ts create mode 100644 packages/agent-bundle/tests/support/record-module-loads.mjs create mode 100644 packages/create-agent-bundle/src/scaffold-cli.ts diff --git a/.changeset/cli-cold-start-lazy-terminal.md b/.changeset/cli-cold-start-lazy-terminal.md new file mode 100644 index 000000000..9ec98badb --- /dev/null +++ b/.changeset/cli-cold-start-lazy-terminal.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": patch +"create-agent-bundle": patch +--- + +Restore CLI cold-start time by loading the Effect terminal runtime lazily. `agent-bundle --version`, `--help`, and argv errors answer in about 60 ms again (they had regressed to about 300 ms) because the Effect `Terminal` / `Stdio` runtime is now built on a command's first write instead of before argv parsing; command output, `--json` documents, and diagnostics are unchanged. `create-agent-bundle --help` and flag errors no longer evaluate the scaffold bundle (Effect, the Node platform layer, Clack), about 70 ms → 40 ms. diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 6ab712371..65bf1bed6 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -269,14 +269,15 @@ the first-party CLI's user-facing text — see the scope closes, which is too early there. - File handles whose use is bounded by one program: scoped `open`. - Layer wiring: one composition root per process. The scaffolder provides - `NodeServices.layer` immediately before its boundary's `runPromise`; - `agent-bundle`'s public API functions provide `platformLayer` through - `runWithPlatform`; the first-party CLI's root is the - `makeScopedEffectRuntime(nodeCliServices)` in `runCli` (today `NodeTerminal` - + `NodeStdio`, see [Terminal and - Stdio](#terminal-and-stdio-user-facing-cli-text)) and widens to - `platformLayer` there when CLI code adopts the filesystem services; the dev - server (phase 2) gets one `makeScopedEffectRuntime(platformLayer)` in + `NodeServices.layer` immediately before its boundary's `runPromise` + (`src/scaffold-cli.ts`, loaded by `runCli` only once a scaffold is + requested); `agent-bundle`'s public API functions provide `platformLayer` + through `runWithPlatform`; the first-party CLI's root is the + `makeCliTerminal(services)` in `src/effect/cli-runtime.ts` (today + `NodeTerminal` + `NodeStdio`, built on the first command write, see + [Terminal and Stdio](#terminal-and-stdio-user-facing-cli-text)) and widens + to `platformLayer` there when CLI code adopts the filesystem services; the + dev server (phase 2) gets one `makeScopedEffectRuntime(platformLayer)` in `startDevServer`, disposed from the session's `close`. Never provide a platform layer deep inside library code. - Errors: `PlatformError` flows through the Effect error channel and is @@ -323,12 +324,18 @@ the first-party CLI's user-facing text — see `packages/create-agent-bundle/src/effect/boundary.ts` is the scaffolder's sole run edge (phase 1 pilot): `runPromise` rethrows `UsageError` and plain -`Error` unchanged and unwraps `PlatformError` to its Node cause. `runCli` -provides `NodeServices.layer` once. Measured on rc.112 (bundled by Rslib, -`node` target): `dist/index.js` 73.7 kB → 456.8 kB with `NodeServices.layer` -(264.7 kB with only `NodeFileSystem` + `NodePath`); packed tarball 33.0 kB → -110.2 kB; `--help` cold start ≈40 ms → ≈65 ms. `undici` is not pulled into -the bundle. +`Error` unchanged and unwraps `PlatformError` to its Node cause. +`src/scaffold-cli.ts` provides `NodeServices.layer` once; `src/index.ts` +(`runCli`) parses argv, answers `--help` and flag errors itself, and loads +that module with a dynamic `import()` only for an actual scaffold. Measured +on rc.112 (bundled by Rslib, `node` target): the bundle grew from 73.7 kB to +456.8 kB with `NodeServices.layer` (264.7 kB with only `NodeFileSystem` + +`NodePath`); packed tarball 33.0 kB → 110.2 kB. Rslib splits the dynamic +import into its own chunk (`dist/scaffold-cli.js`, 447.5 kB; the argv layer +is 10.8 kB), so `--help` no longer evaluates it: cold start ≈40 ms before +`NodeServices.layer`, ≈65 ms with it evaluated eagerly (the 2026-09-03 +regression, ≈69 ms on the 2026-09-03 re-measurement below), ≈40 ms with the +split. `undici` is not pulled into the bundle. `packages/agent-bundle/src/effect/platform.ts` owns the framework's platform layer: `platformLayer` (the `NodeServices` union composed from @@ -357,36 +364,78 @@ Node implementations come from `@effect/platform-node-shared@4.0.0-rc.112`, the same dependency `platform.ts` builds `platformLayer` from (never `@effect/platform-node`, for the consumer-footprint reason above). `effect/Terminal` is the sanctioned way to touch stdin/stdout for -**user-facing text**: human command output, Commander help and argv errors, -and the Workbench startup URL line go through `Terminal.display`, and any -future interactive prompt goes through `terminal.readLine` (EOF surfaces as +**user-facing command output**: human command output and the Workbench +startup URL line go through `Terminal.display`, and any future interactive +prompt goes through `terminal.readLine` (EOF surfaces as `Terminal.QuitError`, so a prompt must handle it). `Terminal.display` is stdout-only; **diagnostics** (the canonical JSON diagnostics document) go through `Stdio.stderr()`, and **machine output** (`--json`, stable JSON lines) goes through `Stdio.stdout()` so its bytes stay exact. The helpers live in `src/effect/terminal.ts` (`display`, `writeStderr`, `writeStdout`). +**Argv-layer text** — Commander's `--help`, `--version`, and argv errors — +is the one exception: it is written synchronously to the process streams +before any command runs (see the cold-start budget below). + +Cold-start budget (measured 2026-09-03, Node v22.23.2, 30 runs, median wall +time of the built `bin/agent-bundle.js`; `node -e 0` is ≈28 ms on the same +machine): `--version` ≈60 ms before the adoption, ≈300 ms with the runtime +built eagerly in `runCli`, ≈60 ms with the lazy runtime; `--help` the same; +`validate` on `examples/host-test` unchanged (≈1.7–2.3 s, dominated by the +compiler). Where the +240 ms went: loading the `effect` module graph +(≈300 ms for the `effect` barrel in an unbundled process; ≈100 ms of it is +`effect/Terminal` alone, and the minimal `effect/Effect` + `Layer` + +`Stream` + `Terminal` + `Stdio` + `ManagedRuntime` subpath set still costs +≈240 ms) plus ≈10 ms for the two platform-node-shared subpaths. +Constructing the runtime is not the cost: `Layer.mergeAll` + +`ManagedRuntime.make` + the first `runPromise` that builds the layer total +≈6 ms. Memoizing the runtime therefore buys nothing; not loading the modules +is the whole fix. Wiring rules: -- Provide the process-backed layers **once**, at the CLI composition root - (`runCli`), through one `makeScopedEffectRuntime(nodeCliServices)` from - `src/effect/boundary.ts`, and close it when the command finishes (a - foreground `dev` session keeps it until the session closes). No other - module provides `NodeTerminal.layer` / `NodeStdio.layer`. +- Provide the process-backed layers **once**, at the CLI composition root, + through one `makeCliTerminal(services)` from `src/effect/cli-runtime.ts` + (a `makeScopedEffectRuntime(nodeCliServices)` behind Promise-shaped + `display` / `writeStdout` / `writeStderr` / `close`), and close it when + the command finishes (a foreground `dev` session keeps it until the + session closes). No other module provides `NodeTerminal.layer` / + `NodeStdio.layer`. +- **Build it lazily.** `src/cli.ts` imports `cli-runtime.ts` type-only and + loads it with a dynamic `import()` on the first command write, after + Commander has parsed argv. `cli-runtime.ts` is the only module that pulls + `effect`, `effect/Terminal`, `effect/Stdio`, and the platform-node-shared + layers into the CLI process; `--version`, `--help`, and argv errors must + never reach it. `packages/agent-bundle/tests/cli.test.ts` proves this by + running the built CLI under a `module.registerHooks` resolve recorder + (`tests/support/record-module-loads.mjs`) and asserting no `effect` or + `@effect/platform-node-shared` URL resolves for those three invocations + (and that a real command does load them). The same dynamic-import pattern + already keeps `./api.ts` out of the trivial path. +- Commander writes its own text (help, version, argv errors) synchronously + through `configureOutput` to `CliOutput.argvText` (default: the process + streams). It only writes before aborting parsing, so it never interleaves + with Effect-written command output, and routing it through + `Terminal.display` would load the runtime for exactly the invocations the + budget protects. - `nodeCliServices` is `Layer.mergeAll(NodeTerminal.layer, NodeStdio.layer)` from the `@effect/platform-node-shared/NodeTerminal` and `/NodeStdio` - subpaths, not the whole `platformLayer`: the CLI's help/version path does - not use child-process, crypto, or filesystem services, and loading them - measured at roughly +400 ms of startup. -- The scaffolder (`packages/create-agent-bundle/src/index.ts`) uses the same - two services from its existing `NodeServices.layer` root for `--help` - (`Terminal.display`) and flag errors (`Stdio.stderr()`); Clack stays the - prompt renderer and is not replaced by `readLine`. + subpaths, not the whole `platformLayer`: the CLI does not use + child-process, crypto, or filesystem services, and loading them measured + at roughly +400 ms on top. +- The scaffolder (`packages/create-agent-bundle/src/index.ts`) follows the + same split: `runCli` parses flags and writes `--help` (stdout) and flag + errors (stderr) synchronously to its `CliStreams` (default: the process + streams), then loads `src/scaffold-cli.ts` — the `NodeServices.layer` + root, the scaffold program, and Clack — with a dynamic `import()`. Clack + stays the prompt renderer and is not replaced by `readLine`. - Keep `display` text explicit about line endings (`\n`); the service writes what it is given. -- Tests provide a capture layer (`tests/support/cli-terminal.ts`: - `Terminal.make({ display })` + `Stdio.layerTest({ stdout, stderr })`) - through `runCli(args, { services })`; they never spy on `process.stdout`. +- Tests provide a capture layer plus capture argv sinks + (`tests/support/cli-terminal.ts`: `Terminal.make({ display })` + + `Stdio.layerTest({ stdout, stderr })` + `argvText`) through + `runCli(args, { argvText, services })`; they never spy on + `process.stdout`. The scaffolder's `tests/cli-text.test.ts` passes capture + `CliStreams` to `runCli`. - **Protocol stdout stays raw.** MCP stdio JSON-RPC (`mcp-entry.ts`, `mcp run`), hook result JSON (`adapters/hook-contract.ts`), the emitted routed-CLI shell (`cli-entry.ts`'s `writeOut`/`writeErr` ports and the @@ -472,7 +521,7 @@ wire contracts](#effect-schema-wire-contracts-schema-projections). | --- | --- | --- | | `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships | | `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract | -| `@effect/platform-node-shared` (`NodeTerminal` / `NodeStdio`) + `effect/Terminal`, `effect/Stdio` | first-party CLI user-facing text, diagnostics, and machine output (`src/cli.ts`, `src/effect/terminal.ts`) and `create-agent-bundle`'s `--help` / flag-error text (2026-09-03) | re-pin re-checks `Terminal.display` stays stdout-only, `readLine` EOF → `QuitError`, the `Stdio` sink contract, and re-measures `agent-bundle --version` startup against the recorded +180 ms budget | +| `@effect/platform-node-shared` (`NodeTerminal` / `NodeStdio`) + `effect/Terminal`, `effect/Stdio` | first-party CLI command output, diagnostics, and machine output (`src/cli.ts`, `src/effect/terminal.ts`, `src/effect/cli-runtime.ts`), loaded lazily on the first command write (2026-09-03); Commander's help/version/argv-error text and the scaffolder's `--help` / flag-error text stay on synchronous process writes for the cold-start budget | re-pin re-checks `Terminal.display` stays stdout-only, `readLine` EOF → `QuitError`, the `Stdio` sink contract, and re-measures `agent-bundle --version` startup against the recorded ≈60 ms (`cli.test.ts` fails the build if the trivial invocations resolve an `effect` module) | | `Schema` / `SchemaAST` / `SchemaParser` projections (`toType` / `toEncoded`) for wire contracts | **declined** (2026-09-01) | revisit at Effect GA or on the first encoded/decoded-divergent wire contract; re-pin re-checks the projections API and the `onExcessProperty` parse-option default | ## Language service diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 94b6e2b78..de76fa67b 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { Command, CommanderError, InvalidArgumentError } from 'commander'; -import type { Effect, Layer } from 'effect'; +import type { Layer } from 'effect'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -9,6 +9,11 @@ import { resolve } from 'node:path'; * inside each action. A static import would load Rsbuild, Rslib, Rslint, the MCP * SDK, chokidar and ajv before argv is even parsed, so `--version`, `--help` and * an argv error would each pay the full graph for nothing. + * + * The Effect terminal runtime (`./effect/cli-runtime.ts`) is type-only here for + * the same reason: `effect` + `effect/Terminal` + the platform-node-shared + * layers measured ≈250 ms of module loading, so `runCli` imports that module + * on the first command write, after Commander has parsed. */ import type { build, @@ -45,21 +50,37 @@ import { formatInstallResult, formatUninstallResult } from './install/format.ts' import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; -import { makeScopedEffectRuntime } from './effect/boundary.ts'; -import { type CliServices, display, nodeCliServices, writeStderr, writeStdout } from './effect/terminal.ts'; +import type { CliTerminal } from './effect/cli-runtime.ts'; +import type { CliServices } from './effect/terminal.ts'; declare const __AGENT_BUNDLE_VERSION__: string; +/** Synchronous text sinks for the text Commander writes itself while parsing argv. */ +export interface ArgvTextSinks { + readonly stderr: (text: string) => void; + readonly stdout: (text: string) => void; +} + /** - * The CLI's terminal services (#465 / Effect Terminal adoption). User-facing - * text is written through `Terminal.display`; diagnostics and machine output - * through `Stdio`. The process-backed Node layers are the default; tests - * provide a capture layer instead of spying on `process.stdout`. + * The CLI's output seams (#465 / Effect Terminal adoption). Command output — + * user-facing text through `Terminal.display`, diagnostics and machine output + * through `Stdio` — runs against `services`; the process-backed Node layers + * are the default and are loaded on the first write. Commander's own text — + * `--help`, `--version`, and argv errors — is written synchronously to + * `argvText` (default: the process streams) before any command runs, so those + * invocations never load the Effect runtime. Tests provide capture sinks for + * both instead of spying on `process.stdout`. */ export interface CliOutput { + readonly argvText?: ArgvTextSinks; readonly services?: Layer.Layer; } +const processArgvText: ArgvTextSinks = Object.freeze({ + stderr: (text: string): void => void process.stderr.write(text), + stdout: (text: string): void => void process.stdout.write(text), +}); + interface CliSignalSource { once(signal: NodeJS.Signals, listener: () => void): unknown; removeListener(signal: NodeJS.Signals, listener: () => void): unknown; @@ -619,29 +640,22 @@ const closeForegroundOnSignal = ( for (const signal of terminationSignals) signals.once(signal, close); }); -/** Commander writes help and argv errors synchronously; the CLI queues them and replays them through the terminal services in order. */ -interface QueuedWrite { - readonly stream: 'stderr' | 'stdout'; - readonly text: string; -} - export const runCli = async ( args: string[], output: CliOutput = {}, dependencies: CliDependencies = {}, ): Promise => { // The terminal services are provided exactly once, here at the composition - // root; every write below runs through the boundary against this runtime. - const runtime = makeScopedEffectRuntime(output.services ?? nodeCliServices); - const run = (effect: Effect.Effect): Promise => runtime.run(effect); - const machine = (result: unknown): Promise => run(writeStdout(machineLine(result))); - const diagnostics = (text: string): Promise => run(writeStderr(text)); - const queued: QueuedWrite[] = []; - const flushQueued = async (): Promise => { - for (const entry of queued.splice(0)) { - await (entry.stream === 'stdout' ? run(display(entry.text)) : diagnostics(entry.text)); - } - }; + // root, but built lazily: the runtime module loads on the first command + // write, so `--version`, `--help`, and argv errors (which Commander writes + // synchronously to the sinks below and then aborts parsing) never load it. + let terminal: Promise | undefined; + const cliTerminal = (): Promise => + (terminal ??= import('./effect/cli-runtime.ts').then(({ makeCliTerminal }) => makeCliTerminal(output.services))); + const show = async (text: string): Promise => (await cliTerminal()).display(text); + const machine = async (result: unknown): Promise => (await cliTerminal()).writeStdout(machineLine(result)); + const diagnostics = async (text: string): Promise => (await cliTerminal()).writeStderr(text); + const argvText = output.argvText ?? processArgvText; let foreground: Promise | undefined; let exitCode = 0; const program = new Command(); @@ -651,8 +665,8 @@ export const runCli = async ( .exitOverride() .showHelpAfterError(false) .configureOutput({ - writeErr: (chunk) => void queued.push({ stream: 'stderr', text: chunk }), - writeOut: (chunk) => void queued.push({ stream: 'stdout', text: chunk }), + writeErr: (chunk) => argvText.stderr(chunk), + writeOut: (chunk) => argvText.stdout(chunk), }); const devCommand = program.command('dev').description('Serve the packaged development workbench on loopback') @@ -672,7 +686,7 @@ export const runCli = async ( ...(options.port === undefined ? {} : { port: options.port }), root: options.root, }); - await run(display(`Development workbench at ${session.url}\n`)); + await show(`Development workbench at ${session.url}\n`); foreground = closeForegroundOnSignal(session, dependencies.signals ?? process, diagnostics); }); @@ -715,7 +729,7 @@ export const runCli = async ( if (result.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { throw new DiagnosticError(result.diagnostics); } - await (options.json === true ? machine(result) : run(display(humanBuild(result)))); + await (options.json === true ? machine(result) : show(humanBuild(result))); }); const prepackCommand = configureSourceOptions( @@ -728,7 +742,7 @@ export const runCli = async ( output: options.output, packageOutputs: true, }); - await (options.json === true ? machine(result) : run(display(humanPrepack(result)))); + await (options.json === true ? machine(result) : show(humanPrepack(result))); }); const installCommand = program.command('install') @@ -756,7 +770,7 @@ export const runCli = async ( ...(options.mode === undefined ? {} : { mode: options.mode }), scope: installScope(options.scope), }); - await (options.json === true ? machine(result) : run(display(humanInstall(result)))); + await (options.json === true ? machine(result) : show(humanInstall(result))); }); const uninstallCommand = program.command('uninstall') @@ -791,7 +805,7 @@ export const runCli = async ( ...(options.purgeData === undefined ? {} : { purgeData: options.purgeData }), scope: installScope(options.scope), }); - await (options.json === true ? machine(result) : run(display(humanUninstall(result)))); + await (options.json === true ? machine(result) : show(humanUninstall(result))); }); const doctorCommand = program.command('doctor') @@ -805,7 +819,7 @@ export const runCli = async ( ...(options.from === undefined ? {} : { from: options.from }), ...(options.host.length === 0 ? {} : { hosts: options.host }), }); - await (options.json === true ? machine(result) : run(display(humanDoctor(result)))); + await (options.json === true ? machine(result) : show(humanDoctor(result))); if (result.diagnostics.some((entry) => entry.severity === 'error')) exitCode = 1; }); @@ -827,7 +841,7 @@ export const runCli = async ( if (result.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { throw new DiagnosticError(result.diagnostics); } - await (options.json === true ? machine(result) : run(display(humanValidate(result)))); + await (options.json === true ? machine(result) : show(humanValidate(result))); }); const evalCommand = configureSourceOptions( @@ -848,7 +862,7 @@ export const runCli = async ( ...(options.suite === undefined || options.suite.length === 0 ? {} : { suites: options.suite }), ...(options.trials === undefined ? {} : { trials: options.trials }), }); - await (options.json === true ? machine(result) : run(display(humanEval(result)))); + await (options.json === true ? machine(result) : show(humanEval(result))); const summary = result.run.summary ?? emptyEvalSummary; // Inconclusive trials produced no evidence, so they cannot report success either. if (summary.fail > 0 || summary.inconclusive > 0) exitCode = 1; @@ -867,7 +881,7 @@ export const runCli = async ( baseRunId: baseline, candidateRunId: candidate, }); - await (sourceOptions.json === true ? machine(result) : run(display(humanEvalComparison(result)))); + await (sourceOptions.json === true ? machine(result) : show(humanEvalComparison(result))); }); const inspectCommand = configureInspectOptions( @@ -899,7 +913,7 @@ export const runCli = async ( ...(options.state === true ? { focus: 'state' as const } : {}), ...(options.target === undefined ? {} : { target: options.target }), }); - await (options.json === true ? machine(result) : run(display(humanInspect(result)))); + await (options.json === true ? machine(result) : show(humanInspect(result))); if (result.state === 'invalid') exitCode = 1; }); @@ -915,7 +929,7 @@ export const runCli = async ( server: options.server, target: options.target, }); - await (options.json === true ? machine(result) : run(display(`Listed ${result.tools.length} tool(s) from ${options.server}\n`))); + await (options.json === true ? machine(result) : show(`Listed ${result.tools.length} tool(s) from ${options.server}\n`)); }); const mcpInvokeCommand = configureArtifactOptions( @@ -939,7 +953,7 @@ export const runCli = async ( target: options.target, tool: options.tool, }); - await (options.json === true ? machine(result) : run(display(`Invoked ${options.tool} on ${options.server}\n`))); + await (options.json === true ? machine(result) : show(`Invoked ${options.tool} on ${options.server}\n`)); }); const mcpRunCommand = configureArtifactOptions( @@ -980,7 +994,7 @@ export const runCli = async ( hooksListCommand.action(async (options: ArtifactCommandOptions) => { const { listHooks } = await import('./api.ts'); const result = await listHooks({ ...artifactOptions(options), target: options.target }); - await (options.json === true ? machine(result) : run(display(`Listed ${result.length} hook(s)${options.target === undefined ? '' : ` from ${options.target}`}\n`))); + await (options.json === true ? machine(result) : show(`Listed ${result.length} hook(s)${options.target === undefined ? '' : ` from ${options.target}`}\n`)); }); const hooksSimulateCommand = configureArtifactOptions( @@ -1001,25 +1015,28 @@ export const runCli = async ( input: await parseJsonObject(options), target: options.target, }); - await (options.json === true ? machine(result) : run(display(`Simulated ${options.hook}\n`))); + await (options.json === true ? machine(result) : show(`Simulated ${options.hook}\n`)); }); try { await program.parseAsync(args, { from: 'user' }); - await flushQueued(); return exitCode; } catch (error) { - await flushQueued(); if (error instanceof CommanderError) { return error.exitCode === 0 ? 0 : 2; } await diagnostics(machineLine(diagnosticsFor(error))); return 1; } finally { - // A foreground session outlives this call; its close diagnostics still - // need the services, so the runtime follows the session instead. - if (foreground === undefined) await runtime.close(); - else void foreground.then(() => runtime.close()); + // Only a command that wrote something built the runtime. A foreground + // session outlives this call; its close diagnostics still need the + // services, so the runtime follows the session instead. + if (terminal !== undefined) { + const built = terminal; + const close = (): Promise => built.then((active) => active.close(), () => undefined); + if (foreground === undefined) await close(); + else void foreground.then(close); + } } }; diff --git a/packages/agent-bundle/src/effect/cli-runtime.ts b/packages/agent-bundle/src/effect/cli-runtime.ts new file mode 100644 index 000000000..c51ae8d3f --- /dev/null +++ b/packages/agent-bundle/src/effect/cli-runtime.ts @@ -0,0 +1,43 @@ +import type { Layer } from 'effect'; + +import { makeScopedEffectRuntime } from './boundary.ts'; +import { type CliServices, display, nodeCliServices, writeStderr, writeStdout } from './terminal.ts'; + +/** + * The first-party CLI's Effect terminal runtime, behind one Promise-shaped + * object so `src/cli.ts` can load it with a dynamic `import()` on the first + * command write instead of at module load. + * + * This module is the only thing that pulls `effect`, `effect/Terminal`, + * `effect/Stdio`, and the `@effect/platform-node-shared` layers into the CLI + * process. Loading that graph measured ≈250 ms on rc.112 (module loading; + * building the runtime and the layer is ≈6 ms), which is more than the rest + * of `agent-bundle --version` put together, so `--version`, `--help`, and + * argv errors must never reach this file. See the "Terminal and Stdio" + * section of `docs/effect-conventions.md`. + */ +export interface CliTerminal { + /** Finalizes the runtime's scope exactly once. */ + close(): Promise; + /** User-facing text to stdout through `Terminal.display`. */ + display(text: string): Promise; + /** A diagnostic to stderr through `Stdio.stderr()`. */ + writeStderr(text: string): Promise; + /** Machine output (canonical JSON) to stdout byte-exact through `Stdio.stdout()`. */ + writeStdout(text: string): Promise; +} + +/** + * Builds the CLI terminal over `services` (default: the process-backed Node + * `Terminal` + `Stdio` layers). One per `runCli` call: it is the CLI's + * composition root for the terminal services. + */ +export const makeCliTerminal = (services: Layer.Layer = nodeCliServices): CliTerminal => { + const runtime = makeScopedEffectRuntime(services); + return Object.freeze({ + close: (): Promise => runtime.close(), + display: (text: string): Promise => runtime.run(display(text)), + writeStderr: (text: string): Promise => runtime.run(writeStderr(text)), + writeStdout: (text: string): Promise => runtime.run(writeStdout(text)), + }); +}; diff --git a/packages/agent-bundle/src/effect/terminal.ts b/packages/agent-bundle/src/effect/terminal.ts index 5d25eaec5..94c3bcbde 100644 --- a/packages/agent-bundle/src/effect/terminal.ts +++ b/packages/agent-bundle/src/effect/terminal.ts @@ -10,10 +10,14 @@ import { Effect, Layer, type PlatformError, Stdio, Stream, Terminal } from 'effe * shells writes to `process.stdout` / `process.stderr` for user-facing text. * * The Node layers are provided exactly once, at the CLI composition root - * (`src/cli.ts`), right before the boundary runs the program; tests provide a - * capture layer instead. Emitted artifacts (routed CLI bins, hook wrappers, - * installers, MCP entries) never import this module: they stay self-contained - * and keep their raw stream adapters. See `docs/effect-conventions.md`. + * (`makeCliTerminal` in `./cli-runtime.ts`, which `src/cli.ts` loads lazily on + * the first command write so `--version` / `--help` never load Effect); tests + * provide a capture layer instead. Commander's own help, version, and argv + * error text is the one user-facing text that does not come through here: it + * is written synchronously before any command runs. Emitted artifacts (routed + * CLI bins, hook wrappers, installers, MCP entries) never import this module: + * they stay self-contained and keep their raw stream adapters. See + * `docs/effect-conventions.md`. */ /** The services a CLI output program needs. */ diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 426b1d17b..81e6ee211 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -1,7 +1,8 @@ -import { execFile as executeFile } from 'node:child_process'; +import { execFile as executeFile, spawn } from 'node:child_process'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; @@ -15,6 +16,7 @@ const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages/agent-bundle'); const cliPath = join(packageRoot, 'dist/cli.js'); +const recorderPath = join(packageRoot, 'tests/support/record-module-loads.mjs'); let buildPackage: Promise | undefined; const buildCliPackage = async (): Promise => { @@ -211,6 +213,67 @@ it('builds a selected target through the built executable from a path containing } }, 30_000 * timeScale); +/** + * Runs the built CLI under the module-load recorder and returns the process + * result plus every non-builtin module URL the invocation resolved. + */ +const runCliRecordingModuleLoads = async ( + args: readonly string[], +): Promise<{ readonly code: number; readonly modules: readonly string[]; readonly stderr: string; readonly stdout: string }> => { + const recordRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-module-loads-')); + const recordPath = join(recordRoot, 'modules.txt'); + try { + const child = spawn(process.execPath, ['--import', pathToFileURL(recorderPath).href, cliPath, ...args], { + cwd: workspaceRoot, + env: { ...process.env, AGENT_BUNDLE_RECORD_MODULE_LOADS: recordPath }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + const code = await new Promise((settle, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => settle(exitCode ?? 1)); + }); + const modules = (await readFile(recordPath, 'utf8')).split('\n').filter((line) => line.length > 0); + return { code, modules, stderr, stdout }; + } finally { + await rm(recordRoot, { force: true, recursive: true }); + } +}; + +const effectModulePattern = /\/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?(?:effect|@effect\/platform-node-shared)\//u; + +it('answers --version, --help, and an argv error without loading the Effect terminal runtime', async () => { + // The Effect module graph (`effect`, `effect/Terminal`, the + // platform-node-shared layers) measured ≈250 ms of module loading on + // rc.112 — more than the rest of the CLI's startup — so the trivial + // invocations must never reach it. Real commands build the runtime on + // their first write (checked last, so the recorder itself is proven). + await buildCliPackage(); + + const version = await runCliRecordingModuleLoads(['--version']); + expect(version).toMatchObject({ code: 0, stderr: '' }); + expect(version.stdout).toMatch(/^\d+\.\d+\.\d+.*\n$/u); + expect(version.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); + + const help = await runCliRecordingModuleLoads(['--help']); + expect(help).toMatchObject({ code: 0, stderr: '' }); + expect(help.stdout).toContain('Usage: agent-bundle'); + expect(help.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); + + const argvError = await runCliRecordingModuleLoads(['mcp', 'list', '--server', 'fixture']); + expect(argvError).toMatchObject({ code: 2, stdout: '' }); + expect(argvError.stderr).toContain("required option '--target ' not specified"); + expect(argvError.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); + + const command = await runCliRecordingModuleLoads(['hooks', 'list', '--artifact', join(workspaceRoot, 'missing artifact'), '--json']); + expect(command).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(command.stderr)).toMatchObject([{ code: 'AB6000', severity: 'error' }]); + expect(command.modules.some((url) => effectModulePattern.test(url))).toBe(true); +}, 30_000 * timeScale); + it('runs MCP and hook operations from a packed consumer with explicit and temporary artifacts', async () => { await buildCliPackage(); const source = await createServiceProject(); diff --git a/packages/agent-bundle/tests/support/cli-terminal.ts b/packages/agent-bundle/tests/support/cli-terminal.ts index 27d9e11aa..82933c6ab 100644 --- a/packages/agent-bundle/tests/support/cli-terminal.ts +++ b/packages/agent-bundle/tests/support/cli-terminal.ts @@ -3,11 +3,11 @@ import { Effect, Layer, Sink, Stdio, Terminal } from 'effect'; import type { CliOutput } from '../../src/cli.ts'; export interface CapturedCliTerminal { - /** Pass as the `runCli` output argument: a capture `Terminal` + `Stdio` layer instead of the process streams. */ + /** Pass as the `runCli` output argument: a capture `Terminal` + `Stdio` layer and capture argv-text sinks instead of the process streams. */ readonly output: CliOutput; - /** Everything written to stderr through `Stdio`. */ + /** Everything written to stderr: Commander's argv errors and `Stdio` diagnostics, in order. */ readonly stderr: () => string; - /** Everything written to stdout: `Terminal.display` text and `Stdio` machine output, in order. */ + /** Everything written to stdout: Commander's help/version text, `Terminal.display` text, and `Stdio` machine output, in order. */ readonly stdout: () => string; } @@ -16,9 +16,11 @@ const chunkText = (chunk: string | Uint8Array): string => /** * The test seam for the first-party CLI's terminal I/O: a `Terminal` whose - * `display` appends to a buffer, and a `Stdio` whose stdout/stderr sinks - * append to the same buffers, so tests never spy on `process.stdout`. - * `readLine` replays `lines` and then quits, like a closed stdin. + * `display` appends to a buffer, a `Stdio` whose stdout/stderr sinks append + * to the same buffers, and synchronous argv-text sinks (Commander's help, + * version, and argv errors) over those buffers too, so tests never spy on + * `process.stdout`. `readLine` replays `lines` and then quits, like a closed + * stdin. */ export const captureCliTerminal = (lines: readonly string[] = []): CapturedCliTerminal => { const out: string[] = []; @@ -37,7 +39,13 @@ export const captureCliTerminal = (lines: readonly string[] = []): CapturedCliTe stdout: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void out.push(chunkText(chunk)))), }); return Object.freeze({ - output: { services: Layer.merge(Layer.succeed(Terminal.Terminal, terminal), stdio) }, + output: { + argvText: { + stderr: (text: string) => void err.push(text), + stdout: (text: string) => void out.push(text), + }, + services: Layer.merge(Layer.succeed(Terminal.Terminal, terminal), stdio), + }, stderr: () => err.join(''), stdout: () => out.join(''), }); diff --git a/packages/agent-bundle/tests/support/record-module-loads.mjs b/packages/agent-bundle/tests/support/record-module-loads.mjs new file mode 100644 index 000000000..36ad95af5 --- /dev/null +++ b/packages/agent-bundle/tests/support/record-module-loads.mjs @@ -0,0 +1,29 @@ +/** + * `node --import` preload for the CLI cold-start proofs: records every module + * URL the process resolves (ESM and CJS, through the in-thread + * `module.registerHooks` resolve hook) and writes the list, one URL per + * line, to the file named by AGENT_BUNDLE_RECORD_MODULE_LOADS when the + * process exits. `node:` builtins are skipped. Plain `.mjs` so Node loads it + * without type stripping. + */ +import { writeFileSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import process from 'node:process'; + +const recordPath = process.env['AGENT_BUNDLE_RECORD_MODULE_LOADS']; +if (typeof recordPath !== 'string' || recordPath.length === 0) { + throw new Error('record-module-loads.mjs needs AGENT_BUNDLE_RECORD_MODULE_LOADS to name the output file.'); +} + +const loaded = []; +registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context); + if (!resolved.url.startsWith('node:')) loaded.push(resolved.url); + return resolved; + }, +}); + +process.on('exit', () => { + writeFileSync(recordPath, `${loaded.join('\n')}\n`); +}); diff --git a/packages/create-agent-bundle/src/index.ts b/packages/create-agent-bundle/src/index.ts index 5603f9c2a..2dae70e17 100644 --- a/packages/create-agent-bundle/src/index.ts +++ b/packages/create-agent-bundle/src/index.ts @@ -1,183 +1,47 @@ -import { spawn } from 'node:child_process'; - -import { cancel, intro, isCancel, log, multiselect, note, outro, select, text } from '@clack/prompts'; -import * as NodeServices from '@effect/platform-node/NodeServices'; -import { Effect, FileSystem, Path, Stdio, Stream, Terminal } from 'effect'; -import type { PlatformError } from 'effect/PlatformError'; - -import { mapCause, runPromise } from './effect/boundary.ts'; -import { liftPromise, liftTry } from './effect/lift.ts'; -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 = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const manifestPath = yield* path.fromFileUrl(new URL('../package.json', import.meta.url)).pipe( - // The URL is built from import.meta.url, so a `BadArgument` here is a bug. - Effect.orDie, - ); - const manifest = JSON.parse(yield* fs.readFileString(manifestPath)) as { readonly version: string }; - return manifest.version; -}); - -const runInstall = (options: ResolvedOptions, targetDirectory: string): Effect.Effect => - liftPromise(() => { - 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'); -}; +import { UsageError, helpText, parseFlags, type ParsedFlags } from './options.ts'; /** - * The scaffold run after flag parsing. Filesystem work goes through the - * `FileSystem` / `Path` services; the failure → exit-code contract is the - * CLI's: `UsageError` cancels with exit 2, anything else with exit 1. + * The argv layer of `create-agent-bundle`: parse the flags, answer `--help` + * and flag errors with plain synchronous writes, and only then load the + * scaffold (`./scaffold-cli.ts`: Effect, the Node platform layer, Clack) + * through a dynamic `import()`. The bundled scaffold chunk measured ≈40 ms + * of startup on rc.112, so the two trivial invocations never evaluate it. + * See the "Terminal and Stdio" section of `docs/effect-conventions.md`. */ -const scaffoldProgram = Effect.fnUntraced(function* ( - flags: ParsedFlags, -): Effect.fn.Return<0 | 1 | 2, PlatformError, FileSystem.FileSystem | Path.Path> { - // Reading this package's own manifest fails before the intro, exactly as - // it did as a rejected Promise: no cancel banner, the error leaves runCli. - const version = yield* ownVersion; - intro(`create-agent-bundle ${version}`); - const run = Effect.gen(function* () { - const path = yield* Path.Path; - const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; - const options = yield* liftPromise(() => resolveOptions(flags, { - interactive, - prompter: clackPrompter, - userAgent: process.env['npm_config_user_agent'], - })); - const frameworkSpec = yield* liftTry(() => resolveFrameworkSpec(version, options.frameworkVersion)); - const targetDirectory = path.resolve(process.cwd(), options.targetDir); - yield* assertScaffoldTarget(targetDirectory, options.targetDir); - const templateRoot = yield* path.fromFileUrl(new URL(`../templates/${options.template}`, import.meta.url)).pipe( - Effect.orDie, - ); - const files = yield* 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.`); +/** Synchronous text sinks for the argv layer's text: `--help` and flag errors. */ +export interface CliStreams { + readonly stderr: (text: string) => void; + readonly stdout: (text: string) => void; +} - if (options.install) { - const exitCode = yield* 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 as const; - } - } - - note(nextSteps(options), 'Next steps'); - outro('Project ready.'); - return 0 as const; - }); - // `catchCause`, not `catch`: template-drift rewrites throw plain Errors, - // which surface as defects, and they must cancel with the same message. - return yield* run.pipe(Effect.catchCause((cause) => Effect.sync((): 1 | 2 => { - const error = mapCause(cause); - if (error instanceof UsageError) { - cancel(error.message); - return 2; - } - cancel(error.message); - return 1; - }))); -}); - -/** `--help`: user-facing text, so it goes through the `Terminal` service (stdout). */ -const showHelp = Effect.gen(function* () { - const terminal = yield* Terminal.Terminal; - yield* terminal.display(helpText); - return 0 as const; -}); - -/** A flag error: the message and the help text on stderr through `Stdio` (`Terminal.display` is stdout-only). */ -const usageFailure = (error: UsageError) => Effect.gen(function* () { - const stdio = yield* Stdio.Stdio; - yield* Stream.run(Stream.make(`${error.message}\n\n${helpText}`), stdio.stderr()); - return 2 as const; +const processStreams: CliStreams = Object.freeze({ + stderr: (text: string): void => void process.stderr.write(text), + stdout: (text: string): void => void process.stdout.write(text), }); /** - * The whole CLI as one program over the platform services, so tests can run - * the help and flag-error paths against a capture `Terminal` / `Stdio` layer. - * A non-usage `parseFlags` failure is a bug and keeps throwing. + * Runs the CLI. `--help` goes to `streams.stdout` and exits 0; a flag error + * (`UsageError`) writes its message and the help text to `streams.stderr` and + * exits 2; a non-usage `parseFlags` failure is a bug and keeps throwing. + * Anything else scaffolds. Tests pass capture streams instead of spying on + * `process.stdout`. */ -export const cliProgram = ( - argv: readonly string[], -): Effect.Effect<0 | 1 | 2, PlatformError, FileSystem.FileSystem | Path.Path | Stdio.Stdio | Terminal.Terminal> => { +export const runCli = async (argv: readonly string[], streams: CliStreams = processStreams): Promise<0 | 1 | 2> => { let flags: ParsedFlags; try { flags = parseFlags(argv); } catch (error) { - if (error instanceof UsageError) return usageFailure(error); + if (error instanceof UsageError) { + streams.stderr(`${error.message}\n\n${helpText}`); + return 2; + } throw error; } - return flags.help ? showHelp : scaffoldProgram(flags); + if (flags.help) { + streams.stdout(helpText); + return 0; + } + const { runScaffold } = await import('./scaffold-cli.ts'); + return runScaffold(flags); }; - -export const runCli = (argv: readonly string[]): Promise<0 | 1 | 2> => - // The one composition root: the Node platform services are provided here - // and nowhere else in the package. Clack stays the prompt renderer; only - // the plain help and flag-error text goes through Terminal / Stdio. - runPromise(Effect.provide(cliProgram(argv), NodeServices.layer)); diff --git a/packages/create-agent-bundle/src/scaffold-cli.ts b/packages/create-agent-bundle/src/scaffold-cli.ts new file mode 100644 index 000000000..b8cdd1388 --- /dev/null +++ b/packages/create-agent-bundle/src/scaffold-cli.ts @@ -0,0 +1,159 @@ +import { spawn } from 'node:child_process'; + +import { cancel, intro, isCancel, log, multiselect, note, outro, select, text } from '@clack/prompts'; +import * as NodeServices from '@effect/platform-node/NodeServices'; +import { Effect, FileSystem, Path } from 'effect'; +import type { PlatformError } from 'effect/PlatformError'; + +import { mapCause, runPromise } from './effect/boundary.ts'; +import { liftPromise, liftTry } from './effect/lift.ts'; +import { resolveFrameworkSpec } from './framework.ts'; +import { + UsageError, + resolveOptions, + type ParsedFlags, + type Prompter, + type ResolvedOptions, +} from './options.ts'; +import { assertScaffoldTarget, scaffold } from './scaffold.ts'; + +/** + * The scaffold run: everything after argv parsing. This module is the only + * one in the package that loads Effect, the Node platform layer, and Clack, + * and `src/index.ts` imports it dynamically once it knows a scaffold is + * actually requested, so `--help` and a flag error never evaluate this + * graph (measured ≈40 ms of the bundled scaffolder's startup on rc.112). + */ + +/** 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 = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manifestPath = yield* path.fromFileUrl(new URL('../package.json', import.meta.url)).pipe( + // The URL is built from import.meta.url, so a `BadArgument` here is a bug. + Effect.orDie, + ); + const manifest = JSON.parse(yield* fs.readFileString(manifestPath)) as { readonly version: string }; + return manifest.version; +}); + +const runInstall = (options: ResolvedOptions, targetDirectory: string): Effect.Effect => + liftPromise(() => { + 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'); +}; + +/** + * The scaffold run after flag parsing. Filesystem work goes through the + * `FileSystem` / `Path` services; the failure → exit-code contract is the + * CLI's: `UsageError` cancels with exit 2, anything else with exit 1. + */ +const scaffoldProgram = Effect.fnUntraced(function* ( + flags: ParsedFlags, +): Effect.fn.Return<0 | 1 | 2, PlatformError, FileSystem.FileSystem | Path.Path> { + // Reading this package's own manifest fails before the intro, exactly as + // it did as a rejected Promise: no cancel banner, the error leaves runCli. + const version = yield* ownVersion; + intro(`create-agent-bundle ${version}`); + const run = Effect.gen(function* () { + const path = yield* Path.Path; + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; + const options = yield* liftPromise(() => resolveOptions(flags, { + interactive, + prompter: clackPrompter, + userAgent: process.env['npm_config_user_agent'], + })); + const frameworkSpec = yield* liftTry(() => resolveFrameworkSpec(version, options.frameworkVersion)); + const targetDirectory = path.resolve(process.cwd(), options.targetDir); + yield* assertScaffoldTarget(targetDirectory, options.targetDir); + + const templateRoot = yield* path.fromFileUrl(new URL(`../templates/${options.template}`, import.meta.url)).pipe( + Effect.orDie, + ); + const files = yield* 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 = yield* 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 as const; + } + } + + note(nextSteps(options), 'Next steps'); + outro('Project ready.'); + return 0 as const; + }); + // `catchCause`, not `catch`: template-drift rewrites throw plain Errors, + // which surface as defects, and they must cancel with the same message. + return yield* run.pipe(Effect.catchCause((cause) => Effect.sync((): 1 | 2 => { + const error = mapCause(cause); + if (error instanceof UsageError) { + cancel(error.message); + return 2; + } + cancel(error.message); + return 1; + }))); +}); + +/** + * Runs one scaffold for already-parsed flags. The one composition root: the + * Node platform services are provided here and nowhere else in the package. + * Clack stays the prompt renderer. + */ +export const runScaffold = (flags: ParsedFlags): Promise<0 | 1 | 2> => + runPromise(Effect.provide(scaffoldProgram(flags), NodeServices.layer)); diff --git a/packages/create-agent-bundle/tests/cli-text.test.ts b/packages/create-agent-bundle/tests/cli-text.test.ts index 135b07054..1c6bd9bc6 100644 --- a/packages/create-agent-bundle/tests/cli-text.test.ts +++ b/packages/create-agent-bundle/tests/cli-text.test.ts @@ -1,59 +1,42 @@ -import { Effect, FileSystem, Layer, Path, Sink, Stdio, Terminal } from 'effect'; import { describe, expect, it } from '@rstest/core'; -import { runPromise } from '../src/effect/boundary.ts'; -import { cliProgram } from '../src/index.ts'; +import { runCli, type CliStreams } from '../src/index.ts'; import { helpText } from '../src/options.ts'; /** - * The scaffolder's plain text — `--help` and a flag error — goes through the - * `Terminal` / `Stdio` services, so it is proven against a capture layer - * rather than by spying on `process.stdout`. Clack renders the prompts and is - * not under test here. + * The scaffolder's plain text — `--help` and a flag error — is written by the + * argv layer's synchronous sinks before the scaffold chunk (Effect, the Node + * platform layer, Clack) is ever loaded, so it is proven against capture + * sinks rather than by spying on `process.stdout`. Clack renders the prompts + * and is not under test here. */ -type CliLayer = Layer.Layer; - -/** - * Terminal and Stdio capture what the CLI writes; the filesystem is a noop - * stub because neither `--help` nor a flag error may touch it (a call would - * fail with `NotFound` and surface as a test failure). - */ -const captureLayer = (): { readonly layer: CliLayer; readonly stderr: () => string; readonly stdout: () => string } => { +const captureStreams = (): { readonly stderr: () => string; readonly stdout: () => string; readonly streams: CliStreams } => { const out: string[] = []; const err: string[] = []; - const decode = (chunk: string | Uint8Array): string => (typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); - const terminal = Terminal.make({ - columns: Effect.succeed(80), - display: (text) => Effect.sync(() => void out.push(text)), - readInput: Effect.die('key input is not used by create-agent-bundle'), - readLine: Effect.fail(new Terminal.QuitError({})), - rows: Effect.succeed(24), - }); - const stdio = Stdio.layerTest({ - stderr: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void err.push(decode(chunk)))), - stdout: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void out.push(decode(chunk)))), - }); return { - layer: Layer.mergeAll(Layer.succeed(Terminal.Terminal, terminal), stdio, FileSystem.layerNoop({}), Path.layer), stderr: () => err.join(''), stdout: () => out.join(''), + streams: { + stderr: (text) => void err.push(text), + stdout: (text) => void out.push(text), + }, }; }; describe('create-agent-bundle plain CLI text', () => { - it('prints --help through Terminal.display on stdout and exits 0', async () => { - const captured = captureLayer(); - const exitCode = await runPromise(Effect.provide(cliProgram(['--help']), captured.layer)); + it('prints --help on stdout and exits 0', async () => { + const captured = captureStreams(); + const exitCode = await runCli(['--help'], captured.streams); expect(exitCode).toBe(0); expect(captured.stdout()).toBe(helpText); expect(captured.stderr()).toBe(''); }); - it('prints a flag error and the help text through Stdio.stderr and exits 2', async () => { - const captured = captureLayer(); - const exitCode = await runPromise(Effect.provide(cliProgram(['one', 'two']), captured.layer)); + it('prints a flag error and the help text on stderr and exits 2', async () => { + const captured = captureStreams(); + const exitCode = await runCli(['one', 'two'], captured.streams); expect(exitCode).toBe(2); expect(captured.stdout()).toBe(''); From 70bb48846f8c0a986eb8b50193c05b4be1d70997 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:52:33 +0000 Subject: [PATCH 2/2] chore: reference #530 in the changeset --- .changeset/cli-cold-start-lazy-terminal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-cold-start-lazy-terminal.md b/.changeset/cli-cold-start-lazy-terminal.md index 9ec98badb..668b30886 100644 --- a/.changeset/cli-cold-start-lazy-terminal.md +++ b/.changeset/cli-cold-start-lazy-terminal.md @@ -3,4 +3,4 @@ "create-agent-bundle": patch --- -Restore CLI cold-start time by loading the Effect terminal runtime lazily. `agent-bundle --version`, `--help`, and argv errors answer in about 60 ms again (they had regressed to about 300 ms) because the Effect `Terminal` / `Stdio` runtime is now built on a command's first write instead of before argv parsing; command output, `--json` documents, and diagnostics are unchanged. `create-agent-bundle --help` and flag errors no longer evaluate the scaffold bundle (Effect, the Node platform layer, Clack), about 70 ms → 40 ms. +Restore CLI cold-start time by loading the Effect terminal runtime lazily. `agent-bundle --version`, `--help`, and argv errors answer in about 60 ms again (they had regressed to about 300 ms) because the Effect `Terminal` / `Stdio` runtime is now built on a command's first write instead of before argv parsing; command output, `--json` documents, and diagnostics are unchanged. `create-agent-bundle --help` and flag errors no longer evaluate the scaffold bundle (Effect, the Node platform layer, Clack), about 70 ms → 40 ms. (#530)