Args & options
Declare Args and Options as plain structs. The field types drive the parser — generated at compile time for exactly your command, type-checked end to end. Field defaults become option defaults; a field with no way to be absent becomes a required option.
pub const meta = .{
.description = "Add files to the index",
.examples = &.{ "add file.txt", "add --all" },
.options = .{
.all = .{ .short = 'a', .description = "Add all files" },
},
};
pub const Args = struct {
files: []const []const u8, // variadic: captures remaining args
};
pub const Options = struct {
region: []const u8, // --region <value> (required — no default)
all: bool = false, // --all / -a (flag)
output: []const u8 = "text", // --output <value>
count: u32 = 1, // --count <number>
verbose: bool = false, // --verbose (flag)
};
Parsing is GNU-style: options and positionals interleave freely, --option value and --option=value both work, and -- ends option parsing so everything after it is positional.
Option syntax
A short option spells its value the same three ways the long form does, and the = is syntax rather than data — exactly one leading = is stripped:
myapp deploy --output json # long, separate token
myapp deploy --output=json # long, attached
myapp deploy -o json # short, separate token
myapp deploy -ojson # short, attached
myapp deploy -o=json # short, attached with '=' — same thing
(-o=json sets json, never =json. A value that really starts with = is written -o==json or -o "=json".)
Short flags bundle, GNU-style — tar -czf archive.tgz works here too:
myapp deploy -vq # -v -q
myapp deploy -vqf out.txt # -v -q -f out.txt
myapp deploy -vqfout.txt # same
The first value-taking char ends the bundle and claims the rest of the token — or the next token, if it’s last. So chars after a value-taking short are its value, not more flags: with a value-taking -f, -fv x sets f to v and leaves x a positional. A following token is only taken as a value if it isn’t itself an option: -f --verbose is a missing value, not a swallowed flag.
Shorts are single ASCII characters and exist only where meta.options.<field>.short declares one — no flag is derived from a field’s first letter.
Numbers
Numbers use one grammar everywhere — integer fields and float fields, options and positionals, CLI values and env values:
- Decimal only.
--port 010is ten (not octal), and0x10/0b101/0o17are errors for every numeric field. No hex floats either. - No
_separators.--count 1_000is an error; that’s Zig literal syntax, not CLI syntax. - Floats additionally accept
1.5e3exponents and the case-insensitiveinf/infinity/nan.
The same grammar decides whether a --leading token is a negative number or a flag, so --offset -5 and a positional -1.5e3 are values, while -x is an option.
-- ends the option namespace it appears in. Before the command name it is consumed by routing — myapp -- deploy runs deploy, so the myapp -- "$@" wrapper idiom is safe for user data that starts with a dash. After the command name it reaches that command’s own parser: myapp add -- --weird stores --weird as a positional. (A single-command app shares the top-level namespace, so myapp -- -x hands its root command -x as a positional.)
Positional args
Args fields bind positionals in declaration order:
- Required — a bare field:
service: []const u8 - Optional — an optional with a default:
tag: ?[]const u8 = null(must come after required args) - Variadic — a final
[]const []const u8captures everything remaining
Required args come first, optionals after, a variadic last — the scaffolder (zcli add arg) enforces the ordering for you.
Option shapes
The field’s type says how the flag behaves:
| Field | CLI shape |
|---|---|
verbose: bool = false | flag: --verbose, and auto-negation --no-verbose |
port: u16 = 8080 | valued, parsed as integer: --port 3000 |
env: ?[]const u8 = null | valued, absent = null |
region: []const u8 | required — no default means a value must arrive from somewhere |
format: enum { json, text } | valued, only listed variants accepted |
tag: []const []const u8 | multi-value — repeat or comma-separate |
Per-field metadata lives in meta.options: .short for a one-letter flag, .description for help, .name to override the long flag spelling, .env to bind an environment variable, plus .validate and .requires and a dynamic .complete hook for shell completions.
Boolean negation
Every boolean flag gets a generated --no-<flag> twin, so users can override a config-file or env default back to false: --verbose / --no-verbose. (Naming a field no_something is a compile error — it would collide with the generated negation.)
Multi-value options
An array field accepts multiple values, by repetition or comma-separated — and the two compose:
myapp deploy --tag a --tag b # [a, b]
myapp deploy --tag a,b # [a, b]
myapp deploy --tag a,b --tag c # [a, b, c]
Element types beyond strings parse too: []u32, []f64, and friends. Greedy space-separated lists (--tag a b c) are deliberately not supported — they’re ambiguous with interleaved positionals. Empty segments (--tag a,,b) are rejected. Help marks these options (repeatable).
Nothing caps how many values accumulate or how often a flag repeats — a docker run --env or cc -I shaped CLI can repeat a flag as many times as the shell allows. Parsing cost is linear in the input and values are borrowed, not copied, so there is nothing to protect against. (The one parsing limit is on the name: an option name longer than 256 bytes is rejected before lookup, which bounds the only superlinear step — the “did you mean” scoring.)
Enums: only valid values, helpful failures
An enum option documents itself — help shows (one of: dev, staging, prod) — and a near-miss gets a suggestion:
$ myapp deploy --env stagin
Invalid value 'stagin' for option '--env'. Expected one of: dev, staging, prod. Did you mean 'staging'?
Environment variables
Bind an option to an env var with .env — the name is used verbatim, no implicit prefixing:
pub const Options = struct {
api_key: []const u8, // required — but satisfiable via the env var
};
pub const meta = .{
.options = .{
.api_key = .{ .env = "MYAPP_API_KEY" },
},
};
For booleans, the env value accepts 1/true/yes and 0/false/no (case-insensitive). For arrays, a single env value is comma-split like a CLI value.
Where values come from
Every option resolves through the same cascade, highest priority first:
- CLI flag
- Environment variable (if the field declares
.env) - Config file (with the
zcli_configplugin — command-scoped over global) - Struct default
Required options
A field that is not a bool, not optional, not an array, and has no default is required: the type says a value must exist, and any source in the cascade can supply it. If none does:
$ myapp deploy
Missing required option '--region'. Expected text.
Help marks these (required). This is the type system doing the work — there’s no .required = true annotation to forget.
Next
- Validation & constraints — value rules, custom parse types, mutually-exclusive sets
- Completion authoring — dynamic
.completehooks and the.file/.dirbuiltins - Config files — the file half of the cascade