XDG-compliant development environment for Linux/WSL. Managed with GNU Stow.
- What this repo does
- Quick start
- Repo layout
- Architecture
- Adding a new tool
- Conventions
- Troubleshooting
- Resources
Three things, each with one tool:
- Installs tools —
scripts/install/*.shclones or apt-installs each tool to a deterministic XDG path. - Symlinks configs — GNU Stow mirrors
stow/<package>/into$HOME. - Wires shell —
.zshrcsources each tool'spath.zshso the shell can find and initialize it.
The three concerns live in three separate places. None duplicate each other.
# 1. SSH key for GitHub
ssh-keygen -t ed25519 -C "you@example.com"
# Add ~/.ssh/id_ed25519.pub to GitHub
# 2. Clone
git clone git@github.com:MihaMlin/dotfiles.git ~/.dotfiles
cd ~/.dotfiles
# 3. Install
./install.shThe installer:
- Runs preflight checks (Linux, sudo available, git installed)
- Installs apt packages from
scripts/apt-packages.txt(includingstow) - Runs each tool installer in
scripts/install/ - Stows all packages from
stow/into$HOME - Sets zsh as the default shell
The installer is idempotent. Safe to re-run.
./install.sh # full
./install.sh --skip-apt # skip apt step (faster on re-runs)
./install.sh --only-symlinks # just re-stow (no sudo needed)For a single tool:
bash scripts/install/nvm.sh # re-install or update one tool~/.dotfiles/
├── install.sh # Main entry point
├── scripts/
│ ├── lib/ # Shared helpers (logging, git clone, preflight)
│ ├── install/ # One installer per tool
│ └── setup/
│ ├── symlinks.sh # Wraps `stow`
│ └── default-zsh.sh
└── stow/ # Everything that gets symlinked into $HOME
├── zsh/ # → ~/.zshrc + ~/.config/zsh/*
├── zinit/ # → ~/.config/zinit/path.zsh
├── nvm/ # → ~/.config/nvm/path.zsh
├── pyenv/ # → ~/.config/pyenv/path.zsh
├── fzf/ # → ~/.config/fzf/path.zsh
├── nvim/ # → ~/.config/nvim/
├── git/ # → ~/.config/git/
├── tmux/ # → ~/.config/tmux/
├── claude/ # → ~/.claude/
└── bin/ # → ~/.local/bin/
Each directory inside stow/ is a stow package. Stow mirrors the package's internal structure into $HOME, creating symlinks that point back to the repo.
This repo follows the XDG Base Directory Specification. Four environment variables decide where things live:
| Variable | Default | What goes here |
|---|---|---|
XDG_CONFIG_HOME |
~/.config |
Configuration (read by tools) |
XDG_DATA_HOME |
~/.local/share |
Persistent app data (plugins, version managers, databases) |
XDG_STATE_HOME |
~/.local/state |
Logs, history, runtime state |
XDG_CACHE_HOME |
~/.cache |
Disposable cached data |
These are exported at the top of .zshrc so every tool started from the shell inherits them.
Take zinit as a worked example. The same pattern applies to every shell-extension tool (nvm, pyenv, fzf).
scripts/install/zinit.sh clones zinit to $XDG_DATA_HOME/zinit/zinit.git/. The path is not hardcoded in the installer — it is sourced from the same path.zsh that the shell uses, so install location and runtime location can never disagree.
# scripts/install/zinit.sh (excerpt)
source "$DOTFILES_DIR/stow/zinit/.config/zinit/path.zsh"
git_install https://github.com/zdharma-continuum/zinit.git "$ZINIT_HOME"stow/zinit/.config/zinit/path.zsh declares the location and conditionally sources the runtime:
export ZINIT_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/zinit/zinit.git"
[[ -s "$ZINIT_HOME/zinit.zsh" ]] && source "$ZINIT_HOME/zinit.zsh"After stow zinit, this file is symlinked at ~/.config/zinit/path.zsh.
.zshrc sources every path.zsh with one glob:
for f in "$XDG_CONFIG_HOME"/*/path.zsh; do
[[ -r "$f" ]] && source "$f"
doneAdding a new shell-extension tool requires zero edits to .zshrc. Drop a new stow package, run stow, the glob picks it up.
Two categories. The distinction is whether the tool runs as a separate process (category 1) or extends the shell from inside (category 2).
These tools are normal binaries on $PATH and read $XDG_CONFIG_HOME/<name>/ automatically. Stow places the config there; the tool finds it. Done.
| Tool | Why no path.zsh |
|---|---|
nvim |
Apt-installed via PPA → /usr/bin/nvim (default $PATH). Reads ~/.config/nvim/init.lua. |
git |
System binary. Reads ~/.config/git/config. |
tmux |
System binary. Reads ~/.config/tmux/tmux.conf. |
bin |
Just user scripts symlinked to ~/.local/bin/. No tool, no config to load. |
These modify $PATH, define shell functions, or register hooks — work that must happen inside the running shell session. Their path.zsh exports the tool's location variable (so the tool knows where its data lives) and sources its runtime (so the shell gains the functions/bindings).
| Tool | Var | What path.zshdoes |
|---|---|---|
zinit |
ZINIT_HOME |
Sources zinit.zsh to register the plugin manager. |
nvm |
NVM_DIR |
Defines lazy wrappers for nvm/node/npm/npx. |
pyenv |
PYENV_ROOT |
Prepends bin/ and shims/ to $PATH; defines lazy wrappers for pyenv/python/pip. |
fzf |
— | Currently no path.zsh (fzf installer writes its own shell init via --xdg) |
path.zsh is the single source of truth for a tool's location. The shell sources it on startup; installers source it to learn where to put the tool. Seven rules:
- Use the tool's official env var name — whatever the tool itself reads.
ZINIT_HOMEfor zinit,NVM_DIRfor nvm,PYENV_ROOTfor pyenv. Don't invent names; if you setNVM_ROOT, the nvm runtime won't see it. - Always
export— installers and child processes need to inherit it. - Always include the XDG fallback —
${XDG_DATA_HOME:-$HOME/.local/share}/<tool>. Installers sourcepath.zshbefore.zshrchas a chance to exportXDG_*, so the file must stand on its own. - Source runtimes conditionally —
[[ -s "$X" ]] && source "$X". The file may be sourced before the tool is installed; never fail the shell. - No eager work at top level — no
$(...), noeval "$(... init -)". Anything that probes the filesystem or runs a binary belongs in a lazy-loader function. Eager work is the #1 cause of slow zsh startup. - Installers must source
path.zsh— never hardcode the path inscripts/install/<tool>.sh. Ifpath.zshand the installer disagree, install location and runtime location drift apart. - Bash-safe guard — exports first, then
[[ -n "${ZSH_VERSION:-}" ]] || return 0, then anything zsh-specific. Installers (bash) get the vars; the shell (zsh) gets the full runtime.
Every path.zsh is split into two regions, divided by a bash-safety guard:
# 1. VARS region — exports only. Bash-safe so installers can source the file.
export TOOL_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/<tool>"
[[ -d "$TOOL_HOME/bin" ]] && export PATH="$TOOL_HOME/bin:$PATH"
# 2. Bash-safety guard — installers stop here, zsh continues.
[[ -n "${ZSH_VERSION:-}" ]] || return 0
# 3. ZSH region — runtime sourcing, lazy-load wrappers, hooks.
[[ -s "$TOOL_HOME/init.zsh" ]] && source "$TOOL_HOME/init.zsh"The guard exists because path.zsh is sourced from two contexts: zsh shells (which need the runtime) and bash installers (which need only the path). A zsh-only runtime like zinit.zsh would crash a bash installer without the guard.
For tools that need eval $(... init -) or shell hooks:
# <Tool> — lazy-loaded
export TOOL_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/<tool>"
[[ -n "${ZSH_VERSION:-}" ]] || return 0
_load_tool() {
unset -f tool cmd1 cmd2
eval "$(tool init -)"
}
tool() { _load_tool; tool "$@"; }
cmd1() { _load_tool; cmd1 "$@"; }
cmd2() { _load_tool; cmd2 "$@"; }Wrappers replace themselves with the real tool on first invocation. Zero startup cost; one-time cost when first used.
The pattern, end to end:
1. Pick the install location. Use $XDG_DATA_HOME/<tool> for git-cloned tools.
2. Create stow/<tool>/.config/<tool>/path.zsh — follow the rules and template above.
3. Create scripts/install/<tool>.sh — source path.zsh to learn the install path:
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.dotfiles}"
source "$DOTFILES_DIR/scripts/lib/log.sh"
source "$DOTFILES_DIR/scripts/lib/git-clone.sh"
source "$DOTFILES_DIR/stow/<tool>/.config/<tool>/path.zsh"
git_install https://github.com/owner/<tool>.git "$TOOL_HOME"
success "<tool> installed at $TOOL_HOME"4. Register the installer in install.sh:
installers=(
# ...existing...
"scripts/install/<tool>.sh"
)5. Run:
./install.shThat's it. No edits to .zshrc — the glob in 00-tools.zsh picks up the new path.zsh automatically.
- Performance: lazy-loading for
nvmandpyenv;zinitruns plugins in turbo mode. Target startup: <300ms. - Local overrides: machine-specific config goes in
~/.localrc, auto-sourced by.zshrc. Not tracked in git. - Backups: stow refuses to overwrite real files. On first migration, move existing configs out of
$HOME(or usestow --adopt, then verifygit diffbefore committing).
stow reports conflicts on first run.
You have real files in $HOME where stow wants to place symlinks. Either move them aside (mv ~/.zshrc ~/.zshrc.bak) or use stow --adopt to pull them into the repo (then check git diff to confirm content is what you expect).
A tool isn't found after install.
Check the three layers in order: (1) does the install path exist? ls $XDG_DATA_HOME/<tool>; (2) is the symlink correct? ls -la ~/.config/<tool>/path.zsh; (3) did .zshrc source it? Open a new shell and run echo $TOOL_HOME.
Shell startup is slow.
Run zsh -xv 2>&1 | head -100 to see what's loading early. Common cause: a path.zsh doing eager work that should be lazy.
- GNU Stow manual
- XDG Base Directory Specification
- ArchWiki: XDG Base Directory — list of which tools respect XDG and how to force the rest