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 010 is ten (not octal), and 0x10 / 0b101 / 0o17 are errors for every numeric field. No hex floats either.
  • No _ separators. --count 1_000 is an error; that’s Zig literal syntax, not CLI syntax.
  • Floats additionally accept 1.5e3 exponents and the case-insensitive inf / 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 u8 captures 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:

FieldCLI shape
verbose: bool = falseflag: --verbose, and auto-negation --no-verbose
port: u16 = 8080valued, parsed as integer: --port 3000
env: ?[]const u8 = nullvalued, absent = null
region: []const u8required — no default means a value must arrive from somewhere
format: enum { json, text }valued, only listed variants accepted
tag: []const []const u8multi-value — repeat; delimiters are explicit

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

Each array-option occurrence contributes one element. Quoted text keeps its commas:

myapp card new --ac "Clear on blur, not on change" --ac "Keep focus"
# ["Clear on blur, not on change", "Keep focus"]

Opt into list shorthand per option when a delimiter is useful:

pub const meta = .{ .options = .{
    .tag = .{ .short = 't', .delimiter = ',' },
} };
myapp deploy --tag a,b --tag c   # ["a", "b", "c"]
myapp deploy -ta,b              # ["a", "b"]

Element types beyond strings parse too: []u32, []f64, and friends. Delimited values reject empty segments (a,,b), do not trim whitespace, and have no CSV quoting or escaping. Literal string options accept an empty element (--ac=). Delimiters must be a single non-whitespace, non-NUL byte, declared on an array option. Help distinguishes repeatable options from delimited repeatable options. Structured configuration arrays preserve their boundaries.

Greedy space-separated lists (--tag a b c) remain unsupported because they are ambiguous with interleaved positionals.

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'?

Reading text from stdin

Declare .stdin = true on a scalar []const u8 or ?[]const u8 option:

pub const Options = struct { goal: ?[]const u8 = null };
pub const meta = .{ .options = .{ .goal = .{ .stdin = true } } };
printf 'hello\n' | myapp card new --goal -

The command receives "hello\n" exactly, including the final newline. Empty input is valid. Reading happens after configuration resolution and before content validation or operational preparation. Only the final explicit CLI value - triggers a read: --goal - --goal text receives text without consuming stdin. Defaults, environment, and config values containing - stay literal. Options without this declaration also keep - literal.

Only one option can request stdin in an invocation. Multiple requests fail before reading. The application can set stdin_max_bytes in build/registry configuration; the default is 16 MiB, and content exactly at the limit is accepted. A zero limit accepts only empty input. Help/version do not read stdin.

To provide a literal hyphen to a stdin-enabled option, pipe it: printf '%s' '-' | myapp --goal -. Content is never reinterpreted as another source. Use invocation tests for argument parsing and input resolution; direct typed command tests receive ordinary resolved text.

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). Accumulating arrays do not read an environment fallback; pass repeated CLI values or a structured configuration array.

Where values come from

Every option resolves through the same cascade, highest priority first:

  1. CLI flag
  2. Environment variable (if the field declares .env)
  3. Config file (with the zcli_config plugin — command-scoped over global)
  4. 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