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 - 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, a removed flag or config key, a value given to a boolean flag, an --allow-* or limit the binary cannot honor, a command-line-only key or malformed permissions block in a config file, a config whose permission requests are not trusted, --trust without confirmation, a conflicting combination of the trust options |
70 | The engine abandoned the run: an engine-integrity fault reached the host, GocciaTestRunner 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 uses it for a missing manifest or an unknown option, and GocciaTOMLComplianceRunner for an unusable invocation. GocciaWasmTestRunner is driven by an external harness, so it stays tolerant otherwise: it warns about, then ignores, extra positional arguments. It has no trust store, so it takes -P to accept config permission requests (Config trust).
Raise TCLIUsageError (in CLI.Options) for a usage error. TGocciaApplication.Run catches it, prints Error: <message> to stderr, and exits 2 (EXIT_CODE_USAGE); binaries with their own argument parser do the same. The option layer already raises it for:
- a removed flag or config key, with a message naming the replacement (see Removed flags and keys);
- a value given to a boolean flag, such as
--print=false; - an
--allow-*flag for a capability, or a limit, the binary does not honor; - a command-line-only key, such as
allow-net, at the top level of a config file, and a malformedpermissionsblock; - a config whose permission requests are not trusted (
EGocciaConfigTrustError, whose message is the whole untrusted report),--trustwith no terminal and no--yes, and conflicting trust options such as-Pwith--ignore-config-permissions.
An invalid value for an accepted option — a bad unit, an empty or malformed scope, a config flag that is not exactly true or false — is still a TParseError and exits 1.
Be aware that the boundary is not clean everywhere yet. Other 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 through TCLIUsageError.
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.
GocciaRunnerin host mode routes uncaught errors, syntax errors, and invalid option values through the shared error handler, which writes to stdout (usage errors exiting2go to stderr); in sandbox mode, whose stdout is the guest's output and the diff, it writes them to stderr — decided from the raw arguments before any option value is parsed, so an invalid value is on stderr too. 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. - Override
HonoredCapabilitiesandHonoredSettingsto declare which capabilities the binary can grant (default: none) and which limits it applies (default: all). The base class hides the rest from--help, rejects them on the command line with exit2, and ignores or warns about them in config; add the binary to the table in Permissions — What each binary honors. OverrideHonorsUnsafeRequeststo returnFalsewhen the binary runs no code, so a config'sunsafe-*keys are warned about rather than needing trust. OnlyGocciaRunneroverridesHonorsSandboxSection: the root config'ssandboxsection needs trust there and is warned about everywhere else. - Check configs before anything runs: expand file inputs with
ExpandMultifileFiles, whoseValidateFileConfigspass validates every governing config and checks that its permission requests are trusted (callValidateFileConfigfor other inputs, such as stdin or a session's working directory, andVerifyGoverningConfigsfor an explicit config path), and build engines throughCreateEngine(orResolveEngineCapabilitiesandFileConfigVerdict), which apply a config's grants only when its verdict accepts them. See Permissions — Config trust. - 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.