Skip to content

feat(cli): route first-party CLI terminal I/O through Effect Terminal/Stdio; spell routed-CLI input errors in CLI terms (#465) - #505

Merged
ScriptedAlchemy merged 7 commits into
mainfrom
feat/effect-terminal-cli-io
Sep 4, 2026
Merged

feat(cli): route first-party CLI terminal I/O through Effect Terminal/Stdio; spell routed-CLI input errors in CLI terms (#465)#505
ScriptedAlchemy merged 7 commits into
mainfrom
feat/effect-terminal-cli-io

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Adopts Effect's Terminal/Stdio services for the first-party agent-bundle CLI's terminal I/O and fixes #465: routed CLI executables now spell inputSchema rejections in CLI terms instead of printing the raw zod issue JSON.

Fixes #465

Effect version and layer wiring

  • Pinned line: effect@4.0.0-rc.112. Terminal is effect/Terminal, Stdio is effect/Stdio; the Node implementations are @effect/platform-node-shared/NodeTerminal and /NodeStdio — the package agent-bundle already depends on since feat(api): stage temporary artifacts and Codex schema output in Effect scoped temp directories (FileSystem phase 1, module 2) #508 (src/effect/platform.ts). No new dependency: the first version of this branch added @effect/platform-node, which feat(api): stage temporary artifacts and Codex schema output in Effect scoped temp directories (FileSystem phase 1, module 2) #508 rules out for agent-bundle because its non-optional redis peer adds ~23 MB to every consumer install; create-agent-bundle keeps NodeServices.layer from @effect/platform-node as before.
  • New src/effect/terminal.ts: display(text) (user-facing text → Terminal.display, stdout), writeStderr(text) (diagnostics → Stdio.stderr()), writeStdout(text) (machine output → Stdio.stdout(), byte-exact), and nodeCliServices = Layer.mergeAll(NodeTerminal.layer, NodeStdio.layer).
  • Composition root: runCli in src/cli.ts creates one makeScopedEffectRuntime(output.services ?? nodeCliServices) from the existing src/effect/boundary.ts and runs every write through it; the runtime closes when the command finishes (a foreground dev session keeps it until the session closes so close diagnostics can still be written). No other module provides the layers. Commander's lazy product imports are untouched.
  • Deviation from the design's NodeServices.layer, measured: loading the full service set (child-process/crypto/filesystem, and undici via the @effect/platform-node barrel) cost 582 ms vs 146 ms for the two subpaths on this machine — so the CLI composes the two narrow layers rather than the whole platformLayer, as docs/effect-conventions.md already prescribes for platform services.
  • Test seam: tests/support/cli-terminal.ts (Terminal.make({ display }) + Stdio.layerTest({ stdout, stderr })), passed as runCli(args, { services }). The former CliStreams stream injection is gone; every runCli test now uses the capture layer. No process.stdout spies remain in migrated coverage.
  • Terminal.display is stdout-only (Terminal.ts, NodeTerminal.ts), so stderr diagnostics go through Stdio.stderr() and canonical JSON (--json, the diagnostics document) through Stdio, never Terminal.

Inventory

Location Kind Verdict
src/cli.ts — human build/prepack/install/doctor/validate/eval/inspect output, Workbench startup URL line, Commander help/argv errors, --json documents, diagnostics document user-facing text / machine / diagnostics Migrated: text via Terminal.display; --json and diagnostics via Stdio. Commander's synchronous writeOut/writeErr are queued and replayed in order through the same services.
src/cli.tsdev proxy writeDiagnostic stderr diagnostics of the stdio MCP bridge Migrated to Stdio.stderr() through a serial chain (the bridge's stdout is JSON-RPC and stays untouched).
src/cli.tsmcp run inherits child stdio Unchanged: no parent stdout writes by design.
src/cli-entry.tsrunGeneratedCliEntry writeOut/writeErr ports, TTY detection, runGeneratedCliProcess exit/signal envelope emitted routed-CLI shell Left raw (protocol/artifact): the shell is bundled into every generated bin; it must not carry a platform runtime. Error mapping fixed here (#465).
src/build/entry-shell.ts — bin template parseInput, worker stderr forwarding emitted artifact Left raw; parseInput now calls cliInputError(command, input, error).
src/mcp-entry.ts, src/services/mcp-run.ts, src/mcp-server-runtime.ts (worker/notice stderr) MCP stdio JSON-RPC, child forwarding Left raw (protocol).
src/adapters/hook-contract.ts hook stdin / exact JSON result stdout Left raw (protocol, byte-exact).
src/install-entry.ts, src/install/surface.ts generated installer human/JSON output Left raw (emitted artifact); first-party agent-bundle install output migrated via src/cli.ts.
src/dev/playground/lifecycle-replay-service.ts, src/test/script.ts child log forwarding / stdout-containment proof Left raw.
packages/create-agent-bundle/src/index.ts--help (process.stdout.write(helpText)) and flag errors (process.stderr.write(message + helpText)) plain user-facing text Migrated (after #501 landed its NodeServices.layer root): cliProgram(argv) is one Effect over `FileSystem
packages/create-agent-bundle/src/index.ts — Clack intro/log/note/outro/prompts interactive prompt renderer Left on Clack per the design; not replaced by readLine.

#465 before/after

Typed CliInputError.issues: { target, expected, received?, message }[] built by cliInputError(command, input, error) (exported from agent-bundle/cli-entry, applied identically in the bin template, src/test/cli.ts, and src/test/render.ts). The first zod path segment maps through CompiledCliOption.key → option: positionals render <name>, flags --kebab-name, projected MCP paths --input.<path>, non-attributable issues input. Exit code stays 2.

Before (curator await --max-wait-ms 300000 ticket):

[
  {
    "origin": "number",
    "code": "too_big",
    "maximum": 55000,
    "inclusive": true,
    "path": ["maxWaitMs"],
    "message": "Too big: expected number to be <=55000"
  }
]
Run 'curator await --help' for usage.

After:

Invalid value for --max-wait-ms: expected number <= 55000; received 300000.
Usage: curator await [options] <ticket>
Run 'curator await --help' for usage.

Positional (inventory ""): Invalid value for <shelf>: expected non-empty string; received "".
Projected MCP (harness echo --input '{"message":42}'): Invalid value for --input.message: expected string; received 42.

--json: stdout empty, stderr one canonical line:

{"error":{"code":"CLI_INPUT_INVALID","issues":[{"expected":"number <= 8","message":"Too big: expected number to be <=8","received":9,"target":"--limit"}],"usage":"Usage: route-harness inventory [options] <shelf>"}}

--ndjson: one {"error":{...same error, plus the joined "message"...},"sequence":0,"type":"error"} event on stdout, stderr empty.

Sizes and startup

  • Emitted routed-CLI bin (examples/audiobook-curator, artifact/claude/bin/audiobook-curator.mjs): 3,145,847 → 3,151,702 bytes (+5,855 B, the issue-mapping code only); -flight.mjs unchanged at 3,350,707 B. No effect/Terminal or @effect/platform-node reference in any emitted bin or installer.
  • First-party dist/cli.js: 33.7 kB, effect and the two platform-node subpaths stay external.
  • agent-bundle --version cold start on this machine: ~60 ms → ~290 ms. The cost is loading effect (barrel ~225 ms; effect/Terminal alone ~145 ms because it imports Schema) plus the two Node layers; the same imports via effect/* subpaths measured ~170 ms, so switching terminal.ts/boundary.ts to subpath imports would recover ~60 ms if wanted. Recorded in docs/effect-conventions.md as the re-pin check.

Risks recorded

Docs

docs/effect-conventions.md (new "Terminal and Stdio: user-facing CLI text" section + adoption-table row, merged over #501's FileSystem adoption: its intro paragraph gains one pointer sentence, its layer-wiring bullet now states the first-party CLI's actual root — makeScopedEffectRuntime(nodeCliServices) in runCli — instead of NodeServices.layer before runPromise), docs/entry-conventions.md, docs/diagnostics.md (rendered into the site's diagnostics reference), website/docs/{en,zh}/guide/authoring/package-entries.mdx.

Verification

  • pnpm typecheck, pnpm lint, pnpm docs:site:build (language parity OK).
  • pnpm test:unit 3110 passed, pnpm test:route-unit 46, pnpm test:projection 151 (includes new tests/projection/cli-input-errors.test.ts: text, positional + multi-issue, --input.<path>, --json, --ndjson).
  • Integration (prebuilt): cli-routes-build.test.ts (packed executable asserts the exact stderr prose and the --json error object), cli.test.ts, package-build.test.ts, dev-workbench.test.ts, artifact-cli-bin.test.ts, eval-cli.test.ts — all green.
  • cli-routes.test.ts gains unit coverage of the mapping (bounds, enum, unknown key, missing value, nested MCP paths, non-schema fallback, every string refinement's operand) and the shell's text/--json rendering.
  • examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts asserted the old raw zod text (maxFiles, expected number to be >=1) for inspect --max-files 0; it now asserts the exact Generated routed CLI prints raw ZodError JSON for input validation failures instead of a plain-language flag error #465 stderr (Invalid value for --max-files: expected number >= 1; received 0. + usage + help hint). This was the Examples check failure on fc64516ba.
  • Release gates on fc64516ba also had packed-release.e2e.test.ts fail its Workbench outage-ledger quiet fence on a net::ERR_ABORTED for /api/logs/replay during the "Logs page heading" phase — a browser navigation aborting an in-flight replay fetch, unrelated to this branch (no Workbench or dev-server code changed). Re-run if it recurs.

Review status

  • Codex reviewed 74dcc262f (PR opened) and left one P2 thread on cli-entry.ts (invalid_format lost the startsWith/endsWith/includes operand). Addressed in fix(cli-entry): keep string-refinement operands and exact lengths in input-issue expectations: the expectation now reads string starting with "/", string ending with ".json", string containing "@", string matching /.../u, string with exactly 4 characters, or URL, with unit coverage for each refinement in the bounded grammar. Per this branch's working rule no PR comments are posted; the thread is resolved from this note.
  • Rebased twice over main (#504 moved the build action and doctor host-validation lines; #507 fixed the unrelated docs-site duplicate key that failed the first Docs site run). The Publish pkg.pr.new preview failure on ebf2c467c was a pkg.pr.new/Cloudflare 500 (put: ... (10043)), not a package problem.
  • Rebased a third time over main (#501 FileSystem phase 1 → conflict in docs/effect-conventions.md resolved as above, and the scaffolder's two plain writes migrated now that its NodeServices root exists; #452 added uninstall/lifecycle output to cli.ts against the old Output/writeMachine API — ported to humanUninstall + machine/display and tests/uninstall.test.ts to the capture layer). pull_request workflow runs stopped being created for this branch after the fc64516ba push (three pushes and a close/reopen produced check suites for every app except Actions); the same commits were verified by workflow_dispatch runs of ci.yml and docs.yml on the branch instead, recorded below.
  • Rebased a fourth time over main (#508 FileSystem phase 1 module 2 → docs/effect-conventions.md conflicts merged, @effect/platform-node dropped from agent-bundle in favour of the @effect/platform-node-shared it now depends on, my packed-fixture @types fix replaced by main's linkWorkspaceTypes; #519 touched cli.ts without conflict).
  • Head SHA at merge: 7e48cd792, all PR checks green (Changeset, Dependency review, Docs site, Examples check, Host install proofs, pkg.pr.new preview, RSC micro-eval, Release gates, Verify Node 24); 9577a3be4 before it also had all PR checks green (Changeset, Dependency review, Docs site, Examples check, Host install proofs, pkg.pr.new preview, RSC micro-eval, Release gates, Verify Node 24). Codex's last review is of 74dcc262f; unreviewed since: the review fix (417e2cb32), the packed-fixture/example-test fix (979a8d0c2), and the third rebase + scaffolder/uninstall port (9577a3be4). No @codex review comment was posted (this branch's working rule is body notes only); any thread opened after merge is answered in a follow-up PR.

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e48cd7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agent-bundle Patch
create-agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T02:06:01.535665Z 74dcc26 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74dcc262fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/agent-bundle/src/cli-entry.ts Outdated
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/effect-terminal-cli-io branch from d3de27f to ebf2c46 Compare September 4, 2026 02:19
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/effect-terminal-cli-io branch from 417e2cb to fc64516 Compare September 4, 2026 02:28
@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@505
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@505
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@505

commit: 7e48cd7

@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/effect-terminal-cli-io branch from b8e0f92 to 979a8d0 Compare September 4, 2026 03:09
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/effect-terminal-cli-io branch from 979a8d0 to 9577a3b Compare September 4, 2026 04:03
…/Stdio; spell routed-CLI input errors in CLI terms (#465)
…assert the #465 flag error in the audiobook-curator dispatch proof
…erminal/Stdio at the NodeServices root; port uninstall output to the CLI's Effect services after rebase
… the dependency agent-bundle already carries (#508); drop @effect/platform-node
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/effect-terminal-cli-io branch from 9577a3b to 7e48cd7 Compare September 4, 2026 04:49
@ScriptedAlchemy
ScriptedAlchemy merged commit e0ae9f0 into main Sep 4, 2026
13 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the feat/effect-terminal-cli-io branch September 4, 2026 05:22
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…art time (#530)

* 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.

* chore: reference #530 in the changeset
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generated routed CLI prints raw ZodError JSON for input validation failures instead of a plain-language flag error

1 participant