atty is a Suckless-style PTY proxy in Zig. It sits between your terminal emulator (Ghostty, Alacritty, kitty…) and your shell, and composes its middleware at compile time instead of loading plugins at runtime. Edit src/config.zig, recompile — that is the entire configuration model.
Features:
- Comptime module dispatch —
inline forover your config tuple; disabled modules contribute zero bytes to the binary - Atuin autosuggestions — fish-style dim/italic ghost text from your shell history, via an async worker thread
- Dangerous-command guardrail — swallows Enter on
rm -rf /,dd if=…,… | sh, fork bombs, and friends, then waits for a confirm - Five hooks per module:
attach/detach/onInput/onOutput/provideGhostText/onTick— implement what you need, the rest is statically dropped - Single static binary — musl-linked, no libutil, no runtime deps;
ghcr.io/fentas/atty:latestis ~14 MB - Zero-allocation hot path — per-keystroke dispatch does no heap traffic; Atuin lookups happen on a worker thread
$ git checkout featu re/auth-refactor ← dim italic ghost text
^^^^^^^^^^^^^^^^^
$ rm -rf /home/work/
! atty guardrail: rm -rf on a root-ish path
line: rm -rf /home/work/
press Enter again to confirm, any other key to cancel.
A live, animated demo lives on atty.sh.
Two installers, two philosophies. Pick one.
# 🛠 The Suckless way — get the source, edit src/config.zig, compile.
# Bootstraps Zig if you don't have it. Prompts before opening config.
curl -fsSL https://get.atty.sh | sh
# 📦 Just give me the binary — no toolchain, no source, default modules.
# Resolves arch, verifies sha256, chmods, hints at $PATH.
curl -fsSL https://bin.atty.sh | shEither one installs to ~/.local/bin/atty by default. Both honor
INSTALL_DIR=…. The binary installer additionally honors
ATTY_VERSION=…; the source installer honors ATTY_SRC=…,
ATTY_NONINTERACTIVE=1, and REPO_URL=….
For container use:
docker pull ghcr.io/fentas/atty:latestSupported pre-built targets: linux-x86_64, linux-aarch64
(musl-static). The source flow works anywhere Zig 0.16 does.
Then make it your terminal's startup command. Ghostty
(~/.config/ghostty/config):
# Ghostty starts atty, which then starts your shell.
command = atty bash
Pin the shell explicitly (atty bash/atty zsh/…) in your terminal
config rather than relying on $SHELL — when the terminal is what
spawns atty, the environment is minimal and $SHELL may not yet be
set. Inside the shell that atty spawns, your .bashrc/.zshrc will
of course see $SHELL normally.
Or invoke ad-hoc:
atty # spawn $SHELL through the proxy
atty bash # spawn bash explicitly
atty bash -l # bash with -l
atty zsh -c 'echo hi' # zsh -c 'echo hi'The first non-flag positional is the shell binary; everything after it
is passed to that shell verbatim — same convention as env(1) or
sudo(1). Use -- only if your shell's name starts with a dash.
atty injects three env vars into every spawned shell. Use them in your
.bashrc/.zshrc to avoid double-wrapping:
# Only run atty if we aren't already inside an atty session,
# and only if the binary is actually on PATH (so a missing install
# never locks you out of your shell).
if [[ -z "${ATTY}" ]] && command -v atty >/dev/null; then
exec atty bash
fi| Variable | Value |
|---|---|
ATTY |
1 |
ATTY_PID |
pid of the atty proxy (parent) |
ATTY_VERSION |
semver string (e.g. 0.1.0) |
The command -v guard is a footgun saver: if atty disappears (uninstall,
upgrade gone wrong, fresh machine), your shell still starts normally
instead of bailing on exec failure.
atty has no runtime config file. dwm-style two-file split:
src/config.def.zig— committed template with commented examples (atty maintains).src/config.zig— your file. Gitignored.build.zigcopies the template across on first build.src/defaults.zig— atty-shipped value for every knob.
Edit src/config.zig. Recompile. Your edits never conflict on git pull because the file isn't tracked, and your config only contains what you override — every other knob falls through to defaults.zig, so new tunables added upstream just appear.
const atty = @import("atty");
// Pick your modules. Default = { guardrail, history } — dependency-free.
pub const modules = .{
atty.modules.guardrail.configure(.{}),
atty.modules.atuin.configure(.{
.suggestion_ttl_ms = 0, // 0 = fish-style (no fade)
.sync_after_records = 10,
}),
atty.modules.history.configure(.{}), // shell-native fallback
};
// Override the visual style if you don't want the dim-only default.
pub const ghost: atty.Ghost = .{ .style = atty.style.presets.muted_italic };
// Override the accept keys if Right / End / Ctrl+F isn't what you want.
pub const keymap: atty.Keymap = .{
.bindings = &.{
.{ .bytes = atty.keymap.key("Tab"), .action = .ghost_accept },
},
};Every subsystem (proxy, ghost, terminal, keymap, statusbar) is a struct with per-field defaults — your pub const xxx: atty.Xxx = .{ … } only spells out the fields you want different. Anything you don't declare picks up defaults.zig.
To track a config outside the repo:
make CONFIG=/path/to/mine.zig build
# or
zig build -Dconfig=/path/to/mine.zig
A module is a Zig type — typically returned from configure(comptime cfg) type — with some subset of these decls:
| Hook | Called when | Hot path |
|---|---|---|
attach(allocator) |
once at startup | no |
detach(rt) |
once at shutdown | no |
onInput |
every keystroke from the user | yes |
onOutput |
every chunk from the shell | yes |
onLineCommit |
Enter pressed on a non-empty, certain line | no |
provideGhostText |
when atty wants to render an overlay | yes |
onTick |
on poll() timeout (default 100 ms) | no |
Minimal example — uppercase every keystroke:
pub fn configure(comptime _: Config) type {
return struct {
pub const Runtime = struct { buf: [256]u8 = undefined };
pub fn attach(_: std.mem.Allocator) !Runtime { return .{}; }
pub fn detach(_: *Runtime) void {}
pub fn onInput(rt: *Runtime, _: *m.Context, in: []const u8) m.Error!m.Action {
for (in, 0..) |b, i| rt.buf[i] = std.ascii.toUpper(b);
return .{ .replace = rt.buf[0..in.len] };
}
};
}Full walkthrough: docs/modules.md or atty.sh/modules.
# Run atty inside a container
docker run --rm -it ghcr.io/fentas/atty:latest
# Copy the binary out of the image
docker create --name atty-tmp ghcr.io/fentas/atty:latest && \
docker cp atty-tmp:/usr/local/bin/atty ./atty && \
docker rm atty-tmpOr use it as a base layer in your own image:
FROM alpine:3.20
COPY --from=ghcr.io/fentas/atty:latest /usr/local/bin/atty /usr/local/bin/atty
ENTRYPOINT ["atty"]The image is multi-arch (linux/amd64, linux/arm64) and the binary is musl-static.
| Module | Hook surface | Purpose |
|---|---|---|
guardrail |
onInput |
Confirm-on-Enter for rm -rf /, dd, mkfs, fork bombs, curl-pipe-sh |
history (default) |
onInput, onLineCommit, provideGhostText, onTick |
Shell-native suggestions from ~/.bash_history / ~/.zsh_history |
atuin (opt-in) |
onInput, onLineCommit, provideGhostText, onTick |
Fish-style autosuggestions from your Atuin history + record on Enter |
Add your own under src/modules/ and wire it into config.modules. Reference docs: atty.sh/providers.
mise use zig@0.16.0 # any other Zig 0.16.0 install also works
zig build # → ./zig-out/bin/atty
zig build test --summary all # 33 unit tests
zig build itest --summary all # PTY round-trip integration testOr via Make:
make build # ReleaseSafe
make test itest
make install # → ~/.local/bin/atty
make docker # local image
make docker-binary # build in docker, copy binary to ./dist/attymake help lists every target.
- OSC 133 prompt-marker awareness (drop guesswork from the line-state model)
- Atuin daemon socket backend (replace the subprocess fallback once IPC stabilises)
- Bracketed-paste detection (suppress ghost text during a paste burst)
- Ring buffer for
onOutputparsers that span read boundaries - BSD / macOS support (currently Linux-only; PTY dance needs
ioctl(TIOCPTYGRANT)glue on Darwin)
PRs welcome. The flow is feat/fix conventional-commit PR → release-please opens a release PR → merging the release PR cuts a tag and ships binaries. Details and PR-title rules in CONTRIBUTING.md.
Before pushing: make fmt && make test.
MIT. Use it, ship it, fork it, sell it — keep the copyright notice intact.
- Atuin — the history daemon this proxies for.
- Suckless — for the config.h, recompile, ship aesthetic this whole project apes.
- Ghostty, Alacritty, kitty — the terminal emulators atty plays in front of.
- Zig — for making
inline for+@hasDecla viable plugin model. - Inspiration on the README treatment and conventional-commit release flow lifted from fentas/b.
Copyright © 2026-present fentas