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.17.0 and MIT licensed.

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.

1

Install the zcli CLI

Grab the binary (see the install page for all options):

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

Scaffold a project

Create a new project (or pass . to initialize the current directory). Add --description to set your app's tagline.

terminalsh
$ 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/tree
my-app/
├── build.zig             — executable + zcli.generate()
├── build.zig.zon         — zcli dependency
└── src/
    ├── main.zig          — entry point
    └── commands/
        └── hello.zig     → my-app hello
3

Build & run

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

Add zcli to build.zig.zon

Add the dependency, then run zig build once — it prints the correct hash to paste in.

build.zig.zonzig
.dependencies = .{
    .zcli = .{
        .url = "https://github.com/ryanhair/zcli/archive/refs/heads/main.tar.gz",
        .hash = "...",  // zig build will tell you the correct hash
    },
},
2

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.

build.zigzig
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);
}
Tip Need config or output too? Add zcli.builtin(.config, .{}) / zcli.builtin(.output, .{}). For your own plugins, set .plugins_dir = "src/plugins" — see the Plugins guide.
3

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.

src/main.zigzig
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,
    };
}
4

Write your first command

Drop a file in src/commands/. The example below maps to myapp hello.

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 {
    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 });
}
terminalsh
$ 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.zigmyapp init
  • users/create.zigmyapp users create
  • users/index.zig → describes the users group (no execute = 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.

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",
        },
    },
    .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.

src/commands/whoami.zigzig
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});
}
Unused params Set unused parameters to _ 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.

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

zinputzig
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.

zprogresszig
var bar = zprogress.progressBar(.{ .total = 100, .show_eta = true });
var spinner = zprogress.spinner(.{ .style = .dots });
spinner.start("Loading...");
spinner.succeed("Done!");   // ✔ Done!