The shared foundation of the tui-tools family: the palette, the widgets, the configuration loader and the command runner that make every tool in the family look and behave the same.
import (
"github.com/tui-tools/tui-kit/config"
"github.com/tui-tools/tui-kit/runner"
"github.com/tui-tools/tui-kit/theme"
"github.com/tui-tools/tui-kit/ui"
)This is a library, not a tool. Install it as a dependency:
go get github.com/tui-tools/tui-kit@v0.1.3| Package | What it gives you |
|---|---|
theme |
The Tokyo Night palette, Omarchy theme detection, NO_COLOR, and a ready-made set of Lip Gloss styles |
ui |
Header, table, help bar, help screen, status line, and the confirm / input / picker / file picker dialogs — see docs/dialogs.md |
config |
/etc/<tool>/config.toml + ~/.config/<tool>/config.toml + environment, in that order |
runner |
Preview → confirm → run, including privilege escalation, timeouts and a fake for --demo and tests |
manifest |
Reads the tool.json a tool embeds, so the manifest is the single source at runtime too |
compat |
Probes the backend's version at startup, classifies it against the manifest, and answers caps.Has("timers") |
pkgmgr |
Detects the distribution and its package manager, reports whether the tui-tools repository is configured and which tui-* packages are installed, and builds the install / remove / upgrade / repository-setup commands |
report |
Renders the --report block a bug report needs — tool and kit versions, backend, distribution, kernel, terminal, where the binary came from — with no hostname, user name, home path or address in it |
Plus the scripts in tools/: render-screenshots.py renders a tool's README
screenshots from the real binary, or guide images from frames captured with
tmux capture-pane (see docs/screenshots.md), render-install.py and render-compat.py
generate the README sections that come from the manifest, compat-sync.py
rebuilds the tested-version lists from a tool's compat/results.jsonl,
check-nfpm.py asserts that the .deb/.rpm/pacman metadata in a tool's
.goreleaser.yaml still matches its tool.json — GoReleaser cannot read the
manifest, so the description is a copy, and this is what stops the copy from
drifting — and check-exec.sh asserts the exec boundary — os/exec in runner and in a
tool's internal/<backend>/, nowhere else. templates/ holds what a new tool
starts from — the CI workflow, the Scorecard and CodeQL workflows, the
GoReleaser configuration, the shared golangci.yml lint bar, the
gitleaks.toml secret scanning rules, the dependabot.yml that keeps its
dependencies current, FUZZING.md, the family's rule for the parsers, and
ISSUE_TEMPLATE/bug_report.yml, the
bug form that asks for the report package's block first —
and
schema/tool.schema.json is the manifest every tool
carries at its root so the family website can describe it — see
docs/tool-manifest.md and
docs/compatibility.md. What a tool has to show
before its manifest says "stability": "stable" is
docs/stability.md.
Dependencies are deliberately small: Bubble Tea, Bubbles and Lip Gloss, nothing else. Configuration and palette files are read by a forty-line parser rather than a TOML library.
Every tool in the family makes the same promise, and runner is where it is
kept. A tool never assembles a shell string. It builds a runner.Command — an
argv plus a description — shows it with ui.Confirm, and hands that same value
back to the runner once the user answered yes.
r, err := runner.New(runner.Options{
Bin: "ufw",
SearchPaths: []string{"/usr/sbin/ufw"},
SudoPrefix: cfg.SudoPrefix(), // "sudo -n"
InstallHint: "install it with `apt install ufw`",
})
cmd := runner.Command{
Argv: []string{"ufw", "--force", "delete", "1"},
Description: "Delete rule 1",
Destructive: true,
}
dialog := ui.Confirm{
Title: cmd.Description,
Command: r.Preview(cmd), // sudo -n /usr/sbin/ufw --force delete 1
Danger: cmd.Destructive,
Payload: cmd,
}
// … the user presses y …
out, err := r.Run(ctx, cmd)Because Preview and Run consume the same value, the text in the dialog is
guaranteed to be what executes. That is the whole trust boundary.
The preview is also a line a person can paste. Each argument is shell-quoted
with POSIX single quotes when it needs it (runner.Join, the rule of Python's
shlex.join), so ufw allow 19443/tcp comment 'headscale control (tailnet)'
reads, and runs in a shell, as the same argv the runner executes.
Reads go through Read, which escalates only when the tool says its reads need
it: ufw status does, systemctl list-units does not.
Reads and mutations are timed differently. A read is bounded by
Options.Timeout (default runner.DefaultTimeout, 15 s), so a stuck query
cannot freeze the UI. A mutation (Run) has no wall-clock timeout unless the
runner sets Options.MutationTimeout: it is what the user confirmed, and a
package manager killed mid-transaction leaves its lock behind (pacman's
db.lck, dpkg's lock) for every later call to trip over. A mutation stops only
when the caller cancels its context, and then it gets SIGTERM and
runner.MutationGrace to clean up before SIGKILL. While one runs, a tool shows
ui.RunningMessage in its status line, redrawn by ui.RunningTick, so a
five-minute install does not look like a frozen screen.
No child gets a terminal. Every process the runner starts, read or mutation,
escalated or not, leads a new session (setsid) and has no controlling
terminal. Its stdio being /dev/null and pipes is not enough on its own: with
sudo's Defaults use_pty (Ubuntu, sudo-rs) the command gets a fresh pty, and
debconf, a password prompt, an editor or a pager would open it and wait,
invisible behind the TUI. Without a controlling terminal that open fails at
once and the step fails with an error. A step that really needs the terminal is
a hand-off through tea.Exec, not a runner step.
The dialog is held to the same standard as the runner. ui.Confirm wraps its
body and its command preview to the dialog's inner width instead of clipping
them — a command whose tail is invisible is a command nobody can check — and
scrolls under a pinned title and footer when the body is taller than the
terminal. ui.Picker filters as you type, which is what makes choosing among
three hundred systemd units possible at all.
ui.FilePicker picks a path from a directory listing, or takes one typed or
pasted into its path field, and lists a fake tree in --demo.
docs/dialogs.md has the keys and the one behaviour change
this brought to the tools.
runner.Fake records what it was asked to run and answers from a canned table.
It is what makes --demo honest: the tool builds and previews every command for
real, and nothing reaches the system.
fake := &runner.Fake{Prefix: "sudo -n", Hook: func(cmd runner.Command) (string, error) {
return sample.apply(cmd) // mutate the in-memory state the way the real command would
}}The same type is the assertion in tests: press a key, then check that
fake.Ran holds exactly one command with exactly the argv the preview showed.
The default palette is Tokyo Night. If the machine runs
Omarchy, the tools read the active desktop theme from
~/.config/omarchy/current/theme/colors.toml and follow it, so switching the
desktop theme switches every tool. Any Omarchy-format colors.toml works.
Precedence:
TUI_THEME=/path/to/colors.toml, or the tool's own--themeflag;- the active Omarchy theme, when that file exists;
- the built-in Tokyo Night palette.
NO_COLOR is respected, per no-color.org: any non-empty
value keeps layout, borders and emphasis and drops every color.
A palette file that cannot be read never takes a tool down. theme.New returns
a Warning string the tool shows in its status line, and falls back to the
default.
config.Load reads, in increasing precedence:
- the defaults the tool declares;
/etc/<tool>/config.toml;~/.config/<tool>/config.toml;TUI_<TOOL>_<KEY>environment variables — only for keys the tool declared, so an unrelated variable can never leak in;- command line flags, which the tool folds in with
cfg.Set.
cfg, err := config.Load(config.Options{
Tool: "tui-firewall",
Defaults: map[string]string{
"backend": "auto",
config.KeySudo: "sudo -n",
config.KeyTheme: "",
},
})
backend := cfg.String("backend", "auto")Values stay untyped strings, read back with String, Bool or Int. An
unknown key in a file is kept but ignored, so a newer config never breaks an
older binary; cfg.OneOf rejects the values a tool does care about.
pkgmgr is what a launcher needs to know about the machine it was started on:
which package manager runs here, whether the family's repository is configured,
which tui-* packages are installed or available, and what exactly would be run
to change that.
pm, err := pkgmgr.New(pkgmgr.Options{SudoPrefix: []string{"sudo", "-n"}})
status, _ := pm.RepoStatus() // configured? which file says so?
have, _ := pm.Installed(ctx, names) // version per package, unprivileged
want, _ := pm.Available(ctx, names)
steps, _ := pm.Install([]string{"tui-firewall"})
for _, step := range steps {
// ui.Confirm(pm.Preview(step)) → pm.Run(ctx, step)
}The contract, in short:
- Detection.
Detectreturns the manager and the distribution. The binary has to be there — a manager that is not installed is not this machine's manager, whatever/etc/os-releaseclaims — and among the ones that are, the distribution decides, so an Ubuntu image carryingrpmis still driven by apt.apt,dnfandpacman, withParseOSReleaseexported so the decision table can be tested without a container per distribution. - Names. Anything that reaches an argv is validated against
^tui-[a-z]+$first. There is no builder that skips the check and no way to pass a name through it, so no command in this package can be assembled from input nobody looked at. - Commands are values.
Command{Argv, Privileged, Explain, Stdin, Env}is previewed and then handed back to be run, exactly asrunnerdoes — the command line in the dialog is the command line that runs. A read is never marked privileged: listing what is installed must not raise a password prompt. - apt never prompts. Every apt mutation (install, upgrade, remove, the
refresh of the repository setup) carries
Env: pkgmgr.APTEnv(),DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a, so Ubuntu's needrestart hook cannot sit on a debconf prompt nobody can answer while the TUI owns the terminal.arestarts the services left on replaced libraries, as Ubuntu server does unattended;lwould only list them into output nobody reads. sudo resets the environment, so the runner passes the variables throughenvafter the prefix, and the preview shows it:sudo -n env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y tui-disk. - Arch and Omarchy. On Arch an install or upgrade is
pacman -Syuwith the names, because a partial upgrade is not supported there. Omarchy refuses that: its pacman hook aborts any direct-Syuthat does not come fromomarchy update.Distro.Omarchy()recognises it (an os-releaseIDorID_LIKEstarting withomarchy, or the guard hook being installed), andInstall/Upgradethen buildpacman -S --needed --noconfirmwith the names, against the databases the repository setup's-Syor the lastomarchy updatesynced, with an explanation that says the system itself upgrades throughomarchy update, not here (OmarchyNote). - Companions. A package a tool drives that is not a
tui-*tool, such asheadscale(mirrored in the family repository) ortailscale(from its own repository), goes through the companion builders:BuildCompanionInstallOn/BuildCompanionUpgradeOntake the manager, the distribution and the targets and return the same plans as the tools, Omarchy's-S --neededincluded;BuildCompanionInstalled,BuildCompanionAvailableandBuildCompanionRemovemirror the rest. A name is held toCheckCompanionName(^[a-z][a-z0-9]*(-[a-z0-9]+)*$, at most 64 characters). An install or upgrade target may name its repository,tui-tools/headscale: pacman gets it as written, so Arch's own build of a mirrored package cannot win over the family's, and apt and dnf get the bare name (apt would read the qualifier as a release, and a dnf--repohides the distribution's repositories from dependency resolution). - Repository.
RepoStatusreports whetherpkgs.tui.toolsis configured — an apt sources file naming it, a dnf.repo, or a[tui-tools]section reachable from/etc/pacman.conf, including through anInclude.RepoSetup(fingerprint)returns the steps that add it, mirroringpkgs/install.shand Omarchy Server'stui-toolsaddon, with the key pinned by the caller's fingerprint: the returnedSetupnames the step whose output must match before anything imports the key. The fingerprint is never compiled in — one that lives in a library is one that cannot be rotated without a release. - Exec. Nothing in
pkgmgrstarts a process. Every command is executed byrunner, which is the family's sanctioned exec site, socheck-exec.shstays satisfied without a tool having to wrap this package in aninternal/of its own. pkgmgr.Fakeis the--demomachine: the same validation, the same previews, and a catalogue that changes the way an install would change a real one.
Every tool in the family is tui-<target>: the repository, the Go package and
the installed binary all carry that one name, with no aliases. tui-firewall,
tui-systemd. Use tui-<name>-<solution> only when a target needs
disambiguating.
The family assets live in assets/branding/ and are
regenerated with make branding. See docs/branding.md for
the colors and how to use them.
Every release is built by the tool's own CI workflow on a v* tag, and a tag
only becomes a release once the security job is green. That job runs
govulncheck over the code the build actually reaches, and gitleaks over
both the working tree and the whole git history — a secret that was committed
once stays reachable long after the commit that removed it. gosec runs a job
earlier, inside the lint bar.
What ships beside the binaries: a CycloneDX SBOM per archive, a keyless cosign
signature over checksums.txt, and SLSA build provenance for every archive,
package and the checksum file. None of it needs a key to be distributed: both
signatures are keyless, tied to the workflow identity GitHub issued at build
time.
Downloading tui-firewall_0.2.2_linux_amd64.tar.gz and its .deb, say:
gh attestation verify tui-firewall_0.2.2_linux_amd64.tar.gz -R tui-tools/tui-firewall
gh attestation verify tui-firewall_0.2.2_amd64.deb -R tui-tools/tui-firewallThat answers "was this file built by that repository's workflow". To check the
signature over the release as a whole, take checksums.txt and its bundle:
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp \
'^https://github.com/tui-tools/tui-firewall/\.github/workflows/ci\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txtThe identity regexp is the part that matters: it says the signature has to come
from that repository's ci.yml, running on a tag. Without it, cosign would
accept anything signed by anyone. Then check what you downloaded against the
file you just verified:
sha256sum -c --ignore-missing checksums.txtReplace tui-firewall with the tool you are verifying; everything else is the
same for every tool in the family.
Each repository also runs OpenSSF Scorecard weekly and publishes the result, so its supply-chain posture is public. A tool's README carries it as a badge:
[](https://scorecard.dev/viewer/?uri=github.com/tui-tools/tui-firewall)
[](https://www.bestpractices.dev/projects/14368)make check # gofmt, go vet, the exec boundary and the tests: what CI runs
make test
make check-exec # only runner may start a process
make brandingEvery package that parses command output carries at least one Go native fuzz
test, seeded from its testdata. make check runs the seed corpus of each
one like any other test; exploring past the seeds is something you run when
you touch a parser:
go test -run=^$ -fuzz=FuzzParseKeyFingerprint -fuzztime=5m ./pkgmgr/A crash writes its input under testdata/fuzz/; commit that file and it
becomes a seed the tests replay forever. pkgmgr/fuzz_test.go is the worked
example, and templates/FUZZING.md is the rule the
whole family follows, including why CI runs the seeds and not the fuzzer.
Tags are annotated, and the message is the release notes:
git tag -a v0.3.0 -m "What changed for somebody running the tool."
git push origin v0.3.0GoReleaser renders that message as the release header ({{ .TagBody }} in
templates/.goreleaser.yaml) above the generated commit list, so a release
opens with a sentence a person wrote instead of a list of subjects. A
lightweight tag leaves the header blank, which is the reminder to go back and
write one.
Issues and pull requests are welcome. Start with
CONTRIBUTING.md: it covers the pull-request flow — open an
issue first for anything larger than a fix — and the bar a change has to
clear, which is make check green, a table-driven test built from real
command output for any parsing change, and preview-then-confirm for anything
that mutates a system. CODE_OF_CONDUCT.md applies to
every interaction here.
Security problems do not go in the issue tracker: SECURITY.md says how to report one privately and what response to expect.
Early. The API may still move before v1; tools pin an exact version.
Unofficial. The family follows the Omarchy visual style. It is not part of the Omarchy project and not endorsed by its maintainers.
MIT — see LICENSE.
