Made by Niche-Knack — Smart Tools. Sharp Solutions.
A k9s-style terminal UI for Zarf packages. Single static binary. Stable as of v1.0.0 — semver from here on.
z9s does not shell out to zarf — it links Zarf in as a Go library, so the
tool is Zarf. One version, no sidecar to extract, and structured output instead
of scraped terminal text. Native CLI operations honor context cancellation; TUI
deployments and removals run as cancellable tasks with captured logs.
Zarf (GitHub,
docs) is the free, Apache-2.0 airgap-native package
manager for Kubernetes. You build a Zarf package while connected — it gathers
the container images, Helm charts, manifests, Git repositories and files an
application needs into one declarative, compressed file — then carry that single
file into a disconnected network and deploy it the same way every time. zarf init gives a cluster its own registry and Git server so nothing reaches back out
to the internet; packages can also be signed, carry SBOMs, and move through OCI
registries.
Zarf already ships k9s for watching a cluster. z9s is the same idea pointed at Zarf itself: browse the packages on disk and in the cluster, inspect them, and build, deploy, and remove them from one keyboard-driven terminal UI.
Download the binary for your platform from the
latest release —
z9s_linux_amd64, z9s_linux_arm64, z9s_darwin_amd64 or z9s_darwin_arm64 —
check it against SHA256SUMS, and put it on your PATH:
curl -LO https://github.com/Niche-Knack-Apps/z9s/releases/latest/download/z9s_linux_amd64
curl -LO https://github.com/Niche-Knack-Apps/z9s/releases/latest/download/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
install -m 0755 z9s_linux_amd64 ~/.local/bin/z9s
It is one static file with no runtime dependencies, so it can be carried to an air-gapped host as-is.
Requires Go 1.26.5+. The build stamps the version from git describe.
make build # -> ./z9s
make check # gofmt, vet, tests — what CI runs
make release # amd64 + arm64 for Linux and macOS into dist/
z9s <path>... # interactive UI
z9s --read-only <path> # browse without deploy/remove actions
z9s ls <path>... # scan paths and print the catalog
z9s status # Zarf version skew between this binary and the cluster
z9s build <dir> # build a package
z9s deploy <path|part000|https://|oci://> [--components a,b] [--values values.yaml] \
[--set-values .key=VALUE] [--set-variables K=V]
z9s zarf <args>... # the full embedded Zarf CLI
On start z9s shows a full-screen splash (the mascot, the version, and live scan
progress) while the first catalog scan runs, then the k9s-style chrome: an info
panel (folder, package, kube context, Zarf local/cluster revisions), a
persistent key legend, the block logotype with the mascot at the right, and a
bordered viewing area whose title always names the view, its active filter, and
its row count — packages(/harbor)[3]. A new / filter replaces the previous
one; it never stacks.
Keys: : command (completes inline — Tab/→ accepts, ctrl-f one word),
/ filter, ? help — tabbed: keys, commands, and config (an annotated
example of config.yaml with every setting and environment variable defined),
ctrl-e hide/show the top panel (more rows on small
terminals), Enter drill in, esc back,
:q quit. The selected row and focused form field use the mascot's zarf blue
with bold black text. e opens the
package's zarf.yaml in $EDITOR (suspending the TUI, reloading on return),
v reviews and fills deploy variables straight from the catalog, and :fs is
an ncdu-style filesystem navigator — enter/l descend, h up, ~ home,
o opens a directory as the new catalog root. In the component
view: space or enter toggles, a all, n none, r reset to Zarf's
defaults, i edit deploy inputs, ctrl-y show the equivalent CLI. The header
carries a permanent legend of the current view's bindings, and the component
breadcrumb shows the live selection count (harbor 4/7). The Inputs view
combines legacy Variables and typed Package Values; f manages ordered values
files and V validates variable patterns and the merged values schema.
Use o for advanced namespace, timeout, retry, architecture, cache, checksum,
verification, remote-transport, and connected-mode controls; unsafe bypasses
are called out in the final plan.
Press d to review a local archive deployment plan, then D and type the
package name to confirm. :tasks shows progress/logs and c requests
cancellation. :deployed reads Zarf's package deployment records from the
current cluster and shows namespace overrides, generations, components, charts,
connect metadata, and recorded status. On a deployed row, ctrl-d opens a full
or partial component-removal plan. It shows Zarf's reverse removal order and
requires R plus the exact package name before submitting the task; Zarf's
incremental deployment-record updates make failed or canceled removal resumable.
Catalog metadata inspection is not a
trust decision; the deploy loader performs Zarf's if-possible signature
policy, where an invalid present signature fails and an unsigned package is
allowed.
:available is the default source browser. It gives each local definition and
built archive its own row, shows signature declarations and SBOM presence
without presenting either as verification, and lists configured OCI/HTTP
sources. Use r to rescan and s/S to change/reverse sorting. Remote sources
are configured as exact references in ~/.config/z9s/config.yaml; their
metadata remains explicitly unresolved until fetched:
catalogRoots: [/srv/zarf-packages]
packageSources:
- oci://registry.example/team/package:1.2.3
- https://packages.example/package.tar.zstSet readOnly: true in the same config, or pass --read-only, to remove
cluster-mutating hints and enforce the same capability policy at their key and
confirmation handlers. The header keeps the mode visible.
Press Enter on a remote row to review the transfer, then L to load it as a
cancellable task. OCI review pulls Zarf metadata layers only; HTTP tar archives
must be downloaded in full. A successful load enters the same component and
input workflow as a local archive, and deployment re-loads and re-verifies the
exact remote reference.
Successful deploy tasks asynchronously refresh the cluster-backed :deployed
records.
On a local definition row, c opens package creation. The wizard covers
create-time template values, flavor, differential base, registry rewrites,
signing, package splitting, SBOM generation/export, cache and OCI concurrency,
build-machine metadata, local/OCI output, and transport/version safety controls.
The plan shows template keys and only whether a signing password is set; secret
values remain masked in memory. Press C, then type CREATE <package-name> to
submit the cancellable task. The source browser refreshes after success.
Scan results are cached in ~/.cache/z9s/packages.json:
- composed definitions, fingerprinted over every
zarf.yamlat or below the package (so editing an imported child invalidates its aggregator), plus the linked Zarf version and flavor - archive inspections, fingerprinted by size + mtime — decompressing built
.tar.zstarchives (some are gigabytes) dominates a cold scan
Measured on the full infrastructure tree (326 sources, 66 archives): cold
3m00s, warm 0.6s. The cache is disposable; delete the file to force a full
re-scan. During a cold scan the walk and load phases report progress on the
splash screen (stderr for z9s ls).
Where the binary lives matters more than anything above. z9s links all of
Zarf (~190 MB); cold-loading that from a spinning disk takes ~45 seconds at 1%
CPU before main() even runs — indistinguishable from a hang. Run the
installed copy on fast storage (make install → ~/.local/bin/z9s). Every
launch writes its timeline to ~/.local/state/z9s/startup.log, whose first
line (exec-to-main) shows exactly this cost; z9s also flashes a warning when
it detects it.
--debug (or Z9S_DEBUG=1) appends timing telemetry to
~/.local/state/z9s/debug.log — scan phases, slow package loads, editor
sessions — for diagnosing lag with data instead of guesses.
:reg manages the OCI registries z9s knows: a adds one (name, endpoint,
plain-HTTP / TLS-skip flags — saved once to registries: in the config), p
pins the default, ctrl-d deletes the z9s entry (never the registry itself),
and enter browses read-only: repositories, then tags, with / filtering
live at each level. The built-in cluster (zarf) row tunnels to the
in-cluster registry with Zarf state's read-only pull credentials. Configured
registries authenticate via your docker login keychain.
Press o on a deploy plan (or the variables view) for the options sheet: a
grouped toggle list — general, transport & verification, and expert flags in
warning colour — over the editable equivalent command. The two stay in
sync: toggling a row rewrites the command; tab (or a mouse click) moves into
the command itself, where any value can be edited inline and any real
zarf package deploy flag is legal, not just the listed rows. Enter applies —
the line is parsed by the embedded Zarf command's own flags, so types,
aliases, and deprecations match the CLI exactly. A bad line never
half-applies: the error names the offending token, the last good line is
restored, and focus returns to the options. Sensitive variable values render
as *** and keep their real value unless replaced. The plan shows a one-line
options: n changed delta. Set mouse: false in the config if capture
fights your terminal's text selection.
Every deploy is remembered as a profile — the exact command line, keyed by
package and kube context, in the XDG state dir (never config.yaml, never a
zarf-config.yaml). The next plan for that package pre-seeds from it:
components, variables, values, options. Sensitive values are never persisted
(the stored line carries the mask, stripped on seeding). x on the plan
forgets a profile.
Within a session, a package's working state — components, variables, values,
options — is one draft shared by every way in: v from the catalog, d
from anywhere, enter into components. Set a variable under v, press d, and
it is still set. Drafts live until z9s exits (they may hold sensitive input, so
they are never written anywhere); the profile is the durable record of a
deploy that actually ran. Editing a package's zarf.yaml resets its draft,
since the old selection no longer describes the package.
A running task's log follows its tail like tail -f. Scroll up and it stays
where you put it; G or End resumes following.
The -zarf-XXXXXXXX suffix Zarf appends to image tags is derived from the image
name; the Zarf CLI version is not an input to that CRC. A local/cluster version
mismatch is still useful compatibility information: review Zarf upgrade notes
and package version requirements before changing cluster state.
z9s reads the cluster's version from the agent-hook deployment's image tag and
shows the relationship permanently in the header:
$ z9s status
z9s 0.2.0
zarf v0.83.0 (linked)
cluster v0.62.0 ✗ SKEW
agent 127.0.0.1:31999/zarf-dev/zarf/agent:v0.62.0
No cluster, or Zarf not initialized on it, is a normal state — z9s stays fully usable for browsing packages with nothing to connect to.
z9s init deploys the init package matching the linked Zarf, which upgrades the
cluster's Zarf and re-seeds the registry via the injector — so it also recovers a
registry wedged in ImagePullBackOff (it pulls its own image from itself, and
cannot start unaided).
Preflight is the default; nothing happens without --confirm:
$ z9s init --search-dir /path/to/init-packages
INIT PLAN
init package .../zarf-init-amd64-v0.83.0.tar.zst
package ver v0.83.0
z9s zarf v0.83.0
cluster now v0.62.0 ✗ skew → will become v0.83.0
warnings:
• this UPGRADES the cluster's Zarf from v0.62.0 to v0.83.0: the registry,
agent and injector are redeployed
• already-deployed packages are not redeployed or otherwise changed
this is a plan only — re-run with --confirm to execute
:init shows the same plan in the TUI. Press I, then type INIT <linked-zarf-version>
exactly, to execute it as a cancellable logged task. Read-only mode removes that
action. Two guards apply: an init package whose
version differs from the linked Zarf is refused (initializing with it would
just recreate the skew) unless --allow-version-mismatch is passed, and the
package must be kind: ZarfInitConfig.
The init package is ~420 MB — more than double this binary — and the great
majority of z9s sessions never init anything. More importantly it is
site-specific: it is built locally with a zarf-config.toml pinning the
registry, gitea and socat image tags. A bundled copy would ship images that
contradict that config. Upstream Zarf does not bundle it either.
So z9s locates it, and makes locating it a one-time cost:
- searched in the working directory, next to the binary, in the Zarf cache, in the catalog roots, and in any remembered directory
- a directory where one is found is remembered in
~/.config/z9s/config.yaml, so later runs need no arguments - a not-found error lists every path tried
- press
pon the:initplan (or runz9s init --list/--remember) to choose among every discovered init package — the stock one, older versions, or a custom build with site-specific components — and pin it asinitPackage:in the config. The choice is made once; every later init uses it without asking.Z9S_CONFIG_DIRoverrides the config location, mirroring k9s'sK9S_CONFIG_DIR. - air-gap posture: z9s never downloads an init package. Upstream
zarf initfalls back to fetching from GitHub when none is found; z9s searches local disk (or an internaloci://reference you configured yourself) and fails loudly otherwise. --init-packageaccepts anoci://reference, which is the better answer across several nodes: publish the init package to the internal registry once and pull it anywhere, instead of copying 420 MB around. SetinitPackageRefin the config to make it the default.
z9s keeps its own config rather than writing a zarf-config.yaml, because Zarf
resolves that from the working directory — a stray one at a repo root silently
applies to every package built beneath it.
In the TUI, :init shows the plan and I plus a typed INIT <version>
confirmation runs it as a cancellable task with a captured log (:tasks).
z9s init --confirm remains the CLI path.
z9s ls output columns: package name, version, component/variable/image counts,
architecture of built artifacts, and load status.
| Status | Meaning |
|---|---|
ok |
Loaded via Zarf's canonical loader: imports composed, flavors applied, validated |
raw |
The loader refused it (usually a lint error) but the YAML parsed. Listed so it can be fixed. Imports are not composed |
broken |
The YAML could not be parsed at all. Still listed so it can be found |
artifact |
A built archive not uniquely linked to a source definition; assembled metadata remains inspectable |
The raw tier exists because Zarf's loader is fail-closed on lint errors — a
browser that hides broken packages is useless exactly when you need it.
Component actions frequently call ./zarf tools kubectl and ./zarf tools yq.
When Zarf runs as a library, those must resolve to something real, so z9s sets
ActionsUseSystemZarf=false and ActionsCommandZarfPrefix="zarf" and mounts
Zarf's whole command tree under z9s zarf. That is why the binary is large: it
contains the same package, cluster, registry, Git, signing, and inspection code
as the corresponding Zarf release.
cmd/z9s entrypoint; cobra root, mounts the embedded Zarf CLI
internal/zarfx the ONLY package that imports Zarf — facade over the library
internal/ui the nines shell (github.com/Niche-Knack-Apps/nines), aliased + z9s art
internal/view screens
internal/config XDG config
internal/zarfx is a deliberate boundary: Zarf's library API is pre-1.0 and the
v1alpha1 → v1beta1 schema migration is in flight, so a Zarf bump stays a
single-package change.
Every merge to main runs make check. When the merge carries a new version in
VERSION, the release workflow cross-compiles static amd64 and arm64 binaries for
Linux and macOS, tags
vX.Y.Z, and publishes the binaries with SHA256SUMS as a GitHub release. A pull
request that does not bump VERSION fails its check, so every merge ships.
The header above is the same art z9s draws in the terminal (internal/ui/logo.go),
rendered to SVG by hack/art-to-svg.py so the colours survive Markdown.
docs/logo-large.svg is the splash mascot. Regenerate after editing the art:
python3 hack/art-to-svg.py docs/logo.svg && python3 hack/art-to-svg.py docs/logo-large.svg --large.
z9s is free and open. Its terminal shell is nines, also open. Niche-Knack also makes small, sharp, offline-first apps — see them at nk.tools.
Really believe in this project's potential and want to contribute to its development? Buy Me a Coffee.