Introducing std/cli: Argv Dispatch as Branch Arms
Every CLI library answers the same three questions: what are the commands,
what are the flags, and who prints the help text. std/cli answers them the
way Koru answers most things — declarations for what is data, a transform for
what is code.
import std/cli
import std/io
std/cli:flag(verbose) {
"type": "boolean",
"short": "v",
"description": "chatter"
}
std/cli:command(run) {
"description": "run the thing",
"subcommands": ["check", "fire"]
}
std/cli()
| run { subcommand, file: string, verbose } |> std/io:print.ln("run {{ subcommand:s }} {{ file:s }} v={{ verbose:any }}")
| build |> std/io:print.ln("build")
| usage { message } |> std/io:print.ln("usage: {{ message:s }}")
| help text |> std/io:print.ln("{{ text:s }}") That is a working program. warp run check myfile.txt --verbose prints run check myfile.txt v=true; warp --help prints a generated usage page; warp run wat routes to the usage arm with the message run: unknown subcommand 'wat' (expected: check, fire). There is no builder chain, no
option registration, no handler table to keep in sync.
The branch set is the dispatch table
std/cli() resolves to the module’s default tor — the same bare-module
convention std/channel uses — and it is a transform. At comptime it walks
the program for std/cli:flag/std/cli:command declarations, walks its own
continuations for the command arms, and emits one bespoke argv parser plus
the dispatch. Each | name arm is a command; | build needs no declaration
at all — arms carry shape, declares carry prose.
The destructure is the arm’s argument grammar — the license std/json:parse takes with schemas, decided per-tor: the tor attached to a branch owns what
its destructure means. Here a field named subcommand binds the token after
the command (validated against the declare’s "subcommands" list), a field
matching a declared flag (verbose) binds the parsed flag, and everything
else is a positional in written order — file: string hands a []const u8,
the ints and floats convert at the splice, ?string admits a missing
trailing argument, []string binds the rest.
Help is generated, not maintained
The usage page is compiled from the same two sources: declarations for descriptions, arm destructures for the argument grammar:
input
usage: input <command> [args]
commands:
run <subcommand> <file> run the thing
build
flags:
-v, --verbose chatter
-h, --help show this help A command’s usage line is its arm’s field list — rename a positional in the
destructure and the help follows, because there is nothing else to edit. The
declares themselves are [comptime|norun] Source records, the same idiom compiler.kz’s flag.declare and command.declare use for koruc’s own
surface: name as the positional argument, metadata in the block body, data in
the AST that a consumer harvests.
usage and help are optional arms. Absent, the transform generates the
defaults — help prints and exits 0, a usage fault prints the message plus
the page and exits 2. Present, they are just arms: an arm that prints and
returns is the whole handling, and exit status is its call — the usage
payload is { message, help }, the help payload is the generated text, and both flow
through the same bindings as everything else.
Program identity lives elsewhere
One thing std/cli deliberately does not declare is the program’s own name
and version — identity is not a CLI concern. The transform reads a std/build:app block when one exists (it already describes the binary to the
OS for bundle packaging) and falls back to the entry-file stem. A library
that ships commands declares them next to the module that implements them;
the harvest sees them through import like std/invariants’s rules.
What it does not yet do: per-command flags (the flag table is program-global,
the same shape koruc’s own flags take), combined short forms (-vv), and -- passthrough — all three are emission details inside one transform, not
new grammar. The JS and C# facets are likewise unwritten; the transform
refuses loudly rather than emit Zig into another host.