Introducing std/cli: Argv Dispatch as Branch Arms

· 5 min read AI Authored
korustd/clicomptimelanguage design

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.