en2026-05-13

Building a Coding Agent in Zig: the CLI

Hypercode CLI illustration

In the first post of this series, we laid down the skeleton: a repo, a pinned Zig 0.16 compiler, a binary that prints hypercode 0.1.0. It's tidy, but it doesn't do anything useful.

This post adds the layer everything will flow through: the CLI. Which model to use, where the API key lives, what prompt to send. Three pieces of information, three possible sources (flag, environment variable, default), one priority rule to enforce.

Code is still on github.com/alexisbchz/hypercode.

What other agents look like

Before writing code, let's see what surface other agents expose.

claude --help
codex --help

They all follow the same shape:

ElementExample
Long flags--model, --api-key, --config
Short flags-h, -v
Environment variablesANTHROPIC_API_KEY, OPENAI_API_KEY
Positionalthe prompt, or a path to a file

For Hypercode, OpenRouter will be the default gateway (it routes to Anthropic, OpenAI, Poolside, etc. behind a single key). The minimum vital set is therefore:

  • --help and -h: print usage.
  • --version: print the version.
  • --model <name>: pick the model (else HYPERCODE_MODEL, else default).
  • --api-key <key>: the OpenRouter key (else OPENROUTER_API_KEY).
  • A positional: the prompt.

Not more for now. If we need --config <file> or --debug later, we'll add them when we actually need them.

The shape: Args

We start by declaring the target struct. All fields are optional — it's config.zig's job (later) to fill in the blanks.

src/cli.zig
pub const Args = struct {
    help: bool = false,
    version: bool = false,
    model: ?[:0]const u8 = null,
    api_key: ?[:0]const u8 = null,
    prompt: ?[:0]const u8 = null,
};

Why [:0]const u8 rather than []const u8? Because init.minimal.args.toSlice(arena) yields zero-terminated strings. Keeping the sentinel in our types avoids conversions on every assignment.

A parser with no magic

Three approaches to parsing:

  1. An external library — no, zero-dependency policy.
  2. comptime reflection over the Args struct — elegant, but opaque to a reader.
  3. A hand-written loop over argv — verbose, but every line is readable.

We pick 3. It's longer, it's less clever, and it's exactly what we want for the first cut.

The parse result is a Result union encoding success or a precise error:

pub const Result = union(enum) {
    ok: Args,
    unknown_flag: [:0]const u8,
    missing_value: [:0]const u8,
    too_many_positionals,
};

This is a useful pattern: rather than an error.UnknownFlag that loses the offending flag's identity, we carry the context directly in the variant. The caller switches on it and can print a specific message.

The loop:

src/cli.zig
pub fn parse(argv: []const [:0]const u8) Result {
    var out: Args = .{};
    var i: usize = 0;
    while (i < argv.len) : (i += 1) {
        assert(i < argv.len);
        const arg = argv[i];

        if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
            out.help = true;
        } else if (std.mem.eql(u8, arg, "--version")) {
            out.version = true;
        } else if (std.mem.eql(u8, arg, "--model")) {
            const value = take_value(argv, &i) orelse return .{ .missing_value = arg };
            out.model = value;
        } else if (std.mem.eql(u8, arg, "--api-key")) {
            const value = take_value(argv, &i) orelse return .{ .missing_value = arg };
            out.api_key = value;
        } else if (std.mem.startsWith(u8, arg, "--") or std.mem.startsWith(u8, arg, "-")) {
            return .{ .unknown_flag = arg };
        } else {
            if (out.prompt != null) return .too_many_positionals;
            out.prompt = arg;
        }
    }
    assert(i == argv.len);
    return .{ .ok = out };
}

The take_value helper consumes the next argument, or returns null if it's missing:

fn take_value(argv: []const [:0]const u8, i_inout: *usize) ?[:0]const u8 {
    assert(i_inout.* < argv.len);
    if (i_inout.* + 1 >= argv.len) return null;
    i_inout.* += 1;
    return argv[i_inout.*];
}

Two style notes:

  • The asserts document invariants — i < argv.len on iteration entry, i == argv.len on exit. The compiler doesn't enforce them, but a future reader reads them as contracts.
  • Comparisons use std.mem.eql(u8, a, b) rather than a == b. In Zig, slice equality isn't defined by default — you compare element by element explicitly.

Testing the parser

The parser is pure: no I/O, no allocation. Ideal for an exhaustive test table.

src/cli.zig
test "--help and -h both set help" {
    const long = parse(args_of(&.{"--help"}));
    try testing.expect(long.ok.help);
    const short = parse(args_of(&.{"-h"}));
    try testing.expect(short.ok.help);
}

test "--model with no value reports missing_value" {
    const r = parse(args_of(&.{"--model"}));
    try testing.expectEqualStrings("--model", r.missing_value);
}

test "flags and prompt can interleave" {
    const r = parse(args_of(&.{ "--model", "x/y", "fix it", "--api-key", "sk-abc" }));
    try testing.expectEqualStrings("x/y", r.ok.model.?);
    try testing.expectEqualStrings("fix it", r.ok.prompt.?);
    try testing.expectEqualStrings("sk-abc", r.ok.api_key.?);
}

The args_of helper exists purely to help type inference on the array literal. Eight tests cover every variant of the Result.

./zig/zig test src/cli.zig
All 8 tests passed.

Resolving the effective config

The parser only reads argv. To get a usable model, you have to consult three sources, in this priority order: CLI > env > default.

src/config.zig
pub const model_default: [:0]const u8 = "anthropic/claude-sonnet-4.6";

pub const env_api_key = "OPENROUTER_API_KEY";
pub const env_model = "HYPERCODE_MODEL";

pub const Config = struct {
    model: [:0]const u8,
    api_key: [:0]const u8,
    prompt: [:0]const u8,
};

pub const Result = union(enum) {
    ok: Config,
    no_api_key,
    no_prompt,
};

Same Result pattern as cli.zig. If the API key or the prompt is missing, we return an explicit variant rather than a generic error.

pub fn resolve(args: cli.Args, environ: std.process.Environ) Result {
    const model = pick(args.model, environ.getPosix(env_model), model_default);
    const api_key = args.api_key orelse environ.getPosix(env_api_key) orelse return .no_api_key;
    const prompt = args.prompt orelse return .no_prompt;

    assert(model.len > 0);
    assert(api_key.len > 0);
    assert(prompt.len > 0);

    return .{ .ok = .{ .model = model, .api_key = api_key, .prompt = prompt } };
}

fn pick(
    cli_value: ?[:0]const u8,
    env_value: ?[:0]const u8,
    fallback: [:0]const u8,
) [:0]const u8 {
    if (cli_value) |v| return v;
    if (env_value) |v| return v;
    return fallback;
}

Three things worth a word.

environ.getPosix. In Zig 0.16, std.process.Init exposes init.minimal.environ, which contains the environment without copying it into a Map. The getPosix method does no allocation — it's just a linear lookup in the environ block the kernel handed to the process.

The orelse chain. For api_key, we have a cascade: CLI? else env? else error. That's exactly what orelse expresses, with a final return that exits the function quietly.

The post-resolution asserts. Once model, api_key, and prompt are chosen, we check they're not empty. An environment variable can be set to "", and that's the kind of thing that creates silent bugs three months later. Fail-fast.

Wiring the CLI into main

main stays thin. Its only job is orchestration: parse, handle special flags, resolve config, do the work (which comes in the next post).

src/main.zig
pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const arena = init.arena.allocator();

    var stdout_buffer: [4096]u8 = undefined;
    var stdout_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
    const stdout = &stdout_writer.interface;

    var stderr_buffer: [1024]u8 = undefined;
    var stderr_writer: Io.File.Writer = .init(.stderr(), io, &stderr_buffer);
    const stderr = &stderr_writer.interface;

    const argv_all = try init.minimal.args.toSlice(arena);
    const argv = if (argv_all.len > 0) argv_all[1..] else argv_all;

    const parsed = cli.parse(argv);
    const args = switch (parsed) {
        .ok => |a| a,
        .unknown_flag => |f| return fail(stderr, "unknown flag '{s}'", .{f}),
        .missing_value => |f| return fail(stderr, "'{s}' requires a value", .{f}),
        .too_many_positionals => return fail(stderr, "too many positional arguments", .{}),
    };

argv_all[1..] skips argv[0] (the binary name). The exhaustive switch on parsed makes every error variant visible at the write site — if we add a variant to Result later, the compiler forces us to handle it here.

    if (args.help) {
        try print_help(stdout);
        try stdout.flush();
        return;
    }
    if (args.version) {
        try stdout.print("hypercode {s}\n", .{version});
        try stdout.flush();
        return;
    }

    const cfg = switch (config.resolve(args, init.minimal.environ)) {
        .ok => |c| c,
        .no_api_key => return fail(
            stderr,
            "no API key. Set {s} or pass --api-key.",
            .{config.env_api_key},
        ),
        .no_prompt => return fail(
            stderr,
            "missing prompt. Usage: hypercode [options] <prompt>",
            .{},
        ),
    };

    try stdout.print("model:  {s}\n", .{cfg.model});
    try stdout.print("prompt: {s}\n", .{cfg.prompt});
    try stdout.writeAll("(no model call yet — that's Post 03.)\n");
    try stdout.flush();
}

The fail helper centralises error output:

fn fail(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) !void {
    try stderr.print("error: " ++ fmt ++ "\n", args);
    try stderr.writeAll("Run `hypercode --help` for usage.\n");
    try stderr.flush();
    std.process.exit(2);
}

std.process.exit(2) is deliberate — exit code 2 is the POSIX convention for "bad usage", distinct from 1 (generic error) and 0 (success).

Discovering tests in sibling modules

Small Zig 0.16 subtlety: zig build test only runs test blocks in files reachable from the root module. Since cli.zig and config.zig's test blocks aren't referenced from main, they aren't discovered by default.

The idiomatic fix: a test block in main.zig that imports them explicitly:

src/main.zig
// Pull sibling modules into the test runner so their `test` blocks execute.
test {
    _ = @import("cli.zig");
    _ = @import("config.zig");
}

The _ = @import(...) forces module resolution during test compilation. Without it, the compiler skips those files, and their tests with them.

Checking everything works

./zig/zig build test --summary all
Build Summary: 3/3 steps succeeded; 11/11 tests passed

Help:

./zig-out/bin/hypercode --help
hypercode — an LLM coding agent

Usage: hypercode [options] <prompt>

Options:
  --model <name>     Model to use (default: anthropic/claude-sonnet-4.6,
                     or HYPERCODE_MODEL)
  --api-key <key>    OpenRouter API key (or OPENROUTER_API_KEY)
  --version          Print the version and exit
  -h, --help         Print this help and exit

The happy path:

OPENROUTER_API_KEY=sk-test ./zig-out/bin/hypercode "fix the bug"
model:  anthropic/claude-sonnet-4.6
prompt: fix the bug
(no model call yet — that's Post 03.)

The error paths:

./zig-out/bin/hypercode --bogus
error: unknown flag '--bogus'
Run `hypercode --help` for usage.
unset OPENROUTER_API_KEY
./zig-out/bin/hypercode "fix the bug"
error: no API key. Set OPENROUTER_API_KEY or pass --api-key.
Run `hypercode --help` for usage.

And exit 2 everywhere for misuse — check with echo $?.

The commits

Three commits, each with one new or modified file:

b806c90 feat(main): wire CLI + config; dry-run print resolved model and prompt
7ebc7de feat(config): resolve effective config from CLI > env > default
6e0ca7c feat(cli): hand-written argv parser

Splitting commit ↔ layer lets a new reader walk the code floor by floor: git show 6e0ca7c shows only the parser, in isolation.

Conclusion

We now have a CLI that gathers all the ingredients for a model call: which model, which key, which prompt. The binary prints them instead of using them — that's intentional. The next layer is network I/O, and it deserves its own post.

In the next article, we'll build a minimal HTTPS client in pure Zig (TLS via std.crypto.tls, request and response via std.Io). No curl, no libcurl, no wrapper — just the standard library and a socket.

Stuck, or want to share notes? Join the Discord server.