Browse documentation

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 — 0 success, 1 failure, 2 unusable invocation; 70 is 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 --help is 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:

InvocationstdinBehavior
no input argumentsinteractive terminalprint help + hint to stderr, exit 2
no input argumentspipe, redirect, or closedread the program from stdin
- as the sole inputanything, terminal includedread the program from stdin
one or more pathsanythingread 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.ts covers every non-terminal path — piped stdin, closed stdin, explicit - — plus the --help text. 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 script invocation 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

Stdin conventions

  • - means standard input. It is recognized by IsStdinPath and 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.js and producer | goccia work without ceremony. It is not an interactive input mode — that is what GocciaREPL is for.

Exit codes

The codes actually in use, which new commands should follow:

CodeMeaning
0Success
1The work was attempted and failed — a script threw, a test failed, a path did not exist, an option value was invalid
2The 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
70The 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 malformed permissions block;
  • a config whose permission requests are not trusted (EGocciaConfigTrustError, whose message is the whole untrusted report), --trust with no terminal and no --yes, and conflicting trust options such as -P with --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=json or 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. GocciaRunner in host mode routes uncaught errors, syntax errors, and invalid option values through the shared error handler, which writes to stdout (usage errors exiting 2 go 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 -h where the shared option set is used) prints to stdout and exits 0. 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, or AddRepeatable and 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 --help alone 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

  1. Derive from TGocciaCLIApplication unless there is a concrete reason not to — it supplies option parsing, config discovery, --help, logging, the multifile split, and the no-argument rule.
  2. Implement Configure (declare options), UsageLine, and ExecuteWithPaths.
  3. If the command reads a program from stdin when given no path, override StdinUsage. Point users at GocciaREPL only where an interactive session is a sensible alternative.
  4. Override HonoredCapabilities and HonoredSettings to 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 exit 2, and ignores or warns about them in config; add the binary to the table in Permissions — What each binary honors. Override HonorsUnsafeRequests to return False when the binary runs no code, so a config's unsafe-* keys are warned about rather than needing trust. Only GocciaRunner overrides HonorsSandboxSection: the root config's sandbox section needs trust there and is warned about everywhere else.
  5. Check configs before anything runs: expand file inputs with ExpandMultifileFiles, whose ValidateFileConfigs pass validates every governing config and checks that its permission requests are trusted (call ValidateFileConfig for other inputs, such as stdin or a session's working directory, and VerifyGoverningConfigs for an explicit config path), and build engines through CreateEngine (or ResolveEngineCapabilities and FileConfigVerdict), which apply a config's grants only when its verdict accepts them. See Permissions — Config trust.
  6. Register the build target in build.pas and add the binary path to scripts/test-cli/binaries.ts.
  7. Add CLI behavior coverage under scripts/test-cli-*.ts; see Testing for which harness owns what.