Introducing zcli: fast, slim, and type-safe command-line tools in Zig

zcli is a CLI framework for Zig: drop a .zig file into commands/ and it’s a command - discovered at build time, routed with no runtime cost, and type-checked by the compiler. Batteries-included means you get theming, common components, and a solid testing story out of the box. It’s MIT-licensed and available today. This post dives into why I built it.

The JS tax

I’ve always loved building command-line tools. I have often joined developer experience teams because I love creating great developer tooling. I love crafting a really great user experience for customers, but I think my favorite customers are other developers.

With most of the companies I’ve worked for, I have found that there are common utilities that are spread across different repos, random collections of scripts scattered between codebases, or even scripts that live directly on developer machines, which get shared organically, copied from one user’s machine to another. One of my passions has been to collect those tools, dig in to figure out what problem the scripts are trying to solve, and build a CLI shaped around the very best solutions, tailored to the products and processes of the company.

Along the way, I’ve tried a lot of different CLI frameworks. I built a company-wide CLI in Node using Pastel and Ink. We had a lot of frontend engineers, and the idea that they could possibly contribute, since Ink uses React, was appealing. I really loved the simplicity that came from building a CLI from a folder structure: create a folder, add a JS file with a function as the default export, and that function returns a React component. To me, it was incredibly elegant. It was also very heavy.

I’ve always held a strong belief that we’ve accepted too much bloat for the simplicity that a high-level language like JS affords us. I get it. JavaScript and TypeScript are two of my most loved languages, and I’ve probably spent more time with them than any other. It hurts, though, when I see that we’ve accepted that 40MB is the floor on the size of a CLI built with JS. It makes sense: we’re packaging an entire runtime, one that is built to do all sorts of things, and we package it up to run a set of scripts. Most JS-based CLIs have dependencies, and in the JS ecosystem that means transitive dependencies. Honestly, most CLIs don’t need this.

It also hurts speed. Having to load an entire JS runtime, then have that runtime parse JS files, takes time. Yes, we’re only talking hundreds of milliseconds, but I’ve learned that perceived time in a CLI matters. There’s a thin line between a CLI that feels responsive and a CLI that feels laggy, and it doesn’t take much to cross that line.

It feels like we’ve accepted this. A lot of AI tooling, especially CLIs, are being written in JS. Again, I understand why: there are many fantastic libraries and frameworks already built. A complex CLI can be built quickly, but that convenience comes at a cost: bloated binaries, slower load times, and, for long-lived tasks, high memory usage.

For a long time, I’ve dreamed of building a CLI framework to solve these issues. I’ve really enjoyed watching Zig grow, and have felt for a long time that it sits right at the intersection of simplicity and performance. zcli is the result of a long time iterating on these problems. Instead of >40MB binaries, you can build <1MB binaries. Instead of >150ms startup time (>300ms with any real content), you get <10ms on a command that loads a file from disk. See the numbers here.

Two principles

There were two core design principles I decided on when architecting zcli:

  1. Wherever possible, have only one way to do things, and
  2. Everything that can be done at compile time should be.

Zig was a perfect language for this. You define a command tree, as complex as you want, and the router is automatically built at comptime. This means no router loading at runtime. Arg and option parsing is determined at comptime. When an option is passed, it goes to machinery that was optimized at comptime exactly for that option. No hash maps, no string dispatch - instead you get exact parsing code for the type you defined. Using structs to define the inputs (args and options), you can specify exactly what you want from your user, layering on top of Zig’s powerful type system. Instead of diving deep here, I’ll talk about this more in a follow-up post.

Following the principle of a single way to do things led to choices that some may not agree with. That’s fine, I completely understand: there were certain features I had to actively decide against in order to keep the zcli patterns simple.

For example, Args and Options must be defined if you define the execute function. If you don’t, it’s not a runtime error, it’s a compile time error (core principle 2). To some, that may seem a bit heavy-handed, not being able to just import and use a struct from another file. (You can still do that, you just have to specify pub const Args = YourExternalType so it’s defined in your file). The benefits, to me, outweigh the cost. Having a structure that is followed consistently means less guessing. Errors are also much easier to design and can cleanly inform the user when something is wrong.

So, what does this look like in practice? Here’s src/commands/deploy.zig:

const Context = @import("command_registry").Context;

pub const meta = .{ .description = "Deploy your application" };

pub const Args = struct { service: []const u8 };
pub const Options = struct { env: []const u8 = "development" };

pub fn execute(args: Args, options: Options, context: *Context) !void {
    try context.stdout().print("Deploying {s} to {s}\n", .{ args.service, options.env });
}

Then run the following:

> zig build
...
> cd zig-out/bin
> myapp deploy api --env production
Deploying api to production

No registration, no router config, and --help comes free.

I’m pretty happy with the result. I’m sure it’s going to have some growing pains: being a batteries-included framework means there’s a lot of surface area, and with a lot of surface area, there will be issues. I feel confident in the structure, and building the zcli CLI on top of this, as the first use case of the framework itself, has made me more confident.

Enabling LLM generation

One of my recent goals was to make LLM generation of CLIs easier. People have very strong opinions here, and that’s fine. I want to make it easy for those who have a need for a CLI to be able to generate one cleanly. The benefit to following “one way to do things” also leads to LLMs being able to more easily reason about and iterate on CLI code. For example, if the model hallucinates a field name, it’s a build error, not a runtime surprise. I also gave LLMs deterministic tooling to add, remove, and change args and options, which strengthens the external contract. When changes to args and options are guaranteed to be correct, and problems with the use of those args and options are a compile time problem, the agent can iterate quickly.

Overall, zcli makes it surprisingly easy to get a small, portable CLI that does what you need, without disk space and memory bloat, and about as snappy as possible.

If this sounds interesting to you, please try it! zcli is MIT licensed, targets stable Zig 0.16.0, and the comparison to zig-clap and zli is on the site if you’re weighing options. Issues and PRs welcome on GitHub.