@cli Builder
The @cli builder turns an annotated struct into a complete CLI without writing
a parser by hand. Each field becomes an option or positional argument; defaults,
types, and help text come from the code itself — never drifting from the source.
Flags and defaults
Annotate the struct with @cli(name: "...") and each field with @arg(short, long, default, help). Args.parse_args(argv) takes an array of strings and
returns a typed instance:
Three calls to parse_args demonstrate: no flags (defaults), long form, and
short form with =value. No real process arguments are needed — the array is
injected directly, making the example runnable in the sandbox.
// Feature: `@cli` builder — declarative argument parsing
// Syntax: annotate a struct with `@cli(name: "...")`. Each field
// becomes a CLI option via `@arg(short, long, default, help)`.
// `Args.parse_args(argv)` parses an array and returns a typed instance.
// When to use: real CLI tools — instead of hand-rolling
// `process.argv()` parsing, get types, defaults, --help and --version
// for free.
@cli(name: "demo")
struct Args {
@arg(short, long, help: "Verbose")
verbose: bool,
@arg(long, short: "n", default: 10, help: "Count")
count: int,
}
// No flags — defaults kick in.
let a = Args.parse_args([])
print(a.verbose) // expected: false
print(a.count) // expected: 10
// Long form: --flag value
let b = Args.parse_args(["--verbose", "--count", "20"])
print(b.verbose) // expected: true
print(b.count) // expected: 20
// Short form, with =value syntax.
let c = Args.parse_args(["-v", "-n=42"])
print(c.verbose) // expected: true
print(c.count) // expected: 42
Positional arguments
@arg(positional) marks a field as positional. required makes it mandatory;
multiple collects all remaining positionals into a list:
Simulates cat main.txt x y z: the first positional goes into input, the rest
fill extras. Also runnable in the sandbox — no process dependency.
// Feature: `@arg(positional, ...)` — positional arguments
// Syntax: `positional` marks the field as a positional, `required`
// makes it mandatory, `multiple` collects all remaining args.
// When to use: file-input arguments (cat, mv, …), commands that
// take a target plus a variadic list of items.
@cli(name: "cat")
struct Args {
@arg(positional, required, help: "Input file")
input: str,
@arg(positional, multiple, help: "Extra files")
extras: [str],
}
// Single positional — extras stays empty.
let a = Args.parse_args(["main.txt"])
print(a.input) // expected: main.txt
print(a.extras.len()) // expected: 0
// Multiple — first goes to `input`, the rest fill `extras`.
let b = Args.parse_args(["main.txt", "x", "y", "z"])
print(b.input) // expected: main.txt
print(b.extras.len()) // expected: 3
print(b.extras[0]) // expected: x
print(b.extras[1]) // expected: y
print(b.extras[2]) // expected: z
Automatic --help and --version
With @cli(name, version) and help: "..." on each field, the runtime
generates --help and --version without additional code. The text never goes
stale because it comes directly from the declaration:
Normal parsing continues to work; to see the --help output, run
zolo run 11-cli-help.zolo -- --help locally. The sandbox does not support
--help via process.argv().
// Feature: `--help` and `--version` are auto-generated from the struct
// Syntax: `@cli(name, version)` plus per-field `help: "..."`. Passing
// `--help` prints usage + every flag with its help text and exits;
// `--version` prints the version line.
// When to use: every real CLI. The help text reflects the struct
// declaration, so it never drifts out of sync.
@cli(name: "demo", version: "1.0")
struct Args {
@arg(short, long, help: "Verbose output")
verbose: bool,
@arg(long, default: 10, help: "Number of items")
count: int,
}
// Normal parse — defaults flow through.
let a = Args.parse_args([])
print(a.verbose) // expected: false
print(a.count) // expected: 10
// To see the help text, run the file with `--help`:
// zolo run 11-cli-help.zolo -- --help
// → Usage: demo [OPTIONS]
// Options:
// -v, --verbose Verbose output
// --count <COUNT> Number of items (default: 10)
// -h, --help Print help
// -V, --version Print version
//
// Or `--version`:
// zolo run 11-cli-help.zolo -- --version
// → demo 1.0
Requires the Zolo CLI/host — open in the playground or run locally.
Subcommands
Annotate an enum with @subcommand in a field of the main struct. Each variant
becomes an independent subcommand (with its own flags if needed), and match
handles dispatch — the same pattern as git commit / cargo build:
tool -v build --arch x86_64, tool start, and tool test — three invocations
demonstrated by passing arrays directly, with no dependency on the real process.
// Feature: subcommands — `@subcommand` over an enum
// Syntax: declare an enum where each variant is a subcommand. A
// field with `@subcommand cmd: Command` becomes the dispatch slot.
// Each subcommand variant can declare its own flags via `@arg(...)`.
// When to use: tools shaped like `git commit`, `cargo build`, `kubectl get`.
// Variants without fields become no-flag subcommands; tuple/struct
// variants get their own flags.
enum Command {
Build { @arg(long) arch: str },
Start,
Test,
}
@cli(name: "tool")
struct Args {
@arg(short, long)
verbose: bool,
@subcommand
cmd: Command,
}
// Dispatch via `match` — the idiomatic shape for an enum.
fn run(args: Args) {
print(args.verbose)
match args.cmd {
.Build { arch } => print("build arch={arch}"),
.Start => print("start"),
.Test => print("test"),
}
}
// `tool -v build --arch x86_64`
run(Args.parse_args(["-v", "build", "--arch", "x86_64"]))
// expected:
// true
// build arch=x86_64
// `tool start`
run(Args.parse_args(["start"]))
// expected:
// false
// start
// `tool test`
run(Args.parse_args(["test"]))
// expected:
// false
// test
Challenge
Add a fourth subcommand Deploy { @arg(long) env: str } to the enum and
implement the corresponding arm in match. Call Args.parse_args(["deploy", "--env", "prod"]) and check the output.
See also