CLI Conventions
Argument, stdin, exit-code, stream, and help conventions shared by the Goccia command-line binaries.
Executive Summary
- No-argument rule — a command that defaults its input to stdin must print help and quit when run with no input at an interactive terminal, never block on
ReadLn - Stdin — implicit for pipes and redirects, explicit via
-, and only ever the sole input - Exit codes —
0success,1failure,2unusable invocation;70is the test runner's engine-integrity abort and124the test262 timeout marker - Streams — machine-readable output modes own stdout; new diagnostics go to stderr
- Help — every option is described by its own declaration, so
--helpis generated, not hand-maintained
The no-argument rule
Several binaries take their program source from standard input when no path is given. Without a guard, running one bare at a terminal blocks inside ReadLn until the user signals end of input — Ctrl-D on a Unix console, Ctrl-Z then Enter on a Windows one — and the command looks hung, with no output explaining why. EndOfInputKeys in Goccia.CLI.Stdin is the single source for that key sequence, so the hint printed at runtime always names the right one for the platform.
The Command Line Interface Guidelines state the rule plainly: if a command expects something piped to it and stdin is an interactive terminal, it should display help immediately and quit, rather than just hanging the way cat does.
So, for every stdin-defaulting binary:
| Invocation | stdin | Behavior |
|---|---|---|
| no input arguments | interactive terminal | print help + hint to stderr, exit 2 |
| no input arguments | pipe, redirect, or closed | read the program from stdin |
- as the sole input | anything, terminal included | read the program from stdin |
| one or more paths | anything | read those paths, ignore stdin |
- is the documented escape hatch: passing it opts back in to reading the terminal, so the blocking behavior is still reachable on purpose.
Where the rule lives
The decision is a pure function in Goccia.CLI.Stdin, taking three booleans — has input arguments, has an explicit -, stdin is a terminal — and returning what the command should do. Keeping it free of I/O is what makes the whole matrix unit-testable without a real terminal; the platform TTY probe sits beside it but is never called from it.
TGocciaCLIApplication applies the decision once for every subclass. A command opts in by overriding StdinUsage; the default opts out, so commands that never source a program from stdin are untouched. Binaries with their own argument parser call the same decision function directly rather than restating the policy.
Do not re-derive the rule per binary. A second copy will drift.
Testing it
CI cannot allocate a pseudo-terminal from the CLI harness, so the split is:
- The decision matrix is covered by Pascal unit tests over the pure function.
scripts/test-cli-apps.tscovers every non-terminal path — piped stdin, closed stdin, explicit-— plus the--helptext. These are the paths the rest of the test harness depends on, and they must stay byte-for-byte unchanged.- The terminal path is verified by hand under a pty. The
scriptinvocation differs by platform:- BSD/macOS:
script -q /dev/null ./build/GocciaTestRunner < /dev/null - Linux (util-linux):
script -q -c "./build/GocciaTestRunner" /dev/null < /dev/null
- BSD/macOS:
Stdin conventions
-means standard input. It is recognized byIsStdinPathand is the only spelling; a file literally named-is not addressable and must be passed as./-.- Stdin is only ever the sole input. Mixing
-with file paths is rejected rather than silently interleaved, because the ordering of a stream against on-disk files is not meaningful. - Source read from stdin is named
<stdin>. Diagnostics, JSON envelopes, and source maps all use that name, so error output is stable regardless of how the source arrived. - Implicit stdin is for pipes. It exists so
goccia < app.jsandproducer | gocciawork without ceremony. It is not an interactive input mode — that is whatGocciaREPLis for.
Exit codes
The codes actually in use, which new commands should follow:
| Code | Meaning |
|---|---|
0 | Success |
1 | The work was attempted and failed — a script threw, a test failed, a path did not exist, an option value was invalid |
2 | The command could not be run as invoked — no input at a terminal, a missing required argument |
70 | The engine abandoned the run: an engine-integrity fault reached the host, GocciaTestRunner only |
124 | test262 timeout marker, GocciaScriptLoaderBare only |
Code 70 is sysexits' EX_SOFTWARE, "an internal software error has been detected", and it says something 1 cannot: the run stopped because the engine caught itself in an unsound state (a use-after-free, an invalid dereference, a broken heap — see ADR 0109), so no result the process produced should be believed. 1 means the opposite — the work was done and the answer is "failed". A harness that only distinguishes zero from non-zero keeps working unchanged; one that reports build health should surface 70 separately, because a suite that aborted is not a suite that failed.
Code 2 is the narrower one: it means the process did no work because the invocation itself was unusable. GocciaWasmTestRunner has used it for a missing manifest since it was introduced, and GocciaTOMLComplianceRunner uses it for an unusable invocation.
Be aware that the boundary is not clean everywhere yet. Errors raised out of Execute are caught centrally and become exit 1 regardless of whether they were a bad flag or a failed script, so some invocation errors — an unknown option, a rejected flag combination — still exit 1. Do not treat that as the pattern to copy; route genuinely new usage errors to 2.
Streams
- A machine-readable output mode owns stdout. When
--output=jsonor another structured mode is active, stdout must contain the envelope and nothing else — no progress markers, no per-test symbols, no summaries. Anything a human would want during such a run goes to stderr or is suppressed. This is why the runners gate reporter output on the output mode rather than only gating the final summary. - New diagnostics go to stderr. The no-argument help, path-not-found errors in the runners, and configuration warnings are all written there.
- Existing placement is uneven.
GocciaScriptLoaderroutes uncaught errors, syntax errors, and option errors through the shared error handler, which writes to stdout; the runners write their inline errors to stderr. Match the surrounding code when editing an existing path, and prefer stderr for anything new.
Help output
--help(and-hwhere the shared option set is used) prints to stdout and exits0. Help that is requested is the command's output. Help printed because the invocation was wrong is an error, so it goes to stderr with a non-zero exit.- Help text is generated from the option declarations. Adding an option with
AddFlag,AddString,AddInteger, orAddRepeatableand giving it a help string is all that is required — there is no second list to update, and option help should not be duplicated in prose. - A command whose input can come from stdin also gets an
Input:section describing the pipe form, the-escape hatch, and the exit code, so--helpalone answers "why did this just quit?". - Every option's help string is a sentence fragment in the imperative or descriptive mood, without a trailing period, matching the existing entries.
Adding a new CLI
- Derive from
TGocciaCLIApplicationunless there is a concrete reason not to — it supplies option parsing, config discovery,--help, logging, the multifile split, and the no-argument rule. - Implement
Configure(declare options),UsageLine, andExecuteWithPaths. - If the command reads a program from stdin when given no path, override
StdinUsage. Point users atGocciaREPLonly where an interactive session is a sensible alternative. - Register the build target in
build.pasand add the binary path toscripts/test-cli/binaries.ts. - Add CLI behavior coverage under
scripts/test-cli-*.ts; see Testing for which harness owns what.