Build integration

All of zcli’s wiring happens in build.zig, at build time: command discovery, plugin registration, registry generation, contract validation. zcli init writes this file for you — this page is the reference for when you customize it.

zcli.generate

The one required call. It scans your commands directory, validates every command, folds in plugins, and returns the generated registry module:

const zcli = @import("zcli");

const zcli_dep = b.dependency("zcli", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zcli", zcli_dep.module("zcli"));

const cmd_registry = try zcli.generate(b, exe, zcli_dep, .{
    .commands_dir = "src/commands",
    .plugins = &.{
        zcli.builtin(.help, .{}),
        zcli.builtin(.version, .{}),
        zcli.builtin(.not_found, .{}),
    },
    .app_name = "myapp",
    .app_description = "My CLI application",
});
exe.root_module.addImport("command_registry", cmd_registry);

The config:

FieldTypeNotes
commands_dirrequireddirectory scanned for command files
app_namerequiredyour binary’s name, used in help and errors
app_descriptionrequiredone-liner shown at the top of --help
plugins&.{}explicit plugin list — zcli.builtin(...) for shipped plugins, or .{ .name, .dependency = b.dependency(...) } for a third-party plugin shipped as its own Zig package
plugins_dirnulla folder of your own project-local plugins, auto-discovered
shared_modulesnullmodules importable from every command

There is no version field. The app version is read from build.zig.zon’s .version at build time — one source of truth, surfaced as context.app_version and --version.

Commands import the result as command_registry — that’s where the concrete Context type lives:

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

What the build checks

Discovery and validation run at build time, so a malformed command is a compile error that names the file and the fix — execute without Args/Options, a meta.options entry naming a field that doesn’t exist, a boolean named no_x (collides with the generated negation), an ill-formed exclusive set. Helpers prefixed _ are skipped; nesting is capped at 6 levels.

Shared modules

Each command file is compiled as its own module rooted at that file, so a relative import of a sibling reaches outside the module and fails to compile:

// in src/commands/tags.zig
const registry = @import("../registry.zig");
// error: import of file outside module path

Commands often share code — a storage layer, an API client. Declare it once as a module and every command can import it by name:

const store_module = b.createModule(.{
    .root_source_file = b.path("src/store.zig"),
    .target = target,
    .optimize = optimize,
});

const cmd_registry = try zcli.generate(b, exe, zcli_dep, .{
    .commands_dir = "src/commands",
    .shared_modules = &[_]zcli.SharedModule{
        .{ .name = "store", .module = store_module },
    },
    // ...
});
// in any command:
const store = @import("store");

Per-command and shared-module unit tests

addCommandTests discovers every command file and compiles each one’s test blocks against the testing harness — with the zcli-testing module, your shared modules, and an in-memory secrets stub wired in:

_ = zcli.addCommandTests(b, exe, zcli_dep, .{
    .commands_dir = "src/commands",
    .target = target,
    .optimize = optimize,
    .shared_modules = &[_]zcli.SharedModule{
        .{ .name = "store", .module = store_module },
    },
});

Then zig build test runs a test binary per command — a test "creates a task" block sitting right next to the execute it verifies.

Pass shared_modules here too, matching what you gave generate. It does two jobs: the command-test stub only wires the shared modules you hand it, so a command that imports one won’t compile under zig build test otherwise — and each module in the list is compiled as a test root of its own, so the test blocks inside src/store.zig run in the same step as the command tests. The test compile is rooted on a mirror of your module, so its tests see the same imports and build configuration the commands do while the module you created is left untouched. If you created it without .target/.optimize — legal, and usually the point, since a module that is only ever imported inherits both from whatever compilation pulls it in — the mirror takes the pair you gave addCommandTests, and your module goes on inheriting everywhere else.

That mirror is a snapshot taken at the call: imports, C macros, include and library paths, rpaths, frameworks, and link objects are all copied into storage of their own, so configuring either module later never disturbs the other. Which means configuration you add to a shared module after calling addCommandTests reaches your commands but not that module’s own tests — configure shared modules first, then wire the tests.

Docs generation

The docs plugin adds a docs build step that renders your command metadata — descriptions, args, options, examples — as documentation. It is build-only: it wires the step and ships nothing in your binary. Register it in the generate() plugins list:

zcli.builtin(.docs, .{
    .formats = &.{ "markdown", "man", "html" },
    .output_dir = "docs",
}),

Run it with zig build docs (it’s off the default step, so ordinary builds stay fast). Each format gets a subdirectory: docs/markdown/, docs/man/ (a section-1 page per command), and docs/html/ — a static site styled to match this documentation, dark and terminal-native, with breadcrumb navigation. Enum-typed options and arguments list their valid choices in every format, and man-page dates honor SOURCE_DATE_EPOCH so the output is byte-for-byte reproducible. Because it reads the same registry your binary compiles from, generated docs can’t drift from the commands they describe.

Built-in plugin tags

zcli.builtin(tag, config) registers a shipped plugin. Eight tags: .help, .version, .not_found, .completions, .config, .secrets, .github_upgrade, and the build-only .docs. Most take no config (.{}); github_upgrade takes its repo and options:

zcli.builtin(.github_upgrade, .{
    .repo = "you/yourapp",
    .command_name = "upgrade",
}),

See Plugins for what each provides and Ship & distribute for the upgrade/signing story.