Secrets & credentials

CLIs collect tokens — and too many of them drop those tokens into dotfiles. The zcli_secrets plugin stores credentials in the operating system’s keychain, namespaced to your app, with a three-method API and no plaintext fallback: if a target OS has no secure backend, the build fails rather than quietly writing a file.

Enable it in build.zig:

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

The API

Three methods on context.plugins.zcli_secrets, each taking the context (for the allocator, io, environment, and app name that namespaces your entries):

// store (or overwrite) — the value is copied
try context.plugins.zcli_secrets.set(context, "token", token);

// retrieve — null if never stored; bytes owned by the command arena
if (try context.plugins.zcli_secrets.get(context, "token")) |token| {
    // use token
} else {
    return context.fail("not logged in — run `myapp login` first", .{});
}

// remove — a no-op if absent
try context.plugins.zcli_secrets.delete(context, "token");

get returns ?[]const u8 — a missing secret is a null, not an error, so “not logged in yet” stays a normal branch.

Backends

OSBackend
macOSthe system Keychain (Security.framework)
LinuxSecret Service via secret-tool, falling back to pass — chosen at runtime
WindowsCredential Manager

The Linux backend deliberately shells out instead of linking libsecret — so a static musl build of your CLI stays fully static and still gets real keychain storage on desktops that have it. Autodetection prefers a live Secret Service and falls through to pass; set ZCLI_SECRETS_BACKEND=secret-service|pass to force one. Any other target is a compile error — never a plaintext file.

Secret names must be valid UTF-8 with no NUL byte — validated once, up front, so the contract is uniform across every backend.

Value size limits

Two backends bound a secret’s size, and both reject oversized values cleanly with a hint — never a silent truncation:

  • Windows Credential Manager caps a value at 2560 bytes.
  • Linux Secret Service caps a value at roughly 6 KiB raw — secret-tool reads the secret through a fixed 8 KiB stdin buffer, so a larger value would be truncated; zcli verifies the stored value after writing and fails with a “too large” error and a pointer to the pass backend instead. Force pass for large secrets: ZCLI_SECRETS_BACKEND=pass.

The macOS Keychain and the Linux pass backend impose no practical cap.

A complete flow

The examples/ghauth example is a small GitHub auth CLI built on this plugin:

// login.zig — the auth flow is ordinary command code
const token = context.environ.get("GITHUB_TOKEN") orelse {
    return context.fail("set GITHUB_TOKEN, then run `ghauth login`.", .{});
};
try context.plugins.zcli_secrets.set(context, "token", token);
try context.stdout().print("Saved your GitHub token to the OS keychain.\n", .{});

whoami reads it back with get, logout calls delete. Nothing touches disk.

Testing commands that use secrets

addCommandTests wires an in-memory secrets stub into per-command unit tests, so a command that calls get/set tests without touching the real keychain — seed the stub, run the command, assert.