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 eight plugins. A project scaffolded with zcli init turns on the first three by default; the rest you toggle on in init's plugin picker (or add later in build.zig) 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, PowerShell | optional |
| zcli_config | Transparent config loading — JSON, TOML, YAML, per-command scoping | 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 |
| zcli_docs | zig build docs step — markdown, man pages, HTML from command metadata; build-only, nothing in the binary | 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 = try zcli.generate(b, exe, zcli_dep, .{ .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 eight shipped plugins map to tags .help · .version · .not_found · .completions · .config · .secrets · .github_upgrade · .docs.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",
// required — pin a minisign key, or opt out with .checksum_only
.verification = .{ .minisign = "RWT…" },
}),
},
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.
One extra requirement for github_upgrade: its upgrade command renders progress as live spinners on zcli's ui engine, so your root source file must declare the two terminal-restoring crash hooks — pub const panic = zcli.ui.panic; and pub const debug = zcli.ui.debug; — both enforced at compile time (segfaults are routed through root.debug.handleSegfault, not root.panic). Projects scaffolded by zcli init already declare whatever their pinned zcli requires. See Shipping for the full upgrade flow.
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. |
| preParse | Runs before argument parsing; can rewrite the raw argv. |
| transformArgs | Runs after preParse; can rewrite or short-circuit the raw argv before parsing. |
| handleGlobalOption | Called for each global option your plugin declared; stash the value in your context data. |
| postParse | Runs after parsing, before command execution. |
| applyConfigDefaults | Fills option fields from a lower-precedence source (e.g. a config file), after CLI + env parsing but before required/dependency validation. |
| preExecute | Runs before the matched command. Return null to halt execution, or the (possibly transformed) args to continue. |
| postExecute | Runs after the command finishes, with a success: bool outcome — 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, success: bool) !void { _ = success; 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. Because plugin_id becomes that field name verbatim, it must be a valid Zig identifier (lowercase snake_case); declaring ContextData without a plugin_id, or using an invalid one like "my-plugin", is a compile-time error that names the fix.
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", .{}); } }; };