Every release, newest first.
All notable changes to zcli, rendered from CHANGELOG.md in the repo — that file stays the single source of truth.
vX.Y.Z is the framework library (the tag for your build.zig.zon), zcli-vX.Y.Z carries the prebuilt meta-CLI binaries.
| zcli | Zig |
|---|---|
| main, v0.18.0 and later | 0.16.0 |
| v0.14.0 – v0.17.0 | 0.15.1 |
v0.25.0 — 2026-08-24
Added
runCommandcan inject stdin. Pass.stdin = "Ada\n\n"and the command reads exactly those bytes throughcontext.stdin()andcontext.prompts(), reaching EOF afterwards instead of blocking on the real terminal — so a command that asks questions is drivable in a plain in-process test, one line per answer. Prompts take their line-based path because the streams the command was handed decide, not the process’s descriptors:context.prompts()now reports non-interactive whenever an in-memory override has replaced stdout or stdin (see below), so a test binary started from a terminal can no longer enter raw mode and read the developer’s real keyboard. Raw-mode keystrokes — arrows through aselect, hiddenpasswordinput, Ctrl-C — are not modeled by a byte stream and remain the PTY-backed E2E tier’s job. Omitting.stdinleaves the process’s own stdin in place, as before.runCommandcan set app metadata..app_name,.app_versionand.app_descriptionfill the context fields a real run gets from the registry’sConfig, so version-bearing output can be asserted against a real string rather than the"unknown"placeholder. They are applied before plugininitContextDatahooks run, so a plugin that captures app metadata into itsContextDatasees the configured values too — the bundled secrets plugin capturesapp_namethere, and it is what namespaces stored secrets. Each field left unset keeps the context default.zcli.Paths— platform-standard application directories (ADR-0035, #820).context.paths()answers “where does this app put its config / cache / data on this platform?” from the threadedenviron, never ambientgetenv(whichstd.fs.getAppDataDirused before it was removed in Zig 0.16, and which pulls libc into a stack kept deliberately libc-free for static musl).dir(kind),file(kind, sub_path),base(kind),resolve(kind, segments)andhome()are pure string functions — no filesystem, noio; onlyensureDir/ensureFile/ensureParenttouch disk, and they create directories only, never the file. Locations:$XDG_CONFIG_HOMEelse~/.config,$XDG_DATA_HOMEelse~/.local/share, and$XDG_CACHE_HOMEelse~/.cacheon Linux/BSD; the same on macOS exceptcache, which falls back to~/Library/Caches(macOS excludes it from Time Machine and targets it for reclamation, so a purgeable cache belongs there);%APPDATA%\{app}for config and%LOCALAPPDATA%\{app}\{data,cache}on Windows. macOS uses XDG for config and data rather than~/Library/Application Support— a stated CLI-ecosystem-compatibility policy, matchinggh,git,aws,cargoand every other CLI a terminal user already runs, not the Apple platform convention. Two orthogonal knobs,convention(which variables and fallback tails — a policy) andsyntax(separators and absoluteness — a property of the filesystem), let a caller pin a tool’s own contract while still doing native host I/O; both are runtime fields, so the whole resolution matrix is testable on any host without cross-compilation. Newly created directories are0700on POSIX; existing directories are never re-chmoded. See the new Application paths guide andzcli guide paths.addCommandTestsruns your shared modules’ tests too. Every module in theshared_moduleslist is now compiled as a test root of its own, so thetestblocks inside a helper likesrc/store.zigrun underzig build testalongside the command tests — no secondaddTesttarget to hand-wire, and no shared logic silently uncovered because only command files were tested. The test compile is rooted on a mirror of each module, so its tests see the same imports and build configuration the commands do while the module the project created is left untouched — one without atarget/optimize(legal, and usually deliberate: an imported-only module inherits both from whatever compilation pulls it in) keeps inheriting for every other consumer, and only the mirror takes the pairaddCommandTestswas given. The mirror is a snapshot: imports, macros, include/library paths, rpaths, frameworks, and link objects are copied into storage of their own, so neither module can disturb the other’s later — configure a shared module before wiring the tests, since what you add afterwards reaches the commands but not that module’s own tests. Two names for one module still produce one test root. The one list you already pass to bothgenerate()andaddCommandTestsis all it takes.zcli guide storagenow covers safe concurrent appending. The topic gained an append-log recipe — shared locks for readers, an exclusive lock for writers, one record flushed inside the lock, bounded reads, and a torn trailing record repaired rather than propagated — with its corruption policy spelled out, the difference between flushing andfsyncstated, and the line where a log stops being a substitute for a database. The worked example is the newexamples/notes/src/log.zig;zig build testraces appending threads, replays a half-written final record, and repeats both against real child processes.zcli_testing.HttpFixture— a scripted loopback HTTP server for adapter tests. Commands that talk to an API have a layer none of the three testing tiers reaches directly: the adapter that builds the request and parses the response.HttpFixture.initbinds an ephemeral127.0.0.1port and starts serving; queue responses withrespondWith(.{ .status, .headers, .body }), point the code under test atbaseUrl()orurl("/path"), then assert onrequests()— each recording carries the method, target, headers, and a bounded copy of the request body. It is a real socket, so the whole client path runs for real with no network access and nothing stubbed. Startup, concurrent serving, and teardown are deterministic, and onedeinit()releases the socket, the serving tasks, and every byte the fixture handed out.Searchable
selectandmultiSelect. Set.search = trueon either canonical list prompt for case-insensitive filtering. Printable characters other than ASCII Space filter, Backspace edits, Up/Down navigate, and the Space key selects or toggles while Enter selects or commits. Plainselectalso accepts Space like Enter; filtered-out multi-select choices remain selected.zcli.process— running an external program, safely (ADR-0034). Every real CLI shells out, and hand-rolling it reproduces the same four bugs: a deadlock once the payload outgrows a pipe buffer, unbounded capture, an ambient environment, and a termination value that cannot tellexit 1from a SIGSEGV.context.process()hands back aRunnerwired to the command’s allocator,io, and — the part that matters — the threadedenviron; there is no constructor that omits it and nogetenvinside. The stdin write and both output drains make independent progress, so any payload size is safe and stdin closes the instant the last byte is handed over; capture is per-stream bounded with an explicit overflow policy (.failstdout,.truncatestderr); andProgramhas no implicit-PATH variant — all four variants resolve to an absolute path in the parent, so PATH never chooses the binary. On Windows the runner emits an explicit supported extension, refuses.bat/.cmdwithout an opt-in, and refuses a target with a supported-extension sibling, closing theCreateProcessWPATHEXT fallback. It reaps its own children by polling and never callsChild.wait/Child.kill, which makes stopping race-free and lets Windows report a fullNTSTATUSinstead of std’s truncation tou8.std.process.runcould not serve: it hard-codes.stdin = .ignore, so it cannot feed a child at all. Guide: Running external programs.
Changed
zcli_secrets’ Linux backends now shell out throughzcli.process. The plugin carried the strongest of the framework’s three hand-rolled subprocess runners; it is now policy on top of the shared one — which trusted directories a helper may come from, that the payload is a secret, and that the captured output is too. Behaviour is unchanged for callers, and the helper is still pinned to an absolute path in a trusted directory (now viaProgram.in_dirs) so the inherited PATH cannot choose who receives a decrypted credential on stdin. Internal to the plugin:subprocess.resolveHelperis gone, replaced bysubprocess.helperAvailable, and thepassargv builders no longer carry anargv[0]— the runner supplies the path it resolved.An interactive-only prompt guard.
try p.requireInteractive()before a prompt sequence fails witherror.NotInteractive— before anything is asked — unless stdin and stdout are both terminals, which is what a command whose whole job is the conversation wants instead of a line-based fallback that answers questions the user never saw.p.isInteractive()asks the same question without erroring, for commands that branch rather than fail, and setting.interactiveon aPromptsinstance overrides the detection for every prompt made through it —falseforces line mode (the prompts still print and still read stdin; it is not a--no-inputswitch, which has to skip the sequence itself). The guard reads the same decision the prompts themselves make, so its verdict is exactly what the next prompt would have done. Prompts without the guard are unchanged: the line fallback — taken whenever either stdin or stdout is redirected — stays the default.completions install/uninstallnow print the resolved destination, shell-quoted (#820). The enable/disable instructions re-hard-coded the same literals as the installer, so a custom-XDG orBASH_COMPLETION_USER_DIRuser was told to edit a.bashrcline pointing at a file that is not there. Both printers now take the path actually written and quote it per shell (bash/zsh'…'with'\'', fish with\\and\', PowerShell with''), so a path containing a space, quote,$, or backtick produces a correct command rather than a broken — or executable — one.installalso warns when a script is still present at the pre-consolidation location and would shadow the new one, anduninstallremoves both.Directories created by the
ensure*family are0700on POSIX (#820), including non-app-scoped ancestors such as~/.local/share/bash-completion,~/.config/fish, and~/.zsh— those usually already exist, and an existing directory’s mode is never changed. There is no retroactivechmod: only directories newly created from now on are private.
Changed (breaking)
Stdio.stdinReader()has been removed. It handed out the rawstd.Io.File.Readerbehind stdin, which bypasses the newstdin_overrideand so would silently defeat injected input. Nothing in the framework or the bundled plugins called it, but it was public API: migrate any external caller tocontext.stdin()(orStdio.stdin()directly), which returns the same*std.Io.Readerinterface and honours the override. Code that reached past the interface forstd.Io.File.Reader-specific state has no replacement by design — that state is exactly what an injected stream does not have.- The standalone
searchprompt has been removed. Replacep.search(.{ ... })withp.select(.{ .search = true, ... })andSearchConfigwithSelectConfig. Searchable single and multi-selection now share the canonicalselectandmultiSelectAPIs. Non-TTY searchable select output now usesselect’s existing CRLF header instead ofsearch’s LF-only header. - A relative, empty, or control-bearing
HOMEnow errors instead of silently producing a CWD-relative destination (#820). Previously the config plugin, the upgrade cache, andcompletions installall joined onto whateverHOMEheld, so a stripped environment (a systemd unit, a container,suwithout-, cron) yielded a config dir, cache file, and completion install path resolved against the working directory. Now the user-config tier is skipped, the upgrade cache is disabled, andcompletions installfails loudly rather than writing into the working directory. Set an absoluteHOME. - Windows: the
%USERPROFILE%\.config\{app}\config fallback is removed (#820).%APPDATA%is now required for user-level config; when it is unset that tier is skipped. Deriving a location from%USERPROFILE%is a guess —FOLDERID_RoamingAppDatacan be redirected by group policy or roaming-profile configuration, and in exactly those managed environments writing to the un-redirected literal scatters data outside the profile. Move existing config to%APPDATA%\{app}\config.*. - A relative, empty, or malformed
XDG_*value is now ignored rather than used verbatim (#820). The XDG spec’s own disposition for an invalid value is to ignore it and use the default, so resolution falls back to~/.config,~/.local/share, or~/.cache. A relative value previously resolved against the process CWD, which was already CWD-dependent and unintended. If you relied on one, set an absolute path or move the file. - A relative, empty, or malformed
%APPDATA%/%LOCALAPPDATA%is now an error rather than used verbatim (#820). Unlike XDG there is no second-choice location, so a defect in a terminal source is fatal: the config tier is skipped and the upgrade cache is disabled. Set absolute, fully-qualified values. - The upgrade plugin’s update-check cache moves (#820). On Windows it is now
%LOCALAPPDATA%\{app}\cache\last-update-check, and an unset%LOCALAPPDATA%means no cache. On macOS it moves for anyone with a valid, absoluteXDG_CACHE_HOME— the plugin previously hard-coded~/Library/Cachesand ignored that variable entirely; users whose value is relative or empty are unaffected and stay at~/Library/Caches. On Linux/BSD it moves for anyone whoseXDG_CACHE_HOMEis relative, empty, or control-bearing, which was previously used verbatim. Every case self-heals with one extra update probe; the ~20-byte file at the old path is orphaned, not cleaned up. - bash and fish completions now install under
$XDG_DATA_HOME/$XDG_CONFIG_HOMEwhen set, and bash honours$BASH_COMPLETION_USER_DIRfirst (#820). Those tools document XDG-rooted user completion directories and zcli previously ignored them, hard-coding~/.local/shareand~/.config— so a user with a custom XDG setup got the script installed where their shell does not look.BASH_COMPLETION_USER_DIRtakes precedence over the XDG default and is treated as a search list (:-separated under POSIX syntax,;-separated under Windows syntax, which is the form MSYS produces when it rewrites a POSIX path list for a native child); the script is written to its first entry. - PowerShell completions move (#820). On POSIX the script follows
$XDG_CONFIG_HOMEwhen set; on Windows it moves from a/-joined~/.config/powershell/…— which was not even a well-formed Windows path — to%APPDATA%\powershell\completions\{app}.ps1. A$PROFILEline dot-sourcing the old hard-coded path will error on every shell start once the old file is gone, so update it to the pathcompletions installprints. - Completions emit native Windows paths; a POSIX-style
HOMEis rejected rather than translated (#820).getInstallPathpreviously joined with/even on Windows. MSYS2 and Cygwin convert path-like environment values to Win32 form when launching a native child, so the ordinary Git Bash setup works; the failure is narrow — conversion suppressed for a variable viaMSYS2_ENV_CONV_EXCL, or a mount with no drive-letter equivalent — and yieldserror.HomeNotAbsolutewith a diagnostic naming both remedies. Translating/c/…ourselves would mean guessing at a mount table zcli cannot read, and a wrong guess installs where the shell never looks. - An
app_namethat is all dots or ends in a dot is now a compile error (#820).app_namescopes everyPathslocation, and Win32 strips a trailing dot from a component before the path reaches the filesystem, somyapp.would have resolved outside the app subtree. The registry now calls the samePaths.isValidSegmentpredicate the runtime uses, so the two can no longer disagree — previously such a name compiled cleanly and then failed everyPathscall at runtime. Rename the app.
Fixed
context.prompts()no longer trusts the process descriptors over its own streams. Prompts decided interactivity by probing the real stdin/stdout descriptors, which say nothing about where a given Context’s bytes actually go. With stdout captured into a buffer (or stdin injected) the probe still saw the terminal a test binary was launched from, so a prompt would enter raw mode, read the developer’s real keyboard, and paint frames into a buffer nobody sees.context.prompts()now sets.interactive = falsewhenever an in-memory override has replaced stdout or stdin, so the streams the prompt was handed are the ones that decide. Captured stderr does not disqualify anything — a full-frame prompt paints on stdout and reads stdin. Normal runs, which have no overrides, still probe exactly as before.- A request that completed as its timeout fired no longer leaks the response.
http.Client.requestraced the request against a timer and then calledSelect.cancelDiscard, but cancelation is delivered at the next I/O cancelation point — so a request that finished just as the timer dequeued still handed back a fully-allocatedResponse, which was then dropped withoutdeinit. Every such race leaked the body and headers under a tracking allocator or any GPA-backed consumer. The race is now drained withSelect.canceluntil it returns null, deiniting any successfulResponsenobody will see, and the drain is installed as anerrdeferimmediately after the first spawn so a failed timer spawn or a canceledawaitcannot strand the in-flight request either. - Long
selectandmultiSelectlists no longer re-scroll when you reverse direction. The visible window was recomputed from the cursor on every frame, growing upward first, so pressing Up after scrolling down moved the list instead of the highlight: the highlighted row stayed pinned near the bottom of the window and the choices slid under it, one row per keypress, in both directions. The window is now anchored between frames and only moves when the highlight crosses its top or bottom edge, which is how every other list navigates. Wrapped choices still measure in physical rows, so a window holds whole choices that fit the terminal, and the anchor is re-clamped whenever filtering shortens the list or the terminal resizes — the highlight stays on screen either way. A terminal too short for the whole frame now gives up chrome instead of the answer: the highlighted choice’s rows are reserved first, then the header and query rows are clipped to what is left (dropped entirely when nothing is left). Previously a three-row terminal, or a narrow one whose header wrapped, spent the whole live region on the header and query and the live region clipped the highlighted choice away — you could not see what you were about to pick. The render seams the emulator tests drive (select_prompt.frameNode/frameNodeFiltered,multi_select_prompt.frameNode/frameNodeFiltered) take the anchor as a new*list_render.Viewportargument; pass one per prompt, not one per frame.list_render.viewportis replaced bylist_render.Viewport.window. - A value parsed by
zcli.http.Response.jsonno longer dangles when the response is released (#814).std.json‘s slice-input default is.alloc_if_needed, so any string that needed no unescaping — which is most of them — came back as a slice intoResponse.bodyrather than into the parse’s own arena. TheParsed(T)looked self-contained and the API said the arena owned it, butresponse.deinit()(or, under the per-command arena, the command returning) freed the bytes out from underparsed.value, and the payloads that happened to contain an escape hid it in testing.jsonnow parses with.allocate = .alloc_always, so everything reachable from.valueis genuinely owned by the parse and outlives the response it came from; the two lifetimes are independent, and the ownership contract in the module doc and the HTTP guide now says so. Unknown-field handling is unchanged. Callers that defensively copied strings out of a parse before the body went away (theoauth-deviceexample did) can drop the copies. - Release version policy is now one checked-in, executable contract instead of duplicated workflow snippets.
scripts/validate-version.shcompares the root, CLI, and core umbrella manifests; README and ROADMAP release URLs/metadata; the newest dated CHANGELOG release; an expected version or either tag shape when supplied; and an actual builtzcli --versionwhen supplied. CI tests that public seam against deterministic drift fixtures and runs it on a locally built CLI; the release workflow runs the same file after the staged bump and against each executable release build. The post-release phase now closes the remaining publication joins too: both tags must resolve to one commit, both source archives must download and contain the same version-consistent tree, the exact eight assets and all six signed checksums must verify, a downloaded host binary must report the release version, both site installers must match the tag, and the live shell installer must resolve the just-published CLI release. scripts/release.shno longer reports a complete release as INCOMPLETE. Its final verification sampled the live site exactly once, but Cloudflare Pages propagates assets independently and eventually — for a minute or so after a deploy the homepage and/install.shroutinely disagree about which build they are on, in either direction. The 0.24.0 deploy loggedversion_ok=false install_ok=trueon its first attempt; a minute later the script caught the reverse skew and failed the installer check on a release that was in fact fine. The site checks now poll both together for up to two minutes, the same waydeploy-docs.yml’s verify step already did — a single sample is a race, not a verdict. A failure to fetch the referenceinstall.shfrom the release tag is also reported as its own error now, instead of being indistinguishable from a genuine mismatch.- Dependency hardening — Nightwatch now includes its Linux raw-errno remediation, and serde.zig includes its YAML quoted-key fix. Renovate proposes review-gated dependency updates; Zig package hashes and downloaded-release checksums remain manually verified before merge.
v0.24.0 — 2026-07-29
Added
scripts/release.sh— one command cuts a release, end to end (ADR-0033). Preflight, dispatch the Release workflow, surface the single approval when the gate opens (terminal bell included), sign the draft, wait for the docs deploy, then independently verify the result: the site serves the new version,zcli.sh/install.shis byte-identical to the repo’s, the release carries all 8 assets, the published signature verifies against the pinned key and binds the tag, and the library release exists. It exits non-zero if any of that is untrue, so “the release is done” stops being a thing you assume. ADR-0023’s offline key means one step of every release is irreducibly local, which is exactly why the orchestrator is a local script rather than a GitHub button: the laptop is the only participant present for the whole transaction. Re-running is always safe — every phase reads remote state (is there a tag, a draft, a published release, what does the site serve) rather than local bookkeeping, so it resumes from wherever it stopped.--sign-onlycovers the recovery path, and--verify-onlyanswers “did that release actually land?” read-only, without a secret key.scripts/sign-release.shis removed; its ceremony — including thetrusted_comment_binds_tagcheck thatscripts/test-install-signature.shextracts and holds to the same matrix as the installers — now lives inscripts/release.shunchanged. Two scripts is how the ceremony became a thing to remember.
Changed (breaking)
- Releases are cut by workflow dispatch only; the
push: tagstrigger is gone (ADR-0033). Pushingzcli-vX.Y.ZorvX.Y.Zby hand no longer starts a release — run the Release workflow with a version instead, and CI bumps the manifests, stages them on a scratch branch, gates on the full cross-platform test + build matrix, then promotes to main and cuts both tags. The tag path was documented as a fallback and had quietly become the path in use, which is where the process problems came from: it needed the version bump merged to main first, which then made dispatch unusable for that version; it let the tag point at a commit the CHANGELOG did not describe (0.23.0 did, caught by eye); and two tags meant two runs and two approvals. Promoting## Unreleasedand cutting both tags in one commit means the tag and the release notes can no longer describe different trees. A failed dispatch touches neither main nor the tag namespace, so re-running it is the recovery path. - A release now takes one approval instead of two.
finalize— the point of no return, where main is pushed and both tags are cut — is the only job carryingenvironment: release. Gating all three publishing jobs was an artifact of the two entry paths, since the tag path skippedfinalizeand left no always-present job to hold the gate.releasepublishes a draft, which is not public, so gating it protected nothing and cost 0.23.0 an eight-hour stall between a green build and the draft appearing;library-releasepublishes for real but cannot run without the tagfinalizecuts. The approval also now lands after test and build are green. The #397 assertion that refuses to publish through an environment that has lost its required-reviewers rule is kept, in one place instead of three.
Fixed
- A docs deploy can no longer fail silently, and the website is built on every PR that can break it. zcli.sh served 0.20.0 for three weeks while 0.21.0, 0.22.0 and 0.23.0 shipped, because nothing asserted that a deploy reached the site. Root cause: the
cloudflare-pagesconcurrency group usedcancel-in-progress: false, which permits only one pending run per group — so each newly queued deploy displaced the pending one, five in a row, each endingcancelledwith zero jobs. One run held the group for 13.9 days waiting on a since-removed environment approval; deleting theenvironment:key did not release it, because a queued run is pinned to the workflow file at its own commit. The group is gone (serialisation was only ever a proxy for the real invariant), and the deploy now ends by asserting the live site actually serves the version just built and thatzcli.sh/install.shis byte-identical to the repo’s — content comparisons, not status codes, since the site answers HTTP 200 for missing paths. Separately,CHANGELOG.mdis a website build input (it generates the changelog page), so a repo-relative markdown link in a release entry breaks the deploy — which is how the 0.23.0 deploy died (#805). CI now builds the site on any PR touching the inputs, using the same pinned Zine as the deploy via a shared composite action, so that failure is a red check instead of a post-release incident.version consistencyalso coversROADMAP.mdnow: the release workflow rewrites its two version lines and nothing verified them afterwards.
v0.23.0 — 2026-07-28
Two whole-repo hardening passes (the thirteenth grade and its follow-up), closing 56 issues across correctness, security, and the build’s own gates. The headline fixes: a registry that could not compile past 27 commands, write failures that exited 0 having written nothing, a terminal left stranded by segfaults and Ctrl-Z, signed-downgrade replay in both installers, and a benchmark harness that had not compiled since the Zig 0.16 migration. Several parsing and API changes are breaking — see below. Requires Zig 0.16.0.
Added
- A layout/render benchmark, and two size-and-speed wins it justified (#776, #777, #778).
zig build benchmarknow covers the render path (Layout Table 50x4,Render Table 50x4), not just the parser, so theperformance budgetsCI job can gate frame cost. Acting on what it measured: the per-parse option-occurrence map was aStringHashMaphashing keys the compiler already knew (every lookup sits inside aninline forover the fields) and is now a[fields.len]u32— which also removes the only allocation on the duplicate-detection path, and theerror.OutOfMemorycallers had to thread through for bookkeeping that cannot fail; and the two suggestion sorts moved fromstd.sort.pdqtostd.sort.insertion, correct for their few-dozen elements and stable, so equal-distance suggestions now come back in a deterministic order. Together: 18.5 KB smaller andParse Options0.42 µs → 0.33 µs. The memoisation #776 proposed was built, measured at ~10% slower (the redundantmeasurecalls are leaf text nodes where hashing a key costs more than recomputing adisplayWidth), and dropped — the benchmark that disproved it is what shipped. meta.options.<field>.no_config— lock a field against config files (#788, ADR-0032).zcli_configdiscovers project-local config from the working directory, so a cloned repo or extracted archive can set the default for any option. For fields whose value is a trust decision — skipping a verification step, naming a trusted URL or repo — declare.no_config = trueand the field is never filled from a config file; the CLI flag, theenvfallback and the struct default are unaffected. The marker is comptime-checked (a typo or a non-bool value is a build error) and enforced in the registry rather than in the plugin, so noapplyConfigDefaultshook — bundled or third-party — can populate a marked field. A marked required option is not satisfied by a config file naming it: the user gets “missing required option”, which is the safe answer. This replaces guidance that previously lived only in a doc comment.
Changed (breaking)
Every
ui.Appnow also requirespub const debug = zcli.ui.debug;in your root source file (#759). Zig does not route hardware faults throughroot.panic— SIGSEGV/SIGILL/SIGBUS/SIGFPE go toroot.debug.handleSegfaultinstead — so the existing panic hook never covered the crash a TUI is most likely to hit. A segfault printed its stack trace into the alternate screen, which is then discarded: the user saw a wedged terminal and no diagnosis. The fix is one more line next to the panic hook (Prompts.debug/Progress.debugfor standalone users), and the compile-time check now enforces it.zcli initscaffolds pick the hook up in the first release that carries it:initpins generated projects to a published zcli, and namingzcli.ui.debugagainst one that predates it would not compile — so the scaffold stays in step with its pin rather than ahead of it. In ReleaseFast, where std installs no fault handler at all,terminal.guardcatches the four signals itself and restores before dying by the signal, so the core dump andWTERMSIGare preserved.zcli_secrets.getreturns aSecret, not a[]const u8(#769). The retrieved plaintext was the one hop in the package nobody scrubbed: the backends zero every intermediate copy they make (subprocess stdin/stdout buffers, the base64 form, the Windows OS-heap blob), but the value handed back was arena-owned — and an arena is released, not wiped, so a decrypted token stayed legible in reclaimable pages for the rest of the process.getnow returns a scopedSecretwhosedeinitzeroes the bytes before releasing them. Migration is two lines:const token = (try …get("token")) orelse …;becomesvar token = (try …get("token")) orelse …;plusdefer token.deinit();, and uses oftokenbecometoken.bytes.setanddeleteare unchanged.-o=valuenow meansvalue, not=value(#767). Exactly one leading=is stripped from a short option’s attached value, so-c=out.txtwritesout.txt— it used to write a file literally named=out.txt. GNU getopt keeps the=; docker (pflag) and cargo (clap) strip it, and so did zcli’s own long form (--config=out.txt), which is the inconsistency this removes.-cV,-c=V, and-c Vare now one spelling, in bundles too (-vqf=archive.tgz). A value that genuinely starts with=is written by doubling (-c==V) or as a separate token (-c =V). Affects any CLI whose users had come to rely on the getopt reading.Numbers are decimal-only on every numeric field, not just integers (#767). Integer options already forced base 10; floats delegated to
std.fmt.parseFloat, a Zig literal parser, so--count 0x10errored while--scale 0x10quietly meant 16. Both now screen the spelling first:0x10,0b101,0o17and hex floats (0x1p4) are errors for every numeric field,--port 010is ten (never octal), and_digit separators (--count 1_000) are rejected — that was Zig literal syntax leaking onto the command line, and no other CLI takes it. Positional arguments moved onto the same grammar, so af64positional that used to accept1_000no longer does. Floats keep1.5e3exponents and the case-insensitiveinf/infinity/nan. The grammar is now written down indocs/DESIGN.md(“Numeric value grammar”) and the website options guide, and it is the same predicate that decides whether a--leading token is a negative number rather than a flag — previously the classifier was decimal-only while the parser was not.Environment-variable values go through that same numeric grammar (#767). An
.env-boundu32/f64option resolved from the environment used to be parsed bystd.fmtdirectly, soMYAPP_COUNT=1_000orMYAPP_SCALE=0x10could set a value no command line could. A value outside the grammar is now ignored the way any other unparseable env value is (the field keeps its default), which changes the resolved value for apps that had been feeding it non-decimal input.zcli.http.Clientis HTTPS-only for every request, not just credentialed ones (#745). A plainhttp://URL now fails witherror.InsecureTransportbefore a connection is opened — the same rule the client already enforced on redirect targets (error.InsecureRedirect), and the one its module doc already promised. A request that also carried anAuthorization/Cookie/Proxy-Authorizationheader still reports the more specificerror.InsecureCredentialTransport. Loopback (127.0.0.0/8,::1,localhost) remains the sole carve-out, so local servers and test fixtures are unaffected.@fileresponse-file expansion is now opt-in, and off by default (#764). A leading-@argv token is an ordinary argument again unless the app sets.response_files = truein itsgenerate()config. Two reasons to flip the default. Functionally,@is a perfectly normal argument character —myapp install @scope/pkg, an@handle, auser@host— and unconditional expansion turned all of them intoerror.ResponseFileUnreadableand exit 2 for every zcli app, with no switch for the author to turn off. Security-wise, expansion is an arbitrary-file-read primitive:myapp @/etc/passwdinjects any readable file’s lines as arguments, and they resurface through value-rejection diagnostics — so any app with an attacker-influenced argv token carried a file-disclosure gadget it never asked for. Apps that genuinely want compiler-style argument files opt in and lose nothing;--remains the per-invocation escape for a literal@value. When enabled, the unreadable-file error now names--as that escape, and an oversize file reportsis too large (limit N bytes)(the newerror.ResponseFileTooLarge) instead of sharing the missing-file wording.
Fixed
zig build docsno longer clobbers a shared log file (#763) —zcli_docs‘s progress writer was positional, sozig build docs 2>>build.logpwrote from byte 0 and overwrote whatever preceded it instead of appending. Same class as the original ADR-0014 shared-offset P0 (#205): an inherited stderr is frequently a shared regular-file fd, and only a streaming writer honours the kernel’s shared offset. This site, the matching (pipe-only, benign) one inzcli_secrets, and the benchmark runners now all usewriterStreaming, and CI greps for the positional spelling so it cannot come back.zcli.http.Clientno longer risks a panic on a body read that failed without a recorded cause (#765) —bodyErr().?asserted astdinternal that nothing enforces, on the network-facing path the upgrade plugin uses. It now falls back to propagatingerror.ReadFailed.- The “Unknown command” diagnostic truncates a huge command path (#790) —
myapp x x x …×500 joined and echoed all 500 tokens back; the echo is now capped (cut on a UTF-8 boundary) with an ellipsis, while suggestion matching still sees the full string. Also in #790: the examples dropped a deaderror.CommandNotFound => std.process.exit(1)arm that contradicted the documented exit-code contract (run()already exits 3 and never returns that error), and the EPIPE-handling comment in the registry now describes the actual mechanism —std.Io.Threaded.initinstalls the no-opSIGPIPEhandler, not Zig’s start code — including the caveat that an app handingrun()an io built withThreaded.init_single_threadedgets no such handler and dies by signal mid-write. - A guard restore mid-repaint no longer leaves the terminal with autowrap off (#760). The diff renderer disables DECAWM (
?7l) and opens synchronized output (?2026h) for the duration of a paint, and the writer buffer drains mid-frame — so akill -TERM, a panic, or a Ctrl-Z during a repaint reached the terminal after the “off” and before the “on”. Long shell lines then overwrote the last column instead of wrapping. Both sequences are now part of every registered restore blob and ofpaint‘s error path, and the guard’s blob bound is checked at compile time at the one site that composes a blob (it was previously anstd.debug.assertwith ~2 bytes of headroom — which compiles out in ReleaseFast, where an oversized blob would have been a silent@memcpyoverrun). - A prompt opened inside a full-screen app no longer poisons the restore guard (#761). The guard held a single registration slot, so the inner (hybrid) session overwrote the outer (full-screen) one: the alt-screen leave was dropped from the restore blob, the saved termios became the already-raw mode captured inside raw mode — a restore-to-raw, strictly worse than doing nothing — and the inner session’s teardown disarmed the guard entirely, leaving the still-live outer app unguarded. Registrations are now a stack: a signal replays every live takeover outward (so the outermost, pre-raw termios wins), and closing the inner one hands the outer its own blob back.
- Ctrl-Z during a spinner no longer returns you to an invisible cursor (#762). Raw mode clears
ISIG, so prompts and full-screen apps never see SIGTSTP/SIGQUIT — but progress indicators run in cooked mode, where Ctrl-Z suspended the process with?25lstill in effect and Ctrl-\ dumped core the same way. The guard now installs both, only while every live takeover is cooked: SIGTSTP restores the terminal, stops by the signal, and re-enters on SIGCONT; SIGQUIT restores before the core dump. zcli_secretsno longer letsPATHchoose which binary receives a secret (#768) — the Linux backends spawned the bare namespass/secret-tool, andstd.process.spawnresolves a bareargv[0]against the parent environment’sPATH, so an attacker-controlled entry ahead of the real install got the credential on stdin plus the whole forwarded environment. Both helpers are now resolved to an absolute path in a fixed list of standard directories (/usr/bin,/bin,/usr/local/bin, the NixOS system profile, Linux Homebrew, then~/.nix-profile/bin,~/.local/bin) and spawned by that path; the availability probes use the same lookup, so they no longer fork a process just to answer “is it installed”. A helper installed outside those directories is now reported as unavailable — symlink it into/usr/local/bin— andZCLI_SECRETS_BACKENDruns through the same lookup, so forcing a backend whose helper is missing gives that actionable line rather than an opaqueBackendFailureat the first operation (the store’s own readiness — session bus, initializedpass, locked keyring — is still never second-guessed on the override path). The ambient environment is still forwarded whole, deliberately:passis a shell script that needsPATHto find its owngpg/tree/interpreter, so trimming would break it without closing anything (spawnnever consults the forwarded map to resolveargv[0]in the first place).zcli_secretsvalidates the app name, not just the secret name (#770) —validateName(no NUL, no/, no.., no leading-) was applied to everynameand to noservice, leaving half of thezcli/{app}/{name}key namespace structurally unguarded. Both halves now go through one rule; a bad app name fails with the newInvalidAppNameso the message points at the app rather than at the caller’s key. Not exploitable before — the app name is developer-controlled — but an unguarded half is a hole waiting for the day it isn’t.- An unknown multibyte short option no longer emits invalid UTF-8 (#766) —
myapp -éreported the lone byte0xC3as the option name, because the bundle walk advanced one byte per step. It now advances a whole codepoint, so the diagnostic readsUnknown option '-é'. The rendering boundary is hardened to match:writeSanitizedstripped only C0/DEL and forwarded everything ≥ 0x80 unchecked, so any byte that is not part of a well-formed sequence — argv is a byte string on POSIX — reached the terminal raw. Such bytes now render as U+FFFD, which also closes an overlong-encoding path (0xC0 0x9Bfor ESC) that the control-byte filter did not see. docs/DESIGN.mdno longer contradicts the parser on bundled value-taking options (#785) — it claimed-abf filewas rejected as ambiguous. It never was: bundling is GNU getopt, the first value-taking char ends the bundle and takes the rest of the token (or the next one) as its value, which is what makestar -czf archive.tgzwork. The doc now describes the shipped behavior, including the consequences authors trip on (chars after a value-taker are its value, and a following flag is never swallowed as one), and the website options guide says the same.zcli_configno longer echoes a rejected config value (#736) — a value that fails to parse is now reported by size, not content (config 'app.json' has an invalid value (22 bytes) for 'api_key' — ignoring). A config file is where a user puts a secret precisely to keep it off the command line, so a typo or a field’s type changing must not print the token into CI logs or scrollback. Every config path the plugin prints — the apply-pass warnings, the project-localnote:, the unreadable/unrecognized-file warnings, the not-found error — now renders through the diagnostic control-byte sanitizer.zcli_configno longer leaks a multi-value option’s buffer on a lenient skip (#750) — a config list with one element that fails to coerce (tags = ["ok", "!!bad"]for a[]SomeEnum) skipped the option without freeing the buffer it had already allocated. Masked by the arena-per-command in framework use; real on any path that supplies a plain allocator.
v0.22.0 — 2026-07-22
Changed (breaking)
- Doc generation is now a plugin;
generateDocs()/DocsConfigare removed (ADR-0030). The documentation generator moved out of the core build API intozcli_docs, the first build-only plugin: it wires thezig build docsstep and ships nothing in your binary. Migration is one line — delete thezcli.generateDocs(b, cmd_registry, zcli_dep, .{...})call and addzcli.builtin(.docs, .{ .formats = ..., .output_dir = ... })to thegenerate()plugins list (same formats, same output layout, byte-identical output). Under the hood this ships a general capability: any plugin (built-in or external package) can declare a build-time tool viaPluginConfig.tool— a host-compiled executable with comptime access to the generated registry, registered as a named build step and never linked into the shipped binary. Two smaller API notes:PluginConfig.init(a raw code-string field) is replaced byPluginConfig.config, filled via the newzcli.config(...)helper (zcli.builtin(...)callers are unaffected), and man-page.THdates are now stamped when the tool runs — still honoringSOURCE_DATE_EPOCH— instead of being injected as a build option. upgradeshows live progress via spinners — thezcli_github_upgradeplugin now renders its phases (checking for updates, download + verify, smoke test, install) ascontext.progress()spinners instead of buffered prints that only appeared when the command finished. Progress narrative moves to stderr (the progress convention — piped stdout stays clean); answers (--checkoutput, “already on the latest version”, the completion line) stay on stdout. Breaking: spinners run on the ui engine, so an app wiring in this plugin must declarepub const panic = zcli.ui.panic;in its root source file (enforced at compile time;zcli initscaffolds already do).
v0.21.0 — 2026-07-18
First-class single-command CLIs (ADR-0029), the zcli init wizard (ADR-0028), and five whole-repo hardening passes. Requires Zig 0.16.0.
Added
- Single-command CLIs — the root is a group (ADR-0029). A top-level
src/commands/index.zigis the root group’s index, and an executable root index with no sibling commands is a single-command CLI — therg/fd/jqshape. The root index gets everything a subcommand gets: typedArgs/Options,metavalidation, help (a single-command app’s usage leads withmyapp [OPTIONS] <ARGS>), completions in all four shells,zcli tree, andaddCommandTests. Routing gains one rule at every depth: when path matching stops short at a group whose executableindex.zigdeclares at least one positional, the remaining argv routes to it as values —myapp Worldruns the root index withname="World",app users 123runsusers/index.zigwith123. Real command names always win,--forces a colliding word to be a value, and a group with no declared positionals keeps full “did you mean?” suggestions. A metadata-only root index is a build error (that slot belongs toapp_description). Scaffold the shape withzcli init --template single. - The
zcli initwizard (ADR-0028) — everything around the skeleton, decided up front in a few keystrokes: a description prompt; the CLI-shape prompt (--template multi|single); the plugin picker, where selectinggithub_upgradegets a follow-up prompt for itsOWNER/REPO(defaulted from the git remote) instead of aTODOplaceholder (--upgrade-repo); an extras step covering git (default on:git init, a Zig.gitignore, an initial commit;--no-git) and GitHub Actions workflows (--github ci,release, release preselected whengithub_upgradewas chosen — it feeds the self-updater); and one summary + confirm before anything touches disk. The scaffold now includes a README stub, and init verifies its own output with a realzig build(--no-buildopts out), so the first command you type runs your CLI, not the compiler. The agent contract is a hard invariant: every prompt has a flag,--defaultsanswers every remaining prompt with its default (implied when stdin is not a TTY),--yesadditionally skips the confirm, and--dry-runprints the plan and file list without writing. zcli gh add workflow ci— scaffolds a build + test GitHub Actions workflow (SHA-pinned actions), the sibling ofgh add workflow release; both are also offered from init’s extras step.- Global options accept
--name=valueand attached short values, matching command options. - zcli_secrets hardening — the macOS backend migrated from the deprecated SecKeychain API to SecItem, and both the macOS and Windows backends now zero plaintext copies after use.
Changed (breaking)
root.zigis gone (ADR-0029): the["root"]pseudo-path machinery is removed with no compatibility shim — asrc/commands/root.zigis now an ordinary command namedroot. Move the file tosrc/commands/index.zigto keep single-command behavior (and gain the bare-positional routingroot.zignever had).- Short options are explicit-only (#439): a short flag now exists only when declared via
meta.options.<field>.short. The undocumented first-letter fallback — the parser silently accepting-vfor averbosefield that never declared it — is removed; help and completions only ever advertised explicit shorts. A comptime guard rejects two fields declaring the same short or resolving to the same long name. zcli init --versionis now--app-version(#565): the old spelling was unreachable — thezcli_versionplugin’s global--versionconsumed it before routing ever reached init.- Removed dead public API: both
PluginEntrytypes andparseOptionsAndArgs(zero instantiations existed).
Fixed
Five whole-repo audit passes (grades 8–12) closed roughly 150 issues since 0.20.0; highlights by area:
- Parsing — bundled short options with a separated value mis-parsed (#427), and a bundle could drop its trailing value-taker; global options now honor the
--terminator; an optional positional no longer steals a required token, and a non-trailing defaulted positional falls through on parse failure; a boolean’s--no-Xnegation colliding with a real field is rejected at comptime. - Plugin lifecycle —
onStartupwas never dispatched (#428);postExecutenow also runs on the unhandled-error path; anonErrorhook that itself fails keeps the original error; a mistyped subcommand’s CommandNotFound routes throughonErrorhooks; command options shadowed by plugin globals are a comptime error. - Help & friends —
--versionon a command group prints the version instead of group help; hidden groups no longer leak into help or completions; “did you mean?” suggestions are relevance-gated; completions escape short-option characters and skip value-taking global options’ values. - Terminal & UI — SS3 arrow decoding, CSI desync on modified arrows, wide-grapheme tears at clip and scroll boundaries, grapheme-cluster-granular text editing, Spinner/MultiBar lifecycle races, prompts double-free and SIGWINCH handling, the editor prompt restoring raw mode on error, Windows console code pages restored in
context.exit, and a console resize can no longer wedge Windows input. - Robustness — a broken pipe at the final buffered flush exits 141, not 0; scaffolder edits are atomic and reject splices that would produce non-compiling Zig; generated-code injection is closed for plugin config strings, scaffolded descriptions, and plugin names; the release command’s commit/tag/push sequence is recoverable and idempotent.
v0.20.0 — 2026-07-15
The terminal-native layout engine (ADR-0013), the migration of the interactive packages onto it, and its growth into a full-screen TUI toolkit (ADRs 0015–0020). Requires Zig 0.16.0.
Added
- Required options — a non-
bool, non-optional, non-arrayOptionsfield with no default (e.g.region: []const u8) is now a required option: the type says a value must be provided. “Required” means absent after every source — the CLI flag, a declared.envvariable, andzcli_config’s config file all satisfy it; only if none did does the command fail withMissing required option '--region'.plus a usage hint. Help marks these(required), shows them in the usage line (app cmd --region <value> [OPTIONS]), and lists enum choices for both options and positional args (one of: dev, staging, prod). (This shape was previously a compile error — see the breaking note below.) Thezcli add option <cmd> <name> --type Tscaffolder (and the interactive wizard) create a required option when no--default/--nullableis given. - Enum value suggestions — a mistyped enum value (positional arg or option) now gets a
Did you mean 'staging'?hint alongside theone of: …choice list, using the same edit-distance engine as unknown-command/option suggestions. zcli.ui— a terminal-native layout engine for CLI/TUI hybrid apps: a static stream flowing into scrollback plus a diffed live region (app.emit()/app.frame(node)). Immediate-mode node trees (box/text/spacer/custom,fit/len/fillsizing), viewport clamping, resize re-layout including reflow of the visible static tail, real-cursor placement for line editors, and plain-line degradation when piped.context.ui(.{})returns a pre-wiredui.App.- Full-screen TUI mode —
context.uiFullScreen(.{})/App.initFullScreenrun the same layout engine on the alternate screen with raw input and anApp.runevent loop (viewbuilds the tree,updatehandles a key/resize/mouse/focus/paste event or a deadline-schedulednulltick, an optional post-frame hook places the hardware cursor). The screen and scrollback are restored on exit; requires apub const panic = zcli.ui.panichook (checked at compile time). (ADR-0015) - Focusable widgets —
ui.widgets.TextInput,Select,Checkbox, andButton: immediate-mode structs with aview/handlecontract wherehandlereturns whether it consumed the key (the whole routing model), caller-owned focus viafocusNext/focusPrev, hardware-cursor placement, and click-to-focus.Selectsupports multi-line / wrapped options. (ADRs 0018–0019) ui.widgets.Table— a read-only data grid:Dim-sized columns (.fit/.len/.fill, distributed by the layout engine), a themed header band, a selectable/scrolling body with PgUp/PgDn paging, cell truncation, and overflow arrows. Adds.pageup/.pagedownto the terminal key parser (CSI 5~/6~). (ADR-0021)ui.widgets.Tabs— a stateless tab-bar row (the chrome only; the caller owns the content panes): a strip of labels with the active one themed apart from the muted rest, ←/→ moving the active tab with wrap-around and number keys1-9jumping directly, over a caller-owned active index.Tabis never consumed, so it stays reserved for focus navigation. (ADR-0021)ui.widgets.TextArea— a multi-line text field over a caller-owned buffer, sharingTextInput‘s codepoint-granular editing over a buffer with embedded\ns. Soft-wraps at the granted width (the same grapheme/ANSI-aware wrap machinerySelectuses), ↑/↓ move by visual row and Home/End to the row’s ends, Enter inserts a newline, PgUp/PgDn page by the field height, and the view scrolls to keep the caret visible. The caret is the real hardware cursor viacursor_out(a reverse block is the fallback). Renders through acustomleaf so wrap sees the granted width and the caret’s absolute cell is reported. (ADR-0021)ui.widgets.FocusRing(State)— a comptime focus-routing helper that derives the ring fromState‘s widget fields (any field whose type has ahandlemethod) in declaration order: a reifiedFocusenum, wrappingnext/prev, anddispatch(state, focus, key, extras)that routes a key to the focused widget and returns consumed (extrassupplies each multi-arg widget’s extra args, and must cover every multi-arg widget since dispatch compiles all arms). Sugar over the ADR-0018 switch — no framework loop, no registry, fully bypassable; generalizesfocusNext/focusPrev.examples/form.zigdrops its hand-writtenFieldenum and dispatch switch for it. (ADR-0021)- Scrollbar indicator — an opt-in
scrollbar: boolonviewportViewportOptsandSelect/TableViewOpts. Off by default (so content width stays stable as data grows); when on, it reserves a 1-cell right gutter and paints a proportional thumb — a dim track (prompts.hint) with a brighter thumb (surface.border), length ∝ visible/total (min 1 cell) and position ∝ scroll/(total−visible), touching the top at the first row and the bottom at the last. OnSelect/Tablethe scrollbar replaces the overflow arrows in the same gutter (the richer indicator for the column). No new theme tokens; the thumb math is a shared, unit-tested pure function. Closes the ADR-0021 widget-catalog arc. (ADR-0021) - Overlays, viewports, and popups — a
stackz-layer direction withcenterfor modals,viewportfor content taller than its window, andprobe/positioned/anchoredfor popups that flip above and clamp on screen; plus opt-inmouse/focus/pasteevents. (ADRs 0016–0019) - Theme-derived style defaults — every styling default derives from the root
zcli_themeat compile time: a newsurfacetoken group (border,panel) styles full-screen chrome,ui.paneland bordered boxes need no call-siteStyle, andui.role(r)resolves a palette role in one word. (ADR-0020) progress.MultiBar— stacked labeled bars for parallel work with thread-safe updates.- vterm supports DECAWM (private mode 7).
zcli.FieldInfogained acompletefield carrying a field’s.completecompletion source, unifying the framework’s per-field metadata behind a single comptime projection that both help and completions render from.initContextDataplugin hook — an optionalpub fn initContextData(data: *ContextData, context: anytype) !voidthat runs once per invocation, after the framework fills the core context fields and before any lifecycle hook, so a plugin can capture borrowed references off the context (allocator, io, app_name, environ, streams) into itsContextData. Itscontext.plugins.<id>methods then serve calls without the command re-threadingcontext. Pairs withdeinitContextData(both requireContextData; declaring either without one is a compile error). Cleanup on a failed init is the caller’sdeinit, sodeinitContextDatamust be safe on default-valued data.
Changed (breaking)
- Options contract: a non-
bool, non-optional, non-arrayOptionsfield with no default used to be a compile error (“required values belong inArgs”). It now compiles and means required option (see Added). Pre-1.0 this only affects code that was relying on that shape being rejected — no runnable app could have shipped one.Argspositionals are still the right home for a value that must appear on the command line in a fixed position. - progress: rebuilt as an instance API (ADR-0014, mirroring Prompts):
@import("progress")is theProgresstype bundling writer/io/allocator/theme, with.spinner()/.progressBar()/.multiBar()constructors — in commands,context.progress(). Indicator types are no longer writer-generic and gained idempotentdeinit();setText→setMessage,stopAndPersist→persist;SpinnerConfig.hide_cursorremoved (the engine owns the cursor); piped bars print one finish summary line instead of a line per update. - prompts: the
textPreview callback returns one line of plain text from the prompt’s frame arena (was: writes raw styled bytes) and is styled with the theme’s hint token;number’s range errors render inside the prompt frame instead of scrolling past it. Rendering is engine-based throughout — navigation repaints only changed cells, long input wraps correctly, and answered lines persist as static output. zcli_secretsmethod signatures:get/set/deleteoncontext.plugins.zcli_secretsno longer takecontext— the plugin now captures what it needs viainitContextData.context.plugins.zcli_secrets.get(context, "token")becomescontext.plugins.zcli_secrets.get("token")(same forset/delete).- The
ui.panichook is now required for everyui.App, not just full-screen. A hybridApp— the substrate under everypromptsprompt andprogressindicator — hides the cursor and rides the caller’s raw mode, so a panic mid-frame (anunreachable, an OOM, a caller’svalidate/previewcallback) that skipsdeinitwould strand the terminal.App.initnow compile-time-enforcespub const panic = zcli.ui.panic;in the root source file, the same checkinitFullScreenalready carried. Standaloneprompts/progressusers addpub const panic = Prompts.panic;/Progress.panic;(both re-export it); zcli apps usezcli.ui.panic. A missing hook is now a build error, not a runtime wedge.
Fixed
- A panic mid-prompt stranded the terminal in raw mode (#288) — a panic inside a hybrid prompt/progress frame (or a caller callback reached from one) ran neither
defer app.deinit()nor a signal handler, so the guard’s raw/cursor restore never fired and the shell was left raw with the cursor hidden untilreset. Theui.panichook that fixes this is now compile-time-required for everyApp(see Changed), closing the gap that made it “recommended” for hybrid. - A signal in the gap before a prompt’s first frame left the terminal raw (#322) — a prompt enables raw mode before building its
App, but the restore guard was only armed on the first frame, so an externalSIGTERMin the few allocations between the two skipped restoration.App.initnow arms the guard the moment it is handed the caller’s raw mode (hybrid_raw), covering the window. - Broken pipe —
yourcli cmd | head(a downstream reader closing the pipe early) now exits quietly with status 141 like a well-mannered unix program, instead of printingerror: WriteFailedand a return trace; cross-platform (no SIGPIPE handling), so Windows behaves identically. - A signal mid-prompt left the terminal in raw mode — an external
SIGTERM/SIGINT/SIGHUP(akillfrom another shell) while a prompt was active skipped the prompt’sdefer raw.disable(), so the async-signal-safe restore guard re-showed the cursor but never put termios back — the shell was stuck with no echo/line-editing untilreset. Prompts now register their raw mode with the guard (App.init’s new hybridhybrid_raw, the single arm/disarm site), so the signal handler restores termios too; andselect/searchno longer fabricate index 0 when raw mode fails to enable (they surface the error instead of “choosing” an item the user never saw). - HTTP client refuses
https→httpredirects to non-loopback hosts — a redirect whose target URL is plainhttp://and not loopback now fails witherror.InsecureRedirectinstead of following it; minisign + checksum already protect integrity, but this closes the downgrade path belt-and-suspenders. - Help blank line after COMMANDS — for a command that is both executable and a group (exec+group), the blank line separating the COMMANDS list from the follow-up “run –help for more” hint was dropped;
showSubcommandswas writing the trailing"\n"through the markdown formatter, which swallowed it (same root cause as the OPTIONS/ARGUMENTS row fix in #263). examples/fullscreen.zigkeyboard selection clipped on a short terminal — walking the process table’s selection down parked it one row below the view: pressing ↓ past the last visible row scrolled the window but kept the highlight off screen (it only reappeared on ↑). The example hand-counted a fixedvisible_rows = 8and passed it to bothTable.viewandTable.handle; on a terminal too short to fit the header + 8-row window plus the demo’s chrome (title, tab bar, blank, status, padding — ~6 rows), the surrounding box clipped the table’s bottom body rows while the widget still believed it owned all 8, soscrollForhappily parked the selection on a row the layout never painted. The example now measures instead of counting: the table node fills the vertical gap,ui.probereports how many rows it actually got, and each frame derives the window asprobed.h − header_rows(shared byviewandhandle, so they never drift) — the idiom to reach for over a chrome-counting constant. Widget-level golden tests reproduce the demo’s shape at a short height and assert the highlighted row stays painted while the selection walks to the bottom. (ADR-0021)ui.widgets.Tableclick-to-select off-by-one — clicking a table row selected the row below the cursor in the full-screen demo. Two things stacked: mouse reports are 1-based cells while surfaces are 0-based, andTable.viewpaints its column header as the table’s first row inside the same rectprobereports — so a hand-rolledrow = click_y - rect.ywas off by one on both counts. AddedTable.rowAt(rect, y) ?usize, which maps a 0-based click row through the header offset and the scroll window (and rejects clicks on the header or below the table), so click-to-select is one call with no layout magic numbers.examples/fullscreen.zignow usesui.probe+rowAt(the same click-hit-test idiomform.zig/popup.zigalready use). (ADR-0021)
v0.19.0 — 2026-07-05
Hardening and tooling release. Requires Zig 0.16.0.
Added
- Command-authoring tools in the meta-CLI:
zcli add option/arg/group/pluginandzcli mv/zcli rmrestructure command files in place via an AST splice engine — no JSON blobs, no regeneration. zcli guide— version-matched, topic-based reference for the framework’s idioms;zcli initscaffolds anAGENTS.mdthat points AI agents at it.zcli_secretsplugin — opt-in credential storage in the OS keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager), no plaintext fallback.- HTTP client with safe defaults over
std.http.Client(credential headers stripped on cross-origin redirects), plus a canonicalghauthexample showing the secrets + auth idiom. - Arena-per-command allocator for
execute()andcontext.fail()for friendly, stack-trace-free command errors. - Windows joins the first tier: unit tests and the full e2e suite (interactive tier via a ConPTY backend) run on Windows CI,
upgradeself-replaces correctly on Windows, and the console is put into UTF-8 mode so multibyte I/O round-trips.
Fixed
- Two full-repo audit passes burned down 50+ findings: memory leaks in the parse pipeline, a PTY-harness deadlock, vterm out-of-bounds on resize, comptime build errors that now name the offending command, config command-scoping applied to TOML/YAML (was JSON-only), and legible errors instead of
exit(1)throughout the build API.
Changed
generate()/generateDocs()/addCommandTests()take typed configs and derive the zcli module themselves.- The 2,460-line registry was split into focused submodules; zcli no longer re-exports all of serde.
- Releases are gated on the full test suite (including native Windows), and CI actions are pinned to SHAs.
v0.18.0 — 2026-06-30
The Zig 0.16 release — the largest since the project started. Breaking: requires Zig 0.16.0 (the new std.Io model).
Added
zcli dev— watches your source and rebuilds on change, with restart-on-change for a running binary (native fs events via kqueue/inotify/FSEvents).zcli tree— prints the command hierarchy, sharing the framework’s own discovery logic.- Interactive wizard for
zcli add command, plus declarative flags for scripted use. - Interactive prompts (text, confirm, select, multi-select, password, search, number, editor), config file support for TOML and YAML with per-command scoping, and command aliases.
- Apps can name their generated
Contexttype for full editor autocomplete in commands. - End-to-end test suite for the meta-CLI; docs website; Windows console backend and a libc-free terminal stack (fully static musl builds on Linux).
Changed
- The monolithic interactive package was split into focused, standalone packages:
zinput,terminal,vterm,zprogress,ztheme. vtermwas removed from zcli’s public re-exports — it’s a testing tool, available directly.zcli.builtin(.help, .{})shortcut for enabling built-in plugins.
v0.14.0 – v0.17.0 — 2025-10-23 to 2025-11-21
Zig 0.15.1 era. Windows support landed (v0.15.0), std.posix.getenv was dropped for portability, repeated short options for array types were fixed, commands gained support for C dependencies and command-specific imports (v0.16.0), and a completions memory leak was fixed.
v0.1.0 – v0.13.1 — 2025-10-09 to 2025-10-19
The foundation, built in ten days: build-time command discovery and routing, the plugin system (help, version, not-found, completions, config, upgrade), shell completions, hidden commands, shared modules, the zcli meta-CLI with init/release/upgrade, and the curl | sh install script. The dual-tag release scheme (v* library / zcli-v* CLI) was established during the 0.11–0.14 series.