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 seven plugins. A project scaffolded with zcli init turns on the first three (and completions); the rest you opt into 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, fishoptional
zcli_configTransparent config loading — JSON, TOML, YAML, per-command scopingoptional
zcli_output--output flag — json, table, plainoptional
zcli_github_upgradeupgrade command via GitHub releasesoptional

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

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:

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.
handleGlobalOptionCalled for each global option your plugin declared; stash the value in your context data.
preExecuteRuns before the matched command. Return null to halt execution, or the (possibly transformed) args to continue.
postExecuteRuns after the command succeeds — 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) !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:

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