Config files

The zcli_config plugin loads option defaults from a config file — JSON, TOML, or YAML — with no changes to command code. Your Options structs stay exactly as they are; the plugin fills in unset fields before execute runs.

Enable it in build.zig:

.plugins = &.{
    zcli.builtin(.help, .{}),
    zcli.builtin(.config, .{}),
},

Discovery

The first match wins:

  1. --config <path> — an explicit file passed on the command line (a global option the plugin contributes)
  2. ./.{app}.config.json|toml|yaml|yml — a project-local dotfile in the working directory
  3. {user config dir}/{app}/config.json|toml|yaml|yml — the user-level config, in the platform-standard location: $XDG_CONFIG_HOME else ~/.config on POSIX and macOS, %APPDATA% on Windows. See Application paths for the full rules, including which values are ignored as invalid. When the environment names no user config location at all, this tier is simply skipped.

The first match wins outright. When the winner is the project-local dotfile, the plugin always prints a note: applied config from ./… naming it (the explicit --config file and the user-level config load silently). Separately, if one tier contains files in several formats — say both .myapp.config.json and .myapp.config.toml — the plugin warns that multiple config files were found and names the one it used.

Global and per-command values

Top-level keys apply to every command; a table named after a command scopes values to just that command:

# .myapp.config.toml
verbose = true      # global — applies to all commands

[list]              # scoped — applies only to `myapp list`
all = true

Keys match option field names. A scoped value beats a global one for its command.

Precedence

Config sits between explicit user input and your struct defaults:

CLI flag  >  env var  >  command-scoped config  >  global config  >  struct default

A value set on the command line — or read from an option’s env fallback — always wins, even when it happens to equal the struct default; the plugin only fills fields the CLI and env left unset.

Every option type coerces from config. A config scalar is stringified and run through the same value parser the CLI and env use, so bools, all integer widths, floats, enums (including optionals), custom parse types, and arrays / multi-value options (from a config list) all work — no per-type wiring.

Failures are loud, never silent. A malformed config file, an unrecognized extension, an out-of-range number, or an unknown enum variant is skipped with a warning on stderr — the default stays — rather than crashing the CLI or being trusted. A value that won’t parse is never injected.

Config can satisfy a required option and participates in validation and constraints like any other source — a validate hook runs on the final value wherever it came from, and a config value counts as “supplied” for exclusive/requires checks.

Locking a field against config

Discovery tier 2 reads from the working directory, which the person running your CLI does not always control — a cloned repo, an extracted archive, or a shared build dir can ship a .myapp.config.toml. That is bounded: values still go through the typed parser (no code execution), the file is size-capped, a CLI flag or env var always wins, and the plugin prints a note: naming the file it applied. But it does mean a directory can set the default for any option, for that invocation.

So for anything whose value is a trust decision — skipping a verification step, disabling a safety check, naming a trusted URL, path, or repo — say so on the field:

pub const Options = struct {
    skip_verification: bool = false,
    registry: []const u8 = "https://registry.example",
};

pub const meta = .{
    .options = .{
        .skip_verification = .{ .no_config = true },
        .registry = .{ .no_config = true },
    },
};

.no_config = true means the field is never filled from a config file. The CLI flag, the env fallback, and the struct default all keep working — it locks only the source the user may not control. The marker is checked at compile time (a typo, or a non-bool value, is a build error), and it is enforced by the framework rather than by the plugin, so no config source can opt out of it.

If a marked field is also required, a config file naming it does not satisfy it: the CLI or env has to. That is the safe outcome — the user gets a “missing required option” error instead of a value chosen by a directory.

Stronger still, where it fits: keep the switch out of Options entirely and make it build-time config. The bundled upgrade plugin does that for its repo and signing key — values no single invocation should be able to choose at all.

Next