# building a terminal CLI in 0.16 > written against zig 0.16. patterns from [zigman](https://tangled.org/zzstoatzz.io/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](./versions/migration-0.16.md)). wire the writers like this: ```zig 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: ```zig 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`: ```zig 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](./build/distribution.md)). ## 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: ```zig 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. ## sources - zigman — CLI patterns (completions, SIGPIPE via head)