Plugins
Plugins are how zcli stays batteries-included without baking everything into the core. They add global options, lifecycle hooks, and even their own commands — all resolved at build time, so there's no runtime registration cost.
build.zig via zcli.generate. The generator imports each one and folds it into the command registry at compile time.Built-in defaults
zcli ships seven plugins. A project scaffolded with zcli init turns on the first three (and completions); the rest you opt into when you need them.
| 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_github_upgrade | upgrade command via GitHub releases | optional |
Enabling plugins
There are three ways to register plugins — and they combine, so you can mix them in one project.
zcli.builtin()
Enable a shipped plugin by tag — zcli.builtin(.help, .{}). No name or path to spell out.
.{ .name, .path }
Point at any plugin by name and path — for dependency plugins or ones outside the discovery folder.
.plugins_dir
Point at a folder and zcli scans it for your own plugins — no per-plugin entry to maintain.
const cmd_registry = zcli.generate(b, exe, zcli_dep, zcli_module, .{ .commands_dir = "src/commands", // Auto-discover everything in your own plugins folder .plugins_dir = "src/plugins", .plugins = &.{ // Shipped plugins via the builtin() shortcut zcli.builtin(.help, .{}), zcli.builtin(.version, .{}), zcli.builtin(.not_found, .{}), // A handrolled entry, by name + path .{ .name = "my_plugin", .path = "src/plugins/my_plugin" }, }, .app_name = "myapp", .app_description = "My CLI application", });
.plugins is optional — omit it entirely and rely on .plugins_dir, or use only the list. Auto-discovered and listed plugins merge into one registry. The seven shipped plugins map to tags .help · .version · .not_found · .completions · .config · .output · .github_upgrade.Auto-discovery rules
When you set .plugins_dir, zcli scans that folder (much like it scans commands/) and registers what it finds:
- Single-file plugin — any
name.zigdirectly in the folder becomes a plugin namedname. - Multi-file plugin — a subfolder containing a
plugin.zigentry point becomes a plugin named after the folder. - Hidden entries (dot-prefixed) and folders without a
plugin.zigare skipped.
src/plugins/ ├── auth.zig → plugin "auth" (single file) └── metrics/ └── plugin.zig → plugin "metrics" (multi-file)
Build-time configuration
Some plugins take settings. Pass them as the second argument to zcli.builtin — plugins that need none just take .{}. This is how github_upgrade learns which repo to check.
.plugins = &.{
zcli.builtin(.help, .{}),
zcli.builtin(.github_upgrade, .{
.repo = "ryanhair/zcli",
.command_name = "upgrade",
}),
},
The generator introspects that config struct at comptime and folds it into the generated registry — fully compile-time, no runtime string building. Handrolled .{ .name, .path } entries can still pass an .init string for the same effect.
Writing a plugin
A plugin is a Zig file that exports a plugin_id plus any of the optional hooks, options, context data, or commands. Start with the smallest useful shape:
const zcli = @import("zcli"); pub const plugin_id = "timing"; // Per-run state, reachable later via context.plugins.timing pub const ContextData = struct { started_ns: i128 = 0, }; // A global option this plugin contributes: --time / -t pub const global_options = [_]zcli.GlobalOption{ zcli.option("time", bool, .{ .short = 't', .default = false }), };
For a multi-file plugin, put that entry point at src/plugins/timing/plugin.zig and split helpers into sibling files.
Lifecycle hooks
Implement only the hooks you need. Plugin hooks take context: anytype (a plugin is compiled into the registry, so it can't name the concrete Context type your commands import). They run around command execution:
| onStartup | Runs once before routing — set up shared state. |
| handleGlobalOption | Called for each global option your plugin declared; stash the value in your context data. |
| preExecute | Runs before the matched command. Return null to halt execution, or the (possibly transformed) args to continue. |
| postExecute | Runs after the command succeeds — flush, report, clean up. |
| onError | Handle an error. Return true if you handled it. |
pub fn handleGlobalOption(context: anytype, name: []const u8, value: anytype) !void { if (std.mem.eql(u8, name, "time") and value) { context.plugins.timing.started_ns = std.time.nanoTimestamp(); } } pub fn preExecute(context: anytype, args: zcli.ParsedArgs) !?zcli.ParsedArgs { _ = context; // Return null to stop; return args to let the command run. return args; } 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}); } } pub fn onError(context: anytype, err: anyerror) !bool { _ = context; _ = err; return false; // not handled — let the next plugin try }
Reading plugin data from a command
Anything a plugin stores in its ContextData is namespaced under context.plugins.{plugin_id} and fully typed — a command can read it directly:
const Context = @import("command_registry").Context; pub fn execute(_: Args, _: Options, context: *Context) !void { if (context.plugins.timing.started_ns != 0) { // the --time flag was set; timing plugin is tracking this run } }
Plugins that add commands
A plugin can ship its own commands by exporting a commands namespace. Each nested struct is a command with the same meta / Args / Options / execute shape as a file-based command — which is exactly how zcli_github_upgrade contributes its upgrade command.
pub const commands = struct { pub const doctor = struct { pub const meta = .{ .description = "Diagnose the environment" }; pub const Args = struct {}; pub const Options = struct {}; // Plugin-provided commands take anytype, like hooks pub fn execute(_: Args, _: Options, context: anytype) !void { try context.stdout().print("all systems go\n", .{}); } }; };