Skip to content
ahmedelgabriPublic

Latest commit

Β 

History

6,230 Commits

Folders and files

Repository files navigation

~ 🍭 ~

built with nix

A Nix-flake-powered personal workstation setup for Unix machines.

This repository is the single source of truth for most of my system configuration: operating system settings, packages, shell, editor, terminal, automation, mail, and day-to-day development tooling. The main target is Apple Silicon macOS using nix-darwin + Home Manager, with an experimental NixOS host as well.

It is a personal setup first, but the structure is meant to stay reusable: hosts are composed from small feature modules, app configs live in config/, and new machines can be bootstrapped with a single nix run command.

screenshot

Font is Pragmata Pro, theme is my own fork of vim-colors-plain, Terminal is Ghostty.

Note

Pragmata Pro is a commercial font and is not bundled with this repo. By default, this flake expects a PragmataPro<version>.zip archive in the Nix store. Bootstrap accepts --without-pragmatapro to skip it for that run. See Font prerequisite.

What this repo manages

The repo is split between reusable Nix modules in nix/ and checked-in application configuration in config/. In practice it manages:

Area What is configured here
System management Nix, flake-parts, Home Manager, nix-darwin, host definitions, overlays, and small custom packages
Shell and terminal Zsh, tmux, herdr, Ghostty, Kitty, direnv, zoxide, eza, fzf, bat, ripgrep, and Yazi
Editor and coding tools Neovim, formatters, language servers, and Node/Bun/Python/Go/Rust tooling
Source control Git, Jujutsu, gh, gh-dash, tig, and delta
macOS automation Hammerspoon, Karabiner-Elements, system defaults, and window management
Mail and communication aerc, isync/mbsync, msmtp, and pass
Notes and media zk, markdown-oxide, qmd, Neovim notes workflow, mpv, and yt-dlp
AI / agent tooling Claude, Pi, Codex, OpenCode, sb, and related llm-agents tooling/config

Codex defaults are symlinked from config/codex/config.toml to /etc/codex/config.toml. Hooks and themes remain in ~/.config/codex, and Codex can write per-user settings to ~/.config/codex/config.toml. Nix generates hooks.json with the absolute path of the repository info hook; rebuild after editing the hook template.

Pi's configuration covers native tools, display settings, and model setup. Its extensions each live in a directory with an index.ts entrypoint. The /simplify command runs four parallel cleanup reviewers over the codebase and sends their findings to the current agent to apply fixes.

Supported targets

These are the targets currently represented in the flake:

System Status Hosts
aarch64-darwin Primary target, actively used on Apple Silicon macOS rocket, alcantara
x86_64-linux Experimental NixOS host nixos

If you do not pass a host during bootstrap, the scripts default to $(hostname -s).

Repository layout

The repo is organized around a small number of top-level directories:

Path Purpose
config/ Checked-in application configs for Neovim, tmux, herdr, git, Ghostty, Kitty, Yazi, aerc, Hammerspoon, Karabiner, Claude/Pi config, and more
nix/ Flake composition, reusable modules, host definitions, overlays, custom packages, and encrypted/private files
scripts/ Bootstrap scripts used by nix run for macOS and NixOS
templates/ Small flake templates for starting new projects

Inside nix/, the interesting pieces are:

Path Purpose
nix/parts/hosts/ Per-machine host definitions
nix/parts/modules/shared/ Reusable features such as shell, git, vim, mail, yazi, ai, node, and python
nix/parts/modules/darwin/ macOS-only modules such as defaults, Hammerspoon, and Karabiner
nix/parts/outputs/ Flake outputs like apps, templates, overlays, formatter, and dev shells
nix/pkgs/ Small custom packages and overrides used by the configuration
nix/secrets/ age-encrypted or private inputs used by the setup

The overall pattern is simple: a host declares a list of features such as shell, git, vim, mail, or ai, and the flake wires in the matching system and Home Manager modules automatically.

Installation

1. Install Nix

I use the new Nix installer:

Warning

Before installing, check the quirks section.

curl --proto '=https' --tlsv1.2 -sSfL https://artifacts.nixos.org/nix-installer | sh -s -- install

2. Font prerequisite

This setup installs Pragmata Pro through a custom Nix package that uses requireFile. If you own the font, add the archive to the Nix store before the first rebuild:

nix-store --add-fixed sha256 /path/to/PragmataPro<version>.zip

To bootstrap before importing the archive, skip the font for that invocation:

nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- alcantara --without-pragmatapro

Pragmata Pro remains enabled by default. The flag selects the <host>-without-pragmatapro configuration without editing the checkout or saving a preference. A later bootstrap or rebuild using the normal host includes the font again and requires the archive. Other fonts remain installed.

3. Bootstrap a machine

Run the repo directly from GitHub:

nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- <host>

Important

The value after -- is the host name consumed by the bootstrap app, not a flake reference like .#rocket.

The host is the flake attribute (e.g. rocket), independent of the machine's current hostname β€” MDM-managed machines may be renamed at any time. If omitted, it falls back to $(hostname -s), so on such machines pass the host explicitly.

Examples:

# macOS hosts
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- rocket
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- alcantara

# NixOS host
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- nixos

On the first run, the bootstrap app:

Step What happens
1 Uses the flake source it was launched from
2 Applies the selected host configuration
3 Clones the repo into ~/.dotfiles for later rebuilds
4 Reuses the local clone on subsequent runs
5 Runs doctor to list the setup still left to do

Platform-specific behavior:

Platform Behavior
macOS (aarch64-darwin) Installs Xcode Command Line Tools if missing, installs Rosetta when needed, moves the Nix installer's /etc/nix/nix.conf to nix.conf.before-nix-darwin (nix-darwin refuses to overwrite it), then switches the system using nix-darwin
Linux (x86_64-linux) Expects nixos-rebuild to exist already, then switches the system using nixos-rebuild

Checking what is left

SSH keys, GPG keys and the pass store are set up by hand afterwards. Run nix run ~/.dotfiles#doctor to see what is still missing; see scripts for what it checks.

4. Rebuild after the first install

Once the repo is cloned locally, rebuild from ~/.dotfiles.

macOS

cd ~/.dotfiles
nixup # helper for `nix flake update`
nixsw # helper for `darwin-rebuild switch --flake .`

Equivalent manual command:

darwin-rebuild switch --flake ~/.dotfiles#<host>

NixOS

cd ~/.dotfiles
nixup # optional helper for `nix flake update`
sudo nixos-rebuild switch --flake .#nixos

Homebrew on macOS

I still use Homebrew mainly for GUI apps.

Nix and/or Home Manager support for GUI applications on macOS is still a bit awkward, especially around symlinking into Applications/. Homebrew is still managed declaratively here, but it is installed and controlled through nix-homebrew rather than manually.

It also covers a few macOS-only dependencies that are awkward to source through Nix alone, such as Tart, which backs the sb sandbox helper.

Each macOS host trusts its configured homebrew.taps through nix-homebrew.trust.taps, without disabling Homebrew's trust checks. The AI module declares openai/tools for Tart on both hosts; only rocket adds the JetBrains and Docker taps. Removing a tap from the configuration does not revoke its trust; use brew untrust <tap> to revoke it.

Machine-local configuration

Not everything belongs in Git. This repo intentionally keeps some values outside of the tracked tree and loads them from a host-specific location.

Home Manager sets a per-host config directory under ${XDG_DATA_HOME}, and in my shell that path is exposed as $HOST_CONFIGS.

In practice this usually means something like:

$HOST_CONFIGS = ~/.local/share/<host>

Useful files to create there:

Path Purpose Example
$HOST_CONFIGS/zshrc Machine-local shell setup; tokens live in the keychain (secret set <name>) and are exported as lookups, never literals export GITHUB_TOKEN="$(secret get github-token)"
$HOST_CONFIGS/gitconfig Local Git settings that should not be committed, especially GPG signing details [user] signingkey =
$HOST_CONFIGS/hammerspoon/<host>.lua Machine-specific Hammerspoon extras loaded alongside the checked-in config in config/.hammerspoon/; <host> is the logical host name read from ~/.local/share/host-name, not the machine hostname Host-specific Lua config
$HOST_CONFIGS/pi/extensions Machine-specific Pi extensions added to the generated Pi settings Private extension
$HOST_CONFIGS/pi/skills Machine-specific Pi skills added to the generated Pi settings Private skill
$HOST_CONFIGS/tmux/sessions/<name> Host-local mx session definitions (win over config/tmux/sessions/); see that dir's README Work session layouts

If you want GitHub to show commits as Verified, the email on the commit, the email on GitHub, and the email attached to the public key all need to match.

Also remember to:

  • generate SSH keys
  • import or create your GPG keys
  • upload the public GPG key to GitHub if you use commit signing

Secrets and private data

This repo includes agenix, but I do not use it as the source of truth for important secrets.

The rough rule here is:

  • truly sensitive credentials are generated or imported per machine and stored in pass
  • agenix is used only for lower-risk private files that I still want to keep out of the public repo

Email setup

Mail is one of the more opinionated parts of this setup.

The stack looks like this:

Component Role
isync / mbsync Synchronizes mail
aerc Mail client
msmtp Handles outgoing mail
pass Stores credentials

The mail module currently has service defaults for:

Service
fastmail.com
gmail.com
cirrux.me

It also supports two modes:

Mode Behavior
local Mail is synced to local Maildir folders
remote aerc connects directly to the remote service

An account can list aliases, with fnmatch wildcards such as *@example.com for a catch-all domain; aerc replies from the alias a mail was sent to and leaves those addresses out of reply-all. Each account also takes a color (default blue). aerc renders unread mail addressed to that account's email domain in it, through a generated per-account styleset.

On a synced Gmail account A deletes the message from the local INBOX instead of moving it to an Archive folder: mbsync propagates that as removing the INBOX label, which is what archiving means on Gmail, and the message stays in All Mail. aerc hooks run mailsync-now after moves, flag changes, sends, and on quit so changes reach the server before the next timer tick.

When local accounts are enabled, synchronization runs automatically:

Platform Scheduler
macOS launchd
NixOS systemd --user timer

The timer runs every 2 minutes in this setup.

Authentication

Each account needs authentication for IMAP and SMTP. Some services also need extra credentials for JMAP or CardDAV.

Passwords are read from pass. For an account named Foo, the module expects entries under the lowercased account name:

Entry Used for
service/email/foo/password IMAP/SMTP password
service/email/foo/source Fastmail/JMAP source access
service/email/foo/outgoing Fastmail/JMAP outgoing access
service/email/foo/contacts Fastmail/CardDAV access

With two-factor authentication enabled, use an app-specific password where needed.

Flake templates

This repo also exports a few project templates from templates/.

Use them like this:

nix flake init --template 'github:ahmedelgabri/dotfiles#<template name>'

or:

nix flake init --template 'github:ahmedelgabri/dotfiles#<template name>' ./my-project

-t can be used as a shorthand for --template.

Available templates:

Template Description
default Empty flake template
node Simple Node / JS / TS template using pnpm
deno Simple Deno template
bun Simple Bun template
python Simple Python project template
python-script Simple one-off Python script template using uv script mode
go Simple Go template
rust Simple Rust template

Color system

The plain-dark and plain-light terminal themes are the color source of truth. Ghostty and Kitty follow the OS appearance, while terminal applications consume named ANSI slots instead of embedding independent truecolor or xterm-256 palettes. This keeps bat, delta, Jujutsu, LS_COLORS/eza, fzf, Yazi, aerc, tig, tmux, and the Zsh prompt synchronized automatically; Neovim's custom plain theme uses the same accents with extra structural shades needed by an editor. bat and delta render syntax through a custom plain.tmTheme that mirrors the Neovim theme's restraint (gray italic comments, blue strings and constants, default foreground for everything else) via bat's ANSI color encoding; the bat cache is rebuilt automatically on every Home Manager activation, so a rebuild picks up theme changes (or run bat cache --build by hand for an instant refresh).

File listing colors come from one checked-in file, config/zsh.d/ls_colors, which user-shell.nix splits at build time: lines prefixed eza: become EZA_COLORS (eza-only UI keys, plus reset to disable eza's built-in filetype styles) and the rest are joined into LS_COLORS, so eza, ls, fd, and zsh completions color files identically. The file header documents the SGR syntax and grouping; adding a color for a new extension is a one-line edit.

The dark palette is a restrained Tomorrow Night derivative. The light palette uses the corresponding Tomorrow family hues with darker green, yellow, and cyan values so colored text remains readable on #fafafa. ANSI bright black is the shared muted color, and red/green/yellow retain error/success/warning semantics everywhere.

How light/dark switching works

macOS appearance is the only switch. Ghostty selects theme = dark:plain-dark,light:plain-light natively, Kitty resolves its *-theme.auto.conf symlinks, and Neovim polls AppleInterfaceStyle on startup and FocusGained to set background, which reloads plain.lua. herdr switches between its [theme.custom.dark] and [theme.custom.light] tables when the terminal reports the change. Nothing else listens for appearance changes: every other tool only emits ANSI indices, so the terminal re-renders even already-printed output the moment its palette flips.

Slot conventions

  • Slot 0 (black) is subtle structure: tmux pane borders and ⦁ separators, aerc scrollbar and selection strips. The dark value is deliberately lifted to #303030 because a true near-black is invisible against the #111111 background β€” treat this slot as the chrome-visibility knob.
  • Slot 8 (bright black) is dim text: status line text, comments, secondary info.
  • Red, green, and yellow keep error/success/warning semantics everywhere; blue marks constants, strings, and links. Anything that is not a signal stays on the default foreground.
  • The delta_ wrapper must not pass --diff-so-fancy (or any per-line foreground style): delta disables syntax highlighting on lines whose style sets its own foreground, which hides the plain theme everywhere except context lines. Delta's default tinted add/remove backgrounds are chosen by its own light/dark detection.

Sync points

The palette is intentionally duplicated in a few places that cannot reference each other. When one changes, change them all:

What Where
Full palettes (slots 0-15 plus the diff-tint slots 16-18 holding Neovim's DiffAdd/Delete/Change backgrounds) config/ghostty/themes/plain-dark and plain-light, config/kitty/themes/plain-dark.conf and plain-light.conf, and terminal_palettes (slots 0-15 only; :terminal cannot remap higher slots) in plain.lua
Editor accents the themes table in plain.lua mirrors the terminal accent hexes, plus editor-only structural shades and diff/conflict backgrounds
Syntax restraint rules the treesitter groups in plain.lua and the scopes in plain.tmTheme express the same rules for Neovim and bat/delta respectively; the bat cache rebuild happens automatically at activation (or run bat cache --build for an instant refresh)
herdr surfaces config/herdr/config.toml's [theme.custom.dark] and [theme.custom.light] copy plain.lua's norm_subtle, cursor_line, and bg_subtle shades and the background, because the terminal palette has no slots for them
Comment markers (TODO/FIXME/…) painted by mini.hipatterns extmarks configured in plugin/mini.lua, styled via MiniHipatterns* groups that link to @comment.* in plain.lua β€” marker styling must go through those groups, not treesitter alone

Working on this repo

The flake also exposes a few outputs that are useful when editing the dotfiles repo itself:

Command What it does
nix develop Provides the default development shell, including Python, Ruff, and ty
nix fmt Formats the Nix code with nixfmt-rs
nix develop .#go Opens the Go-focused dev shell
nix run .#lint Runs every repo check CI runs: formatting, dead-code, type, shell, spelling, workflow, Go, and host evaluation checks, plus zizmor's online audits
nix run .#test-bootstrap Bootstraps a host in a disposable macOS VM; see scripts

All Python files, including the interactive startup script, use the strict Ruff and ty settings in pyproject.toml. See Python checks and tests for local commands and the documented rule exceptions.

Used by

Contributors

Languages