
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.
Before writing code, let's see what surface other agents expose.
claude --help
codex --help
They all follow the same shape:
| Element | Example |
|---|---|
| Long flags | --model, --api-key, --config |
| Short flags | -h, -v |
| Environment variables | ANTHROPIC_API_KEY, OPENAI_API_KEY |
| Positional | the 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).Not more for now. If we need --config <file> or --debug later, we'll add them when we actually need them.
ArgsWe start by declaring the target struct. All fields are optional — it's config.zig's job (later) to fill in the blanks.
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.
Three approaches to parsing:
comptime reflection over the Args struct — elegant, but opaque to a reader.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:
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:
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.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.The parser is pure: no I/O, no allocation. Ideal for an exhaustive test table.
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.
The parser only reads argv. To get a usable model, you have to consult three sources, in this priority order: CLI > env > default.
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.
mainmain stays thin. Its only job is orchestration: parse, handle special flags, resolve config, do the work (which comes in the next post).
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).
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:
// 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.
./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 $?.
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.
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.