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.

A project-local plugin discovered through .plugins_dir can import any shared_modules declared in zcli.generate, exactly as a command can. Dependency-provided plugins declare their own imports. See Build integration.

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 before global parsing. Capture lightweight state; defer operational setup to prepare.
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.
postParseInspects or replaces routed raw positionals before typed command parsing.
applyConfigDefaultsFills option fields from a lower-precedence source (e.g. a config file), after CLI + env parsing but before required/dependency validation.
handleInformationHandles help-like requests before config and validation. Return .complete after successful informational output, otherwise .proceed.
loadConfigLoads input configuration before resolved-value validation.
prepareRuns operational setup after validation. Return an error to stop before the command runs.
onFinishRuns at invocation completion, including setup failures, with a success: bool outcome.
describeFailureAdds an explanation without suppressing failure status.
renderFailureReturns whether this plugin rendered the failure. Rendering does not make the invocation successful.
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 prepare(context: anytype) !void {
    _ = context;
    // Inputs are valid. Acquire operational resources here if needed.
}

pub fn onFinish(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 describeFailure(context: anytype, failure: zcli.Failure) !?zcli.failure.Description {
    _ = context; _ = failure;
    return null; // no additional description
}

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
    }
}

Typed configuration per command

When plugin behavior really differs by command, declare a defaulted CommandConfig. A plugin reads the routed command's settings from context.command_config.{plugin_id}. Commands override fields inside their plugin namespace:

src/plugins/client.zigzig
pub const plugin_id = "client";
pub const CommandConfig = struct {
    needs_daemon: bool = true,
};

pub fn prepare(context: anytype) !void {
    if (context.command_config.client.needs_daemon) {
        // Start or connect to the daemon for this command.
    }
}
src/commands/version.zigzig
pub const meta = .{
    .description = "Print the version",
    .plugins = .{ .client = .{ .needs_daemon = false } },
};

Commands without an override receive the schema defaults. Unknown plugin names or fields fail at compile time. Group settings do not cascade to children, and aliases use the target command's configuration. For resources used by only a few commands, lazy acquisition through ContextData is often simpler than command metadata.

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