building a terminal CLI in 0.16
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)", notsource <(tool ...). process-substitution + a builtin likecompletesilently fails to register in bash (tested — nothing registers). and the zsh script must end withcompdef _f tool(sourceable) — ending with_f "$@"is autoload-only and errorscompadd: can only be called from completion functionwhen eval'd. - never pipe
zig buildthroughhead: 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.