Skip to content

building a terminal CLI in 0.16

nate
Jul 8, 20263 min read1 read

building a terminal CLI in 0.16

ritten against zig 0.16.

patterns from zigman — a single-binary tool that fetches the zig langref over https and prints/pages/opens sections. all the std surface a small CLI actually touches in the Io world.

entry point + stdout/stderr

pub fn main(init: std.process.Init) hands you gpa/io/args/env (see migration#juicy-main). wire the writers like this:

var out_buf: [16 * 1024]u8 = undefined;
var fw = std.Io.File.stdout().writer(io, &out_buf);
const out = &fw.interface;                       // buffered; flush at the end
var ew = std.Io.File.stderr().writer(io, &.{});  // unbuffered — diagnostics show immediately
const err = &ew.interface;

convention worth keeping: data on stdout, diagnostics on stderr (the rg/kubectl split). a "3 sections matched, narrow it" hint is stderr; the list itself is stdout, so it stays pipeable.

broken pipe is normal — catch it at the top

writing to stdout fails with error.WriteFailed the moment a downstream reader exits (| head, quitting less). don't let it crash the program:

pub fn main(init: std.process.Init) !void {
    run(init) catch |e| switch (e) {
        error.WriteFailed => {}, // stdout/pager closed early — clean exit
        else => return e,
    };
}

one-shot https GET into memory

std.http.Client needs .allocator + .io; fetch writes the body into a *Io.Writer:

var client: std.http.Client = .{ .allocator = a, .io = io };
defer client.deinit();
var body: Io.Writer.Allocating = .init(a);
errdefer body.deinit();
const res = try client.fetch(.{ .location = .{ .url = url }, .response_writer = &body.writer });
if (res.status != .ok) return error.HttpStatus;
return body.toOwnedSlice();

zig's TLS is pure-zig — https needs no libc, so the binary still cross-compiles fully static (see build/distribution).

page on a tty, raw when piped

detect the terminal, then route long output through $PAGER (default less -FRX-F makes short output print and exit instead of trapping you). pipe into the child's stdin:

const want_pager = !no_pager and (std.Io.File.stdout().isTty(io) catch false);

fn page(io: Io, env: Environ.Map, text: []const u8) bool {
    const pager = if (env.get("PAGER")) |p| (if (p.len > 0) p else "less -FRX") else "less -FRX";
    var child = std.process.spawn(io, .{
        .argv = &.{ "sh", "-c", pager },   // sh -c so $PAGER can carry flags
        .stdin = .pipe, .stdout = .inherit, .stderr = .inherit,
    }) catch return false;                 // no pager -> caller prints raw
    if (child.stdin) |sin| {
        var pbuf: [16 * 1024]u8 = undefined;
        var cw = sin.writer(io, &pbuf);
        cw.interface.writeAll(text) catch {}; // EPIPE if the user quits the pager early
        cw.interface.flush() catch {};
        sin.close(io);                        // EOF so the pager finishes
        child.stdin = null;
    }
    _ = child.wait(io) catch {};
    return true;
}

std.process.spawn(io, .{...}) with .stdin = .pipe gives you child.stdin: ?File; the same shape opens a browser — argv = &.{ opener, url } where opener is $BROWSER else open (macos) / xdg-open (linux), .stdin = .ignore.

two shell gotchas (not zig, but they bit while shipping this)

  • completion install: document eval "$(tool --completions zsh)", not source <(tool ...). process-substitution + a builtin like complete silently fails to register in bash (tested — nothing registers). and the zsh script must end with compdef _f tool (sourceable) — ending with _f "$@" is autoload-only and errors compadd: can only be called from completion function when eval'd.
  • never pipe zig build through head: it SIGPIPEs the build and kills RunSteps mid-execution, so a codegen step (e.g. generating a file) writes partial/stale output and the build "succeeds" with a wrong artifact. cost a confusing debugging detour.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions