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.

Where they run Plugins are wired in 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.

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, fish, PowerShelloptional
zcli_configTransparent config loading — JSON, TOML, YAML, per-command scopingoptional
zcli_secretsOS-keychain credential storage — macOS Keychain, Linux Secret Service, Windows Credential Manageroptional
zcli_github_upgradeupgrade command via GitHub releasesoptional
zcli_docszig build docs step — markdown, man pages, HTML from command metadata; build-only, nothing in the binaryoptional

Enabling plugins

There are three ways to register plugins — and they combine, so you can mix them in one project.

shortcut

zcli.builtin()

Enable a shipped plugin by tag — zcli.builtin(.help, .{}). No name or path to spell out.

handrolled

.{ .name, .path }

Point at any plugin by name and path — for dependency plugins or ones outside the discovery folder.

auto-discovery

.plugins_dir

Point at a folder and zcli scans it for your own plugins — no per-plugin entry to maintain.

build.zig — all three togetherzig
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",
});
Note .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.zig directly in the folder becomes a plugin named name.
  • Multi-file plugin — a subfolder containing a plugin.zig entry point becomes a plugin named after the folder.
  • Hidden entries (dot-prefixed) and folders without a plugin.zig are skipped.
src/plugins/tree
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.

build.zigzig
.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:

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

onStartupRuns once before routing — set up shared state.
preParseRuns before argument parsing; can rewrite the raw argv.
transformArgsRuns after preParse; can rewrite or short-circuit the raw argv before parsing.
handleGlobalOptionCalled for each global option your plugin declared; stash the value in your context data.
postParseRuns after parsing, before command execution.
applyConfigDefaultsFills option fields from a lower-precedence source (e.g. a config file), after CLI + env parsing but before required/dependency validation.
preExecuteRuns before the matched command. Return null to halt execution, or the (possibly transformed) args to continue.
postExecuteRuns after the command finishes, with a success: bool outcome — flush, report, clean up.
onErrorHandle an error. Return true if you handled it.
timing.zig — hookszig
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.

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

plugin with a commandzig
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", .{});
        }
    };
};