Personal dotfiles managed with GNU Stow for flexible, modular configuration management across macOS, Arch Linux, and Ubuntu/Debian (server or desktop).
git clone <repo-url> ~/dotfiles
cd ~/dotfiles
./install.shThe script detects your OS and installs an appropriate default profile:
- Ubuntu/Debian →
serverprofile (TUI tools, no GUI) - Arch / macOS →
desktopprofile (everything)
Override:
./install.sh --profile=server # force server (no GUI)
./install.sh --profile=desktop # force desktop
./install.sh --profile=server --yes # non-interactiveTo pull repo changes onto a machine and reconcile its config, re-run the installer:
cd ~/dotfiles && git pull && ./install.shinstall.sh is idempotent and doubles as the updater:
- Edited configs are live immediately — stowed files are symlinks into the repo, so a
git pullupdates them in place with no extra step. - New packages / missing tools are installed (
install_packagesskips anything already present) and stowed (stow -Ris a safe restow). - Removed packages or deleted config files are cleaned up: a prune step removes dangling symlinks that point back into the repo, so deletions propagate too. Only broken links resolving into the dotfiles dir are touched — nothing else.
| server | desktop | |
|---|---|---|
| bin, zsh, git, nvim, ssh, glow, hunk | ✓ | ✓ |
| lazygit, yazi, btop | ✓ | ✓ |
| ripgrep, fzf, eza, zoxide, mosh | ✓ | ✓ |
| ideavim | — | ✓ |
| Hyprland + wayland tools (Arch) | — | ✓ |
| GTK theme (Arch) | — | ✓ |
| Ghostty + Zed (macOS) | — | ✓ |
| Hammerspoon + speak-server (macOS) | — | ✓ |
Stow packages are organized by tool. Install individually with stow <pkg>:
- bin — Cross-OS executables for
~/.local/bin(currentlyspeak) - zsh — Zinit, Powerlevel10k, OS-specific fragments, plugins
- git — Git config with hunk diffs
- nvim — Neovim config
- hunk — Diff viewer, Catppuccin Mocha, side-by-side (git's default pager)
- herdr — Terminal multiplexer config
- ssh — SSH defaults with ControlMaster multiplexing
- lazygit, yazi, btop, ideavim — TUI tool configs
- glow — Markdown reader, Catppuccin Mocha theme
- hyprland, wayland-tools, gtk — Arch desktop
- macos-tools — Ghostty, Zed (macOS)
- hammerspoon — Hammerspoon config +
speak-serverTTS endpoint (macOS) - zathura, godot — creative tools
Claude Code config (claude/.claude/) is not stowed. Its authored files
(CLAUDE.md, hooks/, skills/, settings.json) are symlinked by install.sh
into each per-alias config dir — ~/.claude-personal (bare claude / ccp) and
~/.claude-dsf (ccd) — so all profiles share one source of truth while keeping
their own history/sessions. Hook commands use $CLAUDE_CONFIG_DIR so they resolve
under whichever dir is active.
Anything specific to one machine — work-VM aliases, Cloud SDK paths, host-only API keys — goes in ~/.zsh-local.sh. This file is gitignored.
A template is provided at zsh/.zsh-local.sh.example:
cp zsh/.zsh-local.sh.example ~/.zsh-local.sh
$EDITOR ~/.zsh-local.shOn every machine that isn't the Mac, this is also where SPEAK_HOST lives — see
Dictation below.
For a pure SSH-only server:
git clone <repo-url> ~/dotfiles
cd ~/dotfiles
./install.sh --profile=server --yesNotes:
- SSH lands you in zsh automatically. An unattended (
--yes) install skips the password-gatedchshand instead appends anexec zsh -lguard to~/.bashrc(Ubuntu's default~/.profilesources it on login). Reconnect after install. For a one-off plain bash, runNO_AUTO_ZSH=1 bash. To make zsh the real login shell instead, runchsh -s "$(command -v zsh)"(and re-login). - This matters for
claude: environment it (and tools it spawns like glow/hunk) needs —CLAUDE_CONFIG_DIR,PATH,EDITOR— is exported from~/.zshenv, so it's present in every zsh, but you still need to be in zsh for it to load. neovimis installed from GitHub release into/opt, not apt — Ubuntu pins neovim for the life of a release (24.04 is stuck on 0.9.5) and the nvim config needs 0.11+. Re-runninginstall.shupgrades an older nvim in place.lazygitis installed from GitHub release (not in apt).yaziis installed viacargo install yazi-fm yazi-cliif Rust is present, otherwise skipped.- Optional packages (
eza,btop) install if available on your Ubuntu version.
stow zsh git nvim ssh # core
stow -D zsh # uninstall
stow -R zsh # restow (after editing)Capture a screen region with ⌘⇧⌃5, ship it to the dev server, and get the
remote path on your clipboard — paste it into a Claude Code session running over
SSH/herdr.
One-time setup (not version-controlled — host details are personal):
-
Define your Claude Code server as an SSH host alias in a gitignored
~/.ssh/config.d/*.confblock (host-specific details — IP/user/forwards — never get committed). NoControlMasterlines needed; the globalHost *block in the stowed~/.ssh/configalready multiplexes connections. Example:# ~/.ssh/config.d/10-ccd-dev.conf (gitignored) Host ccd-dev HostName <vm-ip-or-dns-or-tailscale-name> User <your-user> IdentityFile ~/.ssh/<your-key>
Set
REMOTE_HOSTin~/.local/bin/img2serverto match this alias (defaults toccd-dev). -
Run
./install.sh --profile=desktop(installs skhd + starts its service) andstow -R macos-tools. -
Grant skhd permission in System Settings → Privacy & Security: Accessibility (to capture the hotkey) and Screen Recording (approved on first capture).
Use: press ⌘⇧⌃5, drag-select a region. A notification shows the remote
path and it's on your clipboard; ⌘V into Claude Code. Each capture is a
unique, immutable file under /tmp/cc-images/, so several can be pasted into
one prompt. Files are cleared on server reboot.
speak reads text aloud through the Mac's speakers, from any machine. Ask a Claude
Code session to "dictate that plan to me" and it pipes a spoken-friendly rewrite
into speak; /dictate does the same for the previous response.
speak "build is green" # argument
git log --oneline -5 | speak # stdin
speak --md PLAN.md # strip Markdown syntax first
speak stop # interrupt playback, clear the queueReading Markdown. Specs and implementation plans are the main thing worth listening to, and there are two ways to hear one:
- Ask Claude — "dictate PLAN.md to me". It reads the document through section by
section, announcing each section as it goes, summarising code blocks and tables in
a sentence each, and reading paths naturally. This is the good one; the rules live
in
claude/.claude/CLAUDE.md. speak --md FILE— a mechanical strip with no LLM in the loop. Drops frontmatter, HTML comments, and horizontal rules; replaces code blocks and tables with a one-line mention; turns checklists into "done"/"to do"; unwraps links, emphasis, and list markers. Instant, but it reads paths literally and can't summarise anything.
How it routes. The script (bin/.local/bin/speak) branches on tool
availability, not hostname:
- Everywhere
sayis missing (the Azure VM) it POSTs the text over Tailscale to the Mac's Hammerspoonspeak-serveron port 8722, which speaks it there. No audio stack is needed on the VM, and only text crosses the network. - On the Mac it POSTs to that same server on
localhost, rather than callingsaydirectly. That keeps both machines on one code path, and it returns immediately instead of blocking for the length of the speech — which matters when a spec takes eight minutes to read and the caller is a tool with a timeout. - Falling back to direct
sayhappens when the server is unreachable (Hammerspoon not running), or whenSPEAK_VOICE/SPEAK_RATEis set — the server has no per-request voice or rate, so honouring an explicit override means bypassing it. This path blocks until the speech finishes.
Requests are queued, so two dictations fired back to back play one after the other rather than on top of each other.
Mac setup (./install.sh --profile=desktop does the first two):
- Installs the
hammerspooncask and creates~/.hammerspoon. - Stows the
hammerspoonpackage (init.lua+speak-server.lua). - Launch it once —
open -a Hammerspoon— and allow it to start at login. Afterwardsopen -g hammerspoon://reloadreloads the config from a terminal.
Voice. Neither path passes a -v flag, so say uses whatever is set as the
System Voice (System Settings → Accessibility → Spoken Content). Choose the
voice there and both the local and the remote path follow it — nothing to
configure in this repo.
That default is deliberate. The Siri voices are the best ones macOS offers and
the only ones say cannot select by name — they never appear in say -v '?'
— so passing any -v would silently downgrade a Siri voice to a stock one. To
pin a specific named voice instead, set SPEAK_VOICE (local path) or the
VOICE constant in speak-server.lua (remote path). SPEAK_RATE sets words
per minute either way.
Remote setup (VM, or any machine without say): put the Mac's Tailscale
MagicDNS name in the gitignored ~/.zsh-local.sh —
export SPEAK_HOST='my-mac.tailnet-name.ts.net' # tailscale status shows itSecurity. speak-server executes nothing: the request body is written to a
temp file and handed to /usr/bin/say as an argv array, never through a shell.
The exposure is "whoever can reach port 8722 can make the Mac talk", and
reachability is gated by Tailscale. If the tailnet ever holds nodes beyond the Mac
and the VM, add a Tailscale ACL restricting port 8722 to those two machines.
Stow conflicts with existing config:
mv ~/.zshrc ~/.zshrc.bak
stow zshReset broken symlinks:
stow -R zshPersonal dotfiles — feel free to use as reference or inspiration.