Skip to content

fentas/atty

Repository files navigation

atty

Stargazers Latest release CI atty.sh Zig 0.16

 

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 dispatchinline for over 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:latest is ~14 MB
  • Zero-allocation hot path — per-keystroke dispatch does no heap traffic; Atuin lookups happen on a worker thread

 

🐚 What it looks like

$ 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.

 

🚀 Install

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 | sh

Either 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:latest

Supported 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.

Detecting atty from your shell

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.

 

🛠 Configure

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.zig copies 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

 

✍️ Writing a module

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.

 

🐳 Using Docker

# 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-tmp

Or 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.

 

📦 Built-in modules

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.

 

🧪 Build from source

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 test

Or 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/atty

make help lists every target.

 

🎯 Roadmap

  • 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 onOutput parsers that span read boundaries
  • BSD / macOS support (currently Linux-only; PTY dance needs ioctl(TIOCPTYGRANT) glue on Darwin)

 

🤝 Contributing

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.

 

📜 License

MIT. Use it, ship it, fork it, sell it — keep the copyright notice intact.

 

❤️ Gratitude

  • 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 + @hasDecl a viable plugin model.
  • Inspiration on the README treatment and conventional-commit release flow lifted from fentas/b.

 

Copyright © 2026-present fentas

About

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages