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
| OS | Backend |
|---|---|
| macOS | the system Keychain (Security.framework) |
| Linux | Secret Service via secret-tool, falling back to pass — chosen at runtime |
| Windows | Credential 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-toolreads 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 thepassbackend instead. Forcepassfor 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.