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.
Quick start3 commands
Install the meta-CLI, scaffold a project, add a command. That's the whole loop.
Install the zcli CLI
See the install page for every option, including shell completions.
$ curl -fsSL https://zcli.sh/install.sh | sh
Scaffold a project
$ zcli init myapp --description "My awesome CLI" Creating new zcli project: myapp Creating build.zig.zon... Creating build.zig... Creating src/main.zig... Creating example command (hello)... Creating AGENTS.md... Fetching dependencies (this may take a moment)... ✓ Project 'myapp' created successfully!
Add a command & build
$ cd myapp $ zcli add command deploy --description "Deploy your application" ✔ Created src/commands/deploy.zig $ zig build $ ./zig-out/bin/myapp --help USAGE myapp <command> [options] deploy Deploy your application hello Say hello
Add more commands the same way — jump to Commands for the file-path rules, or AI agents if a coding agent is driving.
›Manual setup — wiring build.zig by hand — skip if zcli init worked
Add zcli to build.zig.zon
$ zig fetch --save https://github.com/ryanhair/zcli/archive/refs/tags/v0.19.0.tar.gz
Wire up build.zig
const zcli_dep = b.dependency("zcli", .{ .target = target, .optimize = optimize }); const zcli_module = zcli_dep.module("zcli"); 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, .{}), }, .app_name = "myapp", }); exe.root_module.addImport("command_registry", cmd_registry);
Write main.zig & your first command
const Context = @import("command_registry").Context; pub const meta = .{ .description = "Say hello" }; pub const Args = struct { name: []const u8 }; pub const Options = struct {}; pub fn execute(args: Args, _: Options, context: *Context) !void { try context.stdout().print("Hello, {s}!\n", .{args.name}); }
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)
zcli add/rm/mv — instead of hand-editing struct fields and meta blocks; it keeps the file, the metadata, and the argument order in sync. Read the current shape any time with zcli tree --show-options.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. An Options field with no absent-value — not a bool, optional, array, or defaulted — is a required option: the type says a value must be provided (from the CLI flag, its .env var, or config), and the command errors if none did.
pub const meta = .{ .description = "Add files to the index", .examples = &.{ "add file.txt", "add --all" }, .options = .{ .all = .{ .short = 'a', .description = "Add all files" }, }, }; pub const Args = struct { files: []const []const u8, // variadic: remaining args }; pub const Options = struct { region: []const u8, // --region <value> (required) all: bool = false, // --all / -a (flag) output: []const u8 = "text", // --output <value> count: u32 = 1, // --count <number> verbose: bool = false, // --verbose (flag) };
The context
Every command's execute takes a concrete context: *Context, imported from the generated registry. It's your handle to I/O, a per-command arena allocator, the active theme, and plugin data — a real type, not anytype, so the compiler checks your usage and your editor autocompletes it.
const Context = @import("command_registry").Context; pub fn execute(args: Args, options: Options, context: *Context) !void { const allocator = context.allocator; // arena, freed after this command const out = context.stdout(); if (args.name.len == 0) return context.fail("name is required", .{}); try out.print("{s}\n", .{args.name}); }
context.fail("...", .{}) prints a clean message and exits — use it for anything the user should just read. A plain return error.X is for unexpected bugs and gets a name + Debug-only trace.Interactive prompts
The prompts package provides all 8 prompt types — every one works standalone (no zcli dependency) and falls back to line-based input when stdin isn't a TTY.
text
Free-form input with an optional default.
confirm
Yes/no, defaulting either way.
select
Arrow-key single choice from a list.
multiSelect
Space to toggle, arrows to move, defaults per item.
password
Masked input, no echo to the terminal.
search
Type-to-filter over a long list.
number
Range-validated numeric input.
editor
Opens $EDITOR for longer input.
// Pre-wired to the command's streams, allocator, and theme. const p = context.prompts(); const name = try p.text(.{ .message = "Project name:", .default = "my-project", }); const features = try p.multiSelect(.{ .message = "Features:", .choices = &.{ "typescript", "eslint", "prettier" }, .defaults = &.{ true, true, false }, }); const pw = try p.password(.{ .message = "Token:", });
Prompts are one-shot questions that block, return a value, and fall back to line input off a TTY. For a persistent, laid-out, keyboard-driven surface — a live form or dashboard — build a full-screen App from the interactive widgets instead: see prompts vs widgets.
Progress & theming
The progress package provides spinners (9 styles) and progress bars with ETA; animations auto-disable when not a TTY.
// Pre-wired to the command's stdout, io, allocator, and theme. const p = context.progress(); var spinner = try p.spinner(.{ .style = .dots }); spinner.start("Connecting to server..."); spinner.succeed("Synced successfully"); // or .fail() / .warn() / .info() var bar = try p.progressBar(.{ .total = items.len, .show_eta = true }); for (items, 0..) |item, i| { process(item); bar.update(i + 1, null); }
Theming
Declare a theme once in your root source file and the whole CLI follows it — help output, styled text, prompts, spinners, and progress bars. Semantic roles — success, err, warning, command, path — resolve through the active palette at render time and degrade gracefully: true color, 256 color, 16 color, or no color (respects NO_COLOR).
pub const zcli_theme: zcli.Theme = .{ .palette = .{ .accent = .{ .foreground = .{ .rgb = .{ .r = 255, .g = 179, .b = 71 } } } }, };
const styled = zcli.theme.styled; try styled("Error").red().bold().render(writer, &context.theme); try styled("Synced").success().render(writer, &context.theme);
The full role list, component tokens, and how each surface consumes the theme: see the theming guide.
CLI/TUI hybrid
Prompts and progress both render on zcli.ui — a terminal-native layout engine — and it's yours to use directly. Output splits into a static stream that flows into scrollback and a live region that repaints in place just above it — a full layout, from a single line up to the whole viewport, not a fixed bottom strip. Unlike a full-screen TUI it never takes the terminal over, so your scrollback stays intact. This is the shape of modern agent-style CLIs: a component is just a function returning a node, and frames are diffed, so an animation repaints one cell, not the screen.
var app = try context.ui(.{}); defer app.deinit(); // restores the terminal; the final frame persists try app.emit("compiled {s}", .{name}); // static -> scrollback try app.frame(try ui.row(app.arena(), .{ .gap = 1 }, &.{ ui.widgets.spinner(.{}, state.tick), ui.text(.{ .bold = true }, "building..."), })); // live -> diffed repaint
Boxes, wrapped text, spacers, and custom leaves; fit/len/fill sizing; viewport-clamped and resize-aware, degrading to plain lines when piped. The same node tree, layout, and diff also run in full-screen mode — context.uiFullScreen(.{}) with an event loop, focusable form widgets, and overlays — when you want to take the terminal over. The model, the node vocabulary, the widgets, and full-screen: see the CLI/TUI hybrid guide.
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
Using plugins
zcli ships eight plugins. A project scaffolded with zcli init turns on the first three; the rest you opt in to.
| Plugin | Provides | Default |
|---|---|---|
| zcli_help | --help / -h, auto-generated help text | default |
| zcli_version | --version / -V | default |
| zcli_not_found | "Did you mean?" suggestions for typos | default |
| zcli_completions | Generate & install completions for bash, zsh, fish | optional |
| zcli_config | Transparent config loading — JSON, TOML, YAML, per-command scoping | optional |
| zcli_output | --output flag — json, table, plain | optional |
| zcli_secrets | OS-keychain credential storage — macOS Keychain, Linux Secret Service, Windows Credential Manager | optional |
| zcli_github_upgrade | upgrade command via GitHub releases | optional |
.plugins = &.{
zcli.builtin(.help, .{}),
zcli.builtin(.completions, .{}),
zcli.builtin(.secrets, .{}),
},
// or point at your own: .plugins_dir = "src/plugins"
Writing plugins
A plugin is a Zig file exporting a plugin_id plus any lifecycle hooks, global options, context data, or commands it needs. Auto-discovery scans .plugins_dir: a lone name.zig is a single-file plugin; a folder with a plugin.zig entry point is a multi-file plugin.
pub const plugin_id = "timing"; pub const ContextData = struct { started_ns: i128 = 0 }; pub const global_options = [_]zcli.GlobalOption{ zcli.option("time", bool, .{ .short = 't', .default = false }), }; pub fn postExecute(context: anytype) !void { const start = context.plugins.timing.started_ns; if (start != 0) { const ms = @divTrunc(std.time.nanoTimestamp() - start, 1_000_000); try context.stderr().print("⏱ {d}ms\n", .{ms}); } }
Hooks take context: anytype — a plugin is compiled into the registry, so it can't name the concrete Context type your commands import. Full hook list, build-time config, and plugin-provided commands: see the full plugins guide.
Testing
Three tiers, each suited to a different verification need — use them together for coverage without slow feedback loops.
| Tier | Tests | Speed |
|---|---|---|
| Unit | Command logic in isolation — execute() only | Fast, in-process |
| Integration | The full CLI binary via subprocess — arg parsing, routing, output | Medium |
| E2E | Interactive terminal behavior — prompts, signals, TTY output | Slow |
Unit tests run against a real virtual terminal (vterm) that parses ANSI output — assert on colors and formatting, not raw escape codes:
test "deploy command" { var result = try testing.runCommand(DeployCommand, .{ .args = .{ .service = "api" }, .options = .{ .env = "staging" }, }); defer result.deinit(); try std.testing.expectEqualStrings("Deploying api to staging\n", result.stdout); try std.testing.expect(result.term.containsText("Deploying")); try std.testing.expect(result.term.hasAttribute(0, 0, .bold));
result.term is a real ANSI-parsing terminal emulator — assert a cell is bold at row 0, check a foreground color, or match a wildcard pattern across the rendered screen. No other Zig CLI library ships this. Full API and the integration/e2e tiers: see the testing guide.
Error handling
Parsing errors are handled for you: the framework parses and validates every argument and option before execute() runs, prints a user-friendly diagnostic on failure, and picks the exit code. A mistyped option or command gets a "did you mean?" suggestion computed by edit distance — the same machinery your commands get for free.
Unknown option '--verbos' Did you mean: --verbose
Under the hood everything is a standard Zig error union — a structured ZcliError plus an optional ZcliDiagnostic carrying the field, position, and expected type. When you parse outside the framework (zcli.parseCommandLine, parseArgs, parseOptions), formatDiagnostic renders the same messages. Full model and the standalone-parsing API: see the error-handling guide.
Docs generation
Generate markdown, man pages, or HTML documentation from your command metadata — automatically on every build, so docs can't drift from the commands they describe.
zcli.generateDocs(b, cmd_registry, zcli_dep, .{ .formats = &.{ "markdown", "man", "html" }, .output_dir = "docs", });
The HTML output is a styled, dark-mode-aware static site with navigation — this documentation page is generated the same way.
The zcli CLI
The meta-CLI you install with curl | sh is itself a zcli app — init, add, gh, mv, rm, tree, dev, guide, and release are files in its own commands/ directory, running on the framework's own help, completions, "did you mean?", and GitHub self-upgrade plugins. If the meta-CLI's own commands look like a well-formed zcli project, that's because they are one.
$ zcli tree zcli ├── add (group) [Add commands and plugins to your zcli project] │ ├── arg [Add a positional argument to an existing command] │ ├── command [Add a new command to your zcli project] │ ├── group [Add a command group (a directory of subcommands) to your project] │ ├── option [Add an option to an existing command] │ └── plugin [Add a local plugin to your zcli project] ├── dev [Watch src/ and rebuild on change (optionally run a command)] ├── gh (group) [Add GitHub-related features and workflows] │ └── add (group) │ └── workflow (group) │ └── release [Add GitHub Actions workflow for building and releasing binaries] ├── guide [Version-matched reference and worked examples for building with zcli] ├── init [Initialize a new zcli project] ├── mv [Move or rename a command file, tidying up empty groups] ├── release [Create and manage project releases] ├── rm (group) [Remove args and options from an existing command] │ ├── arg [Remove one or more positional arguments from an existing command] │ ├── command [Remove a whole command file from your project] │ └── option [Remove one or more options from an existing command] └── tree [Show the command tree discovered from src/commands]
zcli add option/arg/group/plugin and zcli mv/zcli rm restructure command files in place via an AST splice engine — struct field, meta entry, and argument order all edited together, correctly, instead of by hand.
AI agents
AI coding agents are good at Zig, but they hallucinate stale APIs the moment a framework changes — a `Context` field that got renamed, a plugin config shape that moved. zcli's answer is version-matched: the reference an agent reads is generated from the exact zcli it's compiling against, not a training-data snapshot.
AGENTS.md
zcli init scaffolds an AGENTS.md in every new project — a thin, frozen file that points an agent at zcli guide rather than duplicating framework docs that would drift. Re-running init on an existing project appends or refreshes just the zcli section, never clobbering the rest of your AGENTS.md.
zcli guide
A topic-based reference, matched to the zcli version in your build.zig.zon. Each topic is a worked example, not prose:
structure— files, commands, groups, pluginssharing— reusing a helper module across commandsstorage,arena— allocator lifetimesoutput,prompts,http,secrets— the terminal & network toolkitplugins,testing— extending and verifying a project
$ zcli guide structure structure — a zcli project is its files File path = command path: src/commands/deploy.zig → app deploy src/commands/users/create.zig → app users create src/commands/users/index.zig → the "users" group landing Change structure with the scaffolder, not by hand: zcli add command <path> zcli rm command <path> zcli mv <from> <to>
An agent adding a command, first try
The loop zcli guide teaches an agent: read what exists, change structure with the scaffolder, write logic freeform, verify with zig build test.