Skip to content

refactor(cli): replace hand-rolled parsers with effect/unstable/cli - #52

Open
FreshlyBrewedCode wants to merge 4 commits into
mainfrom
33-parse-cli-with-effect-cli
Open

FreshlyBrewedCode wants to merge 4 commits into
mainfrom
33-parse-cli-with-effect-cli

Conversation

@FreshlyBrewedCode

@FreshlyBrewedCode FreshlyBrewedCode commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Part of #32 · Closes #33

src/cli.ts hand-rolled four argument parsers that disagreed with each other: parseFlags walks
argv at stride 2 and structurally cannot represent a boolean flag, which is why factory start
needed a second, different parser; init's flags were parsed inline in the import.meta.main
block; and parseServeArgs passed an unvalidated Number(portRaw) straight to Bun.serve, so
--port abc became NaN. Because usageError called process.exit inside a parser, none of it
could be tested — every existing CLI test bypassed argv and called the command functions directly
with structured options. This PR replaces all four parsers and the hand-maintained USAGE string
with a single effect/unstable/cli command tree, per ADR 0009 §5.

What changed

  • New src/cli-commands.ts defines factoryCommand — a root Command with six subcommands
    (init, serve, start, runs, log, run), each with typed Flag/Argument definitions,
    descriptions, and defaults matching the old USAGE string.
  • src/cli.ts drops USAGE, usageError, parseFlags, parseArgs, parseStartArgs, and
    parseServeArgs entirely. The import.meta.main block now runs Command.run(factoryCommand, …)
    under a CliEnvLayer (FileSystem.layerNoop, Path.layer, Stdio.layerTest seeded with real
    process.argv, a Terminal shim, and a ChildProcessSpawner shim).
    runCli/startCli/listRunsCli/logRunCli — the command bodies — are unchanged.
  • --port is now Flag.Int, so a non-numeric value is rejected at parse time with a clear error
    instead of reaching Bun.serve as NaN.
  • Exit code fix: Command.run fails with CliError.ShowHelp both for genuine parse errors and
    for "no subcommand given" / explicit --help (help renders from the command definition either
    way). ShowHelp.errors distinguishes the two — empty means help was all that happened. The
    import.meta.main catch now only calls process.exit(1) when that array is non-empty, so bare
    factory, --help, and -h exit 0 again (matching the pre-effect/unstable/cli behaviour),
    while --port abc, unknown flags, and missing required args/flags still exit 1.
  • New src/cli-argv.test.ts (28 tests) feeds argv arrays straight into a Command, covering
    defaults, required flags/arguments, the --watch boolean, the --clone/--git-name/
    --git-email group, --port rejecting non-numeric input, generated --help at the root and per
    subcommand, unknown-flag rejection, and — since the exit-code mapping lives in cli.ts's
    import.meta.main block, only observable by actually running the binary — a subprocess-spawned
    suite pinning the no-args/--help/-h exit code and confirming --port abc/unknown flags still
    exit non-zero.

Notes for reviewers

  • Help text is now generated from the Command/Flag/Argument definitions (with
    Command.withDescription/Flag.withDescription) rather than hand-maintained, and the library
    adds global flags the old CLI never had (--version, --wizard, --completions,
    --log-level) — out of scope for Parse CLI arguments with effect/unstable/cli #33, not regressions.
  • CliEnvLayer in src/cli.ts reuses the effect/cli library's own test-fixture layer shape
    (Stdio.layerTest, Terminal.make with readInput/readLine: Effect.die, a
    ChildProcessSpawner that dies on use) as the production entrypoint's environment. I checked
    whether a real platform layer exists to swap it for: effect@4.0.0-rc.115 exports no such thing
    — no @effect/platform-node/@effect/platform-bun equivalent is installed, and Stdio,
    Terminal, FileSystem, and ChildProcessSpawner in this rc only ship test/noop constructors.
    So CliEnvLayer stays as-is; I added a comment on it explaining why and which paths (--wizard,
    anything reading stdin, anything shelling out through ChildProcessSpawner) would hit a bare
    Effect.die("unused") if ever exercised. Everything this PR's commands do goes through
    Console for output and real process.argv for input, so it's unaffected.
  • All process.exit calls that remain (cli-commands.ts, plus logRunCli's in cli.ts, plus the
    single process.exit(1) in the import.meta.main catch) are outside argv parsing — satisfies
    the "no process.exit in a parsing function" criterion.
  • The exit-code fix deliberately does not use Effect.runPromiseExit +
    Runtime.defaultTeardown/makeRunMain (effect's usual main-entrypoint helper): that teardown
    calls process.exit(0) on any successful Effect completion, but factory serve's handler
    effect resolves right after starting the long-lived HTTP server — forcing an exit there would
    kill the daemon immediately after startup. The fix stays inside the existing .catch() shape
    instead, so a successful run still falls through to whatever keeps (or doesn't keep) the process
    alive on its own.

Verification

  • bun run check (format:check + lint + typecheck + bun test) passes locally: 289 pass, 0 fail.
  • Manually ran a matrix of hand invocations (bare factory, --help, -h, serve --help,
    serve --port abc, serve --nope, unknown subcommand, missing required flag/argument,
    $FACTORY_URL fallback, --clone/--git-name/--git-email) and confirmed exit codes and
    stdout/stderr routing match the pre-effect/unstable/cli CLI.
  • Manually started factory serve --port 0 and confirmed it stays listening (doesn't exit after
    startup) under the new exit-code handling.
  • I did not additionally run bun run test:e2e.

Stack

  1. refactor(cli): replace hand-rolled parsers with effect/unstable/cli #52 — 33-parse-cli-with-effect-cli (issue Parse CLI arguments with effect/unstable/cli #33) ← you are here
  2. refactor(errors): represent domain failures as Schema.TaggedError #53 — 34-domain-errors-as-tagged-errors (issue Represent domain failures as Schema.TaggedError #34)
  3. refactor(runtime): move chunk interpretation into the agent adapter #54 — 35-move-chunk-interpretation-into-adapter (issue Move chunk interpretation into the agent adapter #35)
  4. refactor(runtime): move headless permission setup into the agent adapter #55 — 37-move-headless-permissions (issue Move headless permission setup out of the workspace allocator #37)
  5. feat(runtime): add Effect composition root and agent runtime service #56 — 36-agent-runtime-service (issue Add an Effect composition root and make the agent runtime a service #36)
  6. refactor(daemon): move singletons into per-daemon layers #57 — 38-move-singletons-into-layers (issue Move the daemon's remaining singletons into layers #38)

Stack created with GitHub Stacks CLI • Give Feedback 💬

@FreshlyBrewedCode
FreshlyBrewedCode force-pushed the 33-parse-cli-with-effect-cli branch from 77daec6 to 8de9136 Compare September 20, 2026 12:58
@FreshlyBrewedCode
FreshlyBrewedCode added this pull request to stack #58 September 20, 2026 13:00
@FreshlyBrewedCode FreshlyBrewedCode changed the title 33 parse cli with effect cli refactor(cli): replace hand-rolled parsers with effect/unstable/cli Sep 20, 2026
FreshlyBrewedCode added a commit that referenced this pull request Sep 23, 2026
Commit a507d1e's conflict resolution against 33-parse-cli-with-effect-cli
silently reverted PR #52: src/cli.ts's import.meta.main block regressed to
the pre-#52 hand-rolled USAGE/parseFlags/usageError/parseArgs parser, even
though src/cli-commands.ts's factoryCommand (the effect/unstable/cli command
tree) and its tests kept passing in isolation — so CI stayed green while the
shipped binary silently lost generated help, typed flag validation, and
--wizard/--completions.

Restore the base branch's entrypoint (import { factoryCommand } from
"./cli-commands"; Command.run(factoryCommand, ...)) while keeping this PR's
actual new work intact: the ManagedRuntime/AgentRuntimeLayer composition root
in runCli, which resolves the adapter from factory.config.ts (falling back to
opencodeAdapter) and threads a ManagedRuntime into startRun. Also restore
`prepareWorkspace: options.clone !== undefined`, which the same bad merge
had silently dropped from runCli's startRun call.

cli-commands.ts's runCommand was hardcoding `adapter: opencodeAdapter` on
every `factory run` invocation, which bypassed runCli's config-driven adapter
resolution and defeated issue #36's "runtime selectable from
factory.config.ts" criterion for the direct-run path. Drop that override so
runCli's own fallback (options.adapter ?? config.agent.adapter ??
opencodeAdapter) decides.

Add tests that exercise src/cli.ts's actual import.meta.main entrypoint (the
same path bin/factory.js runs in production), not just factoryCommand in
isolation: a static check that the file contains no hand-rolled parser, and
spawned-process checks that --help renders effect/unstable/cli's generated
help and that `serve --port abc` is rejected by the typed Int flag. Without
these, this class of regression can pass CI again undetected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the four hand-rolled argument parsers with a single command tree
built from effect/unstable/cli. Each command (init, serve, start, runs,
log, run) is defined with typed flags and positional arguments.

The new argv tests feed argv directly to the parser via Command.runWith
rather than bypassing it with structured options. Flag.Int rejects
non-numeric values for --port, Flag.Boolean handles --watch correctly,
and all required/optional flags are validated by the framework.
…ed parsers

Replace the import.meta.main dispatch block with Command.run(factoryCommand).
Remove the USAGE constant, parseFlags, parseArgs, parseStartArgs,
parseServeArgs, and usageError — help text is now generated from the
command definitions, flag values are validated before reaching command
bodies, and no process.exit call remains inside a parsing function.

The command body functions (runCli, startCli, listRunsCli, logRunCli,
watchSse) are untouched — they are wrapped at the Effect boundary in
cli-commands.ts. Exit codes are preserved: 0 completed, 1 failed,
130 cancelled.
…rrors

Command.run(factoryCommand) fails with CliError.ShowHelp both when no
subcommand is given and for genuine parse/validation errors, so the blanket
`.catch(() => process.exit(1))` was mapping bare `factory` (and anything else
that only renders help) to exit 1 instead of the pre-effect/unstable/cli
behaviour of exit 0. ShowHelp.errors distinguishes the two cases — empty
means "help was all that happened" — so only map that case to exit 0; a
populated errors array (bad flag value, unknown flag, missing argument, ...)
still exits 1.

Avoid delegating to Runtime.defaultTeardown/makeRunMain for this: it calls
process.exit(0) on any successful Effect completion, which would kill
`factory serve` right after it starts its long-lived HTTP server.

Adds subprocess-spawned regression tests in cli-argv.test.ts pinning the
no-args/--help/-h exit codes and confirming --port abc and an unknown flag
still exit non-zero — the exit-code mapping lives in cli.ts's
import.meta.main block, so it's only observable by running the binary.
effect@4.0.0-rc.115 only exports test/noop constructors for Stdio/Terminal/
FileSystem/ChildProcessSpawner (Stdio.layerTest, FileSystem.layerNoop,
Terminal.make, ChildProcessSpawner.make) — no @effect/platform-node or
@effect/platform-bun equivalent is installed, so there is no real platform
layer to swap CliEnvLayer for. Record why the test-fixture shapes are used
for the real binary and which paths would break if they were ever exercised,
so the next reader doesn't have to re-derive it.
@FreshlyBrewedCode
FreshlyBrewedCode force-pushed the 33-parse-cli-with-effect-cli branch from a52788c to 90186ba Compare September 23, 2026 07:08

This branch has not been deployed

No deployments
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.

Parse CLI arguments with effect/unstable/cli

1 participant