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.

Requires Zig 0.16.0 or newer. zcli is currently v0.19.0 and MIT licensed.

Quick start3 commands

Install the meta-CLI, scaffold a project, add a command. That's the whole loop.

1

Install the zcli CLI

See the install page for every option, including shell completions.

terminalsh
$ curl -fsSL https://zcli.sh/install.sh | sh
2

Scaffold a project

terminalsh
$ 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!
3

Add a command & build

terminalsh
$ 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
1

Add zcli to build.zig.zon

terminalsh
$ zig fetch --save https://github.com/ryanhair/zcli/archive/refs/tags/v0.19.0.tar.gz
2

Wire up build.zig

build.zigzig
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);
3

Write main.zig & your first command

src/commands/hello.zigzig
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.zigmyapp init
  • users/create.zigmyapp users create
  • users/index.zig → describes the users group (no execute = shows subcommands)
Structure changes Use the scaffolder — 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.

command structurezig
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.

src/commands/whoami.zigzig
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 vs error 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.

promptszig
// 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.

progresszig
// 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).

main.zigzig
pub const zcli_theme: zcli.Theme = .{
    .palette = .{ .accent = .{ .foreground = .{ .rgb = .{ .r = 255, .g = 179, .b = 71 } } } },
};
themezig
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.

src/commands/deploy.zigzig
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 modecontext.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.

.myapp.config.tomltoml
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.

PluginProvidesDefault
zcli_help--help / -h, auto-generated help textdefault
zcli_version--version / -Vdefault
zcli_not_found"Did you mean?" suggestions for typosdefault
zcli_completionsGenerate & install completions for bash, zsh, fishoptional
zcli_configTransparent config loading — JSON, TOML, YAML, per-command scopingoptional
zcli_output--output flag — json, table, plainoptional
zcli_secretsOS-keychain credential storage — macOS Keychain, Linux Secret Service, Windows Credential Manageroptional
zcli_github_upgradeupgrade command via GitHub releasesoptional
build.zigzig
.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.

src/plugins/timing.zigzig
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.

TierTestsSpeed
UnitCommand logic in isolation — execute() onlyFast, in-process
IntegrationThe full CLI binary via subprocess — arg parsing, routing, outputMedium
E2EInteractive terminal behavior — prompts, signals, TTY outputSlow

Unit tests run against a real virtual terminal (vterm) that parses ANSI output — assert on colors and formatting, not raw escape codes:

deploy_test.zigzig
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.

$ myapp deploy --verbossh
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.

build.zig — after generate()zig
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 treesh
$ 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, plugins
  • sharing — reusing a helper module across commands
  • storage, arena — allocator lifetimes
  • output, prompts, http, secrets — the terminal & network toolkit
  • plugins, testing — extending and verifying a project
terminalsh
$ 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.

you: add a "users create" command that takes a name and an --admin flag agent: zcli tree --show-options agent: zcli add command users/create --description "Create a user" Created src/commands/users/create.zig agent: zcli add arg users/create name --type []const u8 Added argument 'name' to src/commands/users/create.zig agent: zcli add option users/create admin --type bool --default false --short a Added option --admin to src/commands/users/create.zig agent: writes execute() body freeform agent: zig build test All tests passed