Documentation
A batteries-included framework for building command-line interfaces in Zig. Drop .zig files in a directory and get a fully-featured CLI — with command discovery and routing wired up at compile time for zero-cost dispatch.
Fast path — zcli initrecommended
The quickest way to a working CLI: install the zcli tool, then scaffold a project. zcli init writes build.zig, build.zig.zon, src/main.zig, an example command, and fetches the dependency — with the default plugins already wired up.
Install the zcli CLI
Grab the binary (see the install page for all options):
$ curl -fsSL https://zcli.sh/install.sh | sh
Scaffold a project
Create a new project (or pass . to initialize the current directory). Add --description to set your app's tagline.
$ zcli init my-app --description "My awesome CLI" Creating new zcli project: my-app Creating build.zig.zon... Creating build.zig... Creating src/main.zig... Creating example command (hello)... Fetching dependencies (this may take a moment)... ✓ Project 'my-app' created successfully!
This generates the full structure for you:
my-app/ ├── build.zig — executable + zcli.generate() ├── build.zig.zon — zcli dependency └── src/ ├── main.zig — entry point └── commands/ └── hello.zig → my-app hello
Build & run
$ cd my-app $ zig build $ ./zig-out/bin/my-app hello World Hello, World! $ ./zig-out/bin/my-app --help USAGE my-app <command> [options]
That's the whole loop. Add another command by dropping a new .zig file in src/commands/ — jump to Commands.
Manual setup
Prefer to wire things up yourself, or adding zcli to an existing project? Here's everything zcli init does, by hand.
›Set up a zcli project from scratch — 4 steps
Add zcli to build.zig.zon
Add the dependency, then run zig build once — it prints the correct hash to paste in.
.dependencies = .{
.zcli = .{
.url = "https://github.com/ryanhair/zcli/archive/refs/heads/main.tar.gz",
.hash = "...", // zig build will tell you the correct hash
},
},
Set up build.zig
Create the executable, import the zcli module, then call zcli.generate to discover commands and produce the routing registry at build time. Turn on the default plugins with the zcli.builtin(.tag, .{}) shortcut — no paths to spell out.
const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const zcli_dep = b.dependency("zcli", .{ .target = target, .optimize = optimize, }); const zcli_module = zcli_dep.module("zcli"); const exe = b.addExecutable(.{ .name = "myapp", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }), }); exe.root_module.addImport("zcli", zcli_module); const zcli = @import("zcli"); const cmd_registry = zcli.generate(b, exe, zcli_dep, zcli_module, .{ .commands_dir = "src/commands", .plugins = &.{ zcli.builtin(.help, .{}), zcli.builtin(.version, .{}), zcli.builtin(.not_found, .{}), zcli.builtin(.completions, .{}), }, .app_name = "myapp", .app_description = "My CLI application", }); exe.root_module.addImport("command_registry", cmd_registry); b.installArtifact(exe); }
config or output too? Add zcli.builtin(.config, .{}) / zcli.builtin(.output, .{}). For your own plugins, set .plugins_dir = "src/plugins" — see the Plugins guide.Write main.zig
zcli uses Zig's structured entry point. main receives a std.process.Init — hand its allocator, I/O, environment, and args to the generated registry.
const std = @import("std"); const registry = @import("command_registry"); pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(init.arena.allocator()); var app = registry.init(); app.run(init.gpa, init.io, init.environ_map, args) catch |err| switch (err) { error.CommandNotFound => std.process.exit(1), else => return err, }; }
Write your first command
Drop a file in src/commands/. The example below maps to myapp hello.
const Context = @import("command_registry").Context; pub const meta = .{ .description = "Say hello" }; pub const Args = struct { name: []const u8, }; pub const Options = struct { loud: bool = false, }; pub fn execute(args: Args, options: Options, context: *Context) !void { const greeting = if (options.loud) "HELLO" else "Hello"; try context.stdout().print("{s}, {s}!\n", .{ greeting, args.name }); }
$ zig build && ./zig-out/bin/myapp hello world Hello, world!
Commands
Commands are .zig files in your commands directory; the file path becomes the command path. Create folders for subcommands, and add an index.zig to give a directory a description.
init.zig→myapp initusers/create.zig→myapp users createusers/index.zig→ describes theusersgroup (noexecute= shows subcommands)
Args & options
Every command has up to four exports. Declare Args and Options as structs — field defaults become option defaults, and parsing is type-checked at compile time.
pub const meta = .{ .description = "Add files to the index", .examples = &.{ "add file.txt", "add --all" }, .options = .{ .all = .{ .short = 'a', .description = "Add all files", }, }, .aliases = &.{ "a" }, }; pub const Args = struct { files: []const []const u8, // variadic: remaining args }; pub const Options = struct { all: bool = false, // --all / -a (flag) output: []const u8 = "text", // --output <value> count: u32 = 1, // --count <number> verbose: ?bool = null, // optional flag };
The context
Every command's execute takes a concrete context: *Context, imported from the generated registry: const Context = @import("command_registry").Context;. It's your handle to I/O, the allocator, the active theme, and plugin data — a real type, not anytype, so the compiler checks your usage and your editor can autocomplete it.
const Context = @import("command_registry").Context; pub fn execute(args: Args, options: Options, context: *Context) !void { const allocator = context.allocator; // per-run allocator const out = context.stdout(); // buffered stdout writer const in = context.stdin(); // stdin reader (prompts) // Plugin data is namespaced under context.plugins.{plugin_id} if (context.plugins.zcli_help.help_requested) return; try out.print("{s}\n", .{args.name}); }
_ directly in the signature: pub fn execute(_: Args, _: Options, context: *Context) !void. Plugin authors — lifecycle hooks take context: anytype, since a plugin can't import the registry it gets compiled into.Plugins
zcli ships seven plugins. Three are on by default in a freshly scaffolded project (zcli_help, zcli_version, zcli_not_found); the rest you opt into. Plugins contribute global options and lifecycle hooks, and store data in context.plugins.{plugin_id}.
Enable a default with the zcli.builtin(.help, .{}) shortcut, or auto-discover your own by pointing .plugins_dir at a folder. Full details — defaults, build-time config, and writing your own — live on the dedicated page:
Config files
The zcli_config plugin transparently loads option defaults from a config file — JSON, TOML, or YAML, with no changes to command code. Discovery follows: --config <path>, then ./{app}.config.*, then $XDG_CONFIG_HOME/{app}/config.*.
Values cascade: CLI flags > command config > global config > struct defaults.
output = "json" # global — applies to all commands [list] # scoped — applies only to "list" all = true
Interactive prompts
The zinput package provides 8 prompt types — text, confirm, select, multi-select, password, search, number, and editor. It works standalone (no zcli dependency) and falls back to line-based input when stdin isn't a TTY.
const name = try zinput.text(writer, reader, allocator, .{ .message = "Project name:", .default = "my-project", }); const features = try zinput.multiSelect(writer, reader, allocator, .{ .message = "Features:", .choices = &.{ "typescript", "eslint", "prettier" }, .defaults = &.{ true, true, false }, }); const port = try zinput.number(writer, reader, .{ .message = "Port:", .default = 3000, .min = 1, .max = 65535, });
Progress indicators
The zprogress package provides spinners (9 styles) and progress bars with ETA. Theming uses semantic colors that adapt to terminal capabilities.
var bar = zprogress.progressBar(.{ .total = 100, .show_eta = true }); var spinner = zprogress.spinner(.{ .style = .dots }); spinner.start("Loading..."); spinner.succeed("Done!"); // ✔ Done!