XDG and POSIX Compliance¶
The XDG Base Directory Specification defines a set of well-known
environment variables that applications should use to locate
user-specific files, rather than scattering them across $HOME. The
practical outcome is a $HOME directory with a handful of dotfiles
instead of dozens, and a predictable directory hierarchy that backup
tools, sync clients, and dotfiles managers can reason about.
mise is XDG-native by default — unlike many CLI tools, it does not
require overrides to respect XDG_CONFIG_HOME, XDG_DATA_HOME,
XDG_CACHE_HOME, and XDG_STATE_HOME. This eliminates a class of
toolchain-specific boilerplate that was previously necessary for proto
(PROTO_HOME), volta (VOLTA_HOME), and similar managers.
XDG variable definitions¶
| Variable | Default | Holds |
|---|---|---|
XDG_CONFIG_HOME |
~/.config |
User-specific config files — read/write by user, not programs |
XDG_DATA_HOME |
~/.local/share |
User-specific data files — application-generated, persistent |
XDG_STATE_HOME |
~/.local/state |
Persistent but non-critical state: history, logs, layout |
XDG_CACHE_HOME |
~/.cache |
Non-essential cached data — safe to delete at any time |
XDG_RUNTIME_DIR |
Set by PAM/systemd | Runtime files: sockets, fifos, named pipes — 0700, cleared on logout |
Zsh does not read XDG_CONFIG_HOME by default — it always looks in
$HOME for .zshenv, .zprofile, .zshrc, .zlogin. The bridge is
ZDOTDIR: when set, zsh looks for its dotfiles in that directory
instead of $HOME. The challenge is that ZDOTDIR must be set
before zsh reads any user dotfile — which means it must be set in a
file that zsh reads unconditionally: /etc/zshenv or ~/.zshenv
(still in $HOME, read before ZDOTDIR takes effect). The
~/.zshenv bootstrap solves this.
The bootstrap file (~/.zshenv)¶
This is the only file that lives in $HOME. Its sole responsibility
is to set ZDOTDIR and then immediately hand off to the XDG-located
.zshenv. It contains nothing else — no PATH manipulation, no
aliases, no conditionals beyond the ZDOTDIR redirect. Keep this
file under ten lines.
# ~/.zshenv
# ─────────────────────────────────────────────────────────────────────
# Bootstrap only. Redirects zsh to the XDG config directory.
# All further configuration lives in $ZDOTDIR.
# ─────────────────────────────────────────────────────────────────────
# Establish XDG base directories with POSIX-compliant defaults.
# Use := assignment: set only if unset or empty (idempotent).
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:=$HOME/.config}"
export XDG_DATA_HOME="${XDG_DATA_HOME:=$HOME/.local/share}"
export XDG_STATE_HOME="${XDG_STATE_HOME:=$HOME/.local/state}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:=$HOME/.cache}"
# Point zsh at its XDG config directory.
# Subsequent files (.zshrc, .zprofile, etc.) are resolved from here.
export ZDOTDIR="${XDG_CONFIG_HOME}/zsh"
System-level alternative
On systems where you have root access (e.g., a personal workstation
or managed team machine), you can set ZDOTDIR in /etc/zsh/zshenv
instead, which eliminates the need for a ~/.zshenv bootstrap file
entirely. The per-user bootstrap approach shown here works without
root and is suitable for both personal machines and shared/managed
environments.
Directory structure¶
The full XDG-compliant zsh config tree:
~/.zshenv # Bootstrap only — sets ZDOTDIR
~/.config/zsh/ # $ZDOTDIR — all zsh config lives here
├── .zshenv # Env vars, XDG tool redirects, PATH seeds
├── .zprofile # Login-shell PATH finalization
├── .zshrc # Interactive shell settings, sources conf.d/
├── .zlogout # Cleanup on login shell exit
└── conf.d/ # Modular fragments, loaded in sorted order
├── 10-path.zsh
├── 20-completion.zsh
├── 25-tool-cache.zsh
├── 30-history.zsh
├── 40-options.zsh
├── 50-keybinds.zsh
├── 60-aliases.zsh
├── 61-git-extensions.zsh
├── 62-ruby-aliases.zsh
├── 63-python-aliases.zsh
├── 64-js-aliases.zsh
├── 66-data-functions.zsh
├── 67-devloop.zsh
├── 68-diagnostics.zsh
├── 70-tools.zsh # mise, direnv, rv, ssh-agent (Tier 1)
└── 80-functions.zsh
~/.config/mise/ # mise global config
└── config.toml # User-scope tool/env/task defaults
~/.local/share/mise/ # mise state — installs, shims, plugins
├── shims/ # Shims used by non-interactive subprocesses
└── installs/ # Installed tool versions
~/.local/state/zsh/ # Runtime state — NOT in config directory
└── history
~/.cache/zsh/ # Caches — safe to delete
└── zcompdump
Why number the conf.d fragments?
Numeric prefixes enforce deterministic load order without relying on
filesystem inode ordering (which varies by OS and filesystem). The
gaps (10, 20, 30...) leave room to insert new fragments without
renaming existing ones. Use two-digit prefixes — the sort order of
10 vs. 9 is alphabetic, so 09 sorts before 10 correctly, but
single digits do not.
Directories that escape XDG¶
XDG compliance is not total, and pretending otherwise sets a false
expectation. Two different things put dot-directories back in $HOME,
and they have different fixes.
Tools the framework relocates — but only once its environment is
loaded. These honor an environment variable, and the framework sets
it. If the tool runs before that variable is exported — in a bash
session, or in a shell that has not yet sourced the zsh chain or
~/.profile — the tool falls back to its default $HOME location and
the directory leaks:
Directory in $HOME |
Relocated by | To |
|---|---|---|
~/.cargo |
CARGO_HOME |
$XDG_DATA_HOME/cargo |
~/.rustup |
RUSTUP_HOME |
$XDG_DATA_HOME/rustup |
~/.gnupg |
GNUPGHOME |
$XDG_DATA_HOME/gnupg |
~/go |
GOPATH |
$XDG_DATA_HOME/go |
~/.viminfo |
set viminfofile in vimrc |
$XDG_STATE_HOME/vim/viminfo |
Seeing one of these in $HOME means the tool ran with a stale
environment, not that relocation failed. The fix is ordering: ensure
the framework environment is active before installing or first running
the tool (see the onboarding runbook). If
the directory already leaked, it is safe to remove the empty stub once
the correctly-located one exists — but inspect first, because a tool
that already wrote real data (gpg keys, cargo credentials) to the
$HOME path will not find it at the XDG path.
Tools that ignore XDG entirely — the accepted-holdout list. These
hardcode a $HOME path and honor no usable environment variable, so
the framework cannot redirect them. This is the canonical list of
entries the framework considers expected in $HOME. Anything here is
known cruft to be left alone; anything in $HOME that is not here
(and not a framework symlink) is worth investigating as rogue.
Entry in $HOME |
Created by | Why it cannot move |
|---|---|---|
~/.mozilla |
Firefox | Profile root is hardcoded; no XDG support upstream |
~/.pki |
NSS / Chromium / some TLS clients | NSS database path is hardcoded |
~/.claude.json |
Claude Code | Top-level config file path is fixed to $HOME; no override variable (upstream feature request open) |
For ~/.mozilla and ~/.pki there is no framework lever at all; they
are the cost of running a browser. Do not spend effort trying to
relocate these.
~/.claude.json deserves a precise note, because it is half-movable
and easy to get wrong. The Claude Code config directory —
~/.claude/ (settings, skills, agents, memory) — does honor
CLAUDE_CONFIG_DIR, and the framework works with that directory
directly. The top-level ~/.claude.json file is the part that
stays: as of this writing it is written to $HOME with no documented
way to relocate it. So the directory can move and the file cannot —
treat the file as a holdout and leave it.
This table is documentation, not enforcement. There is deliberately no script that deletes or relocates these — the framework states what is expected and stops there. If a future home-directory audit is added, it should read this list as its allowlist rather than hard-coding the entries a second time; keep this table the single source of truth.
Vim is a deliberate placement, not a holdout¶
Vim is the opposite case, and worth calling out because it is easy to
miscategorize. Vim is fully redirectable. VIMINIT controls the config
entry point, and from there 'runtimepath', 'undodir',
'directory', 'backupdir', and viminfofile move every piece of
state Vim writes. Vim 9.1+ goes further and reads
$XDG_CONFIG_HOME/vim/vimrc natively when no ~/.vimrc or ~/.vim/
exists. Nothing about Vim forces anything into $HOME.
The framework already uses this. The shipped vimrc redirects undo,
swap, backup, and viminfo into $XDG_STATE_HOME/vim/ (see
editors), so none of Vim's runtime state lands
in your home directory. What remains is ~/.vim/ as the config
home — and that is a deliberate choice: bootstrap.sh symlinks the
config to ~/.vim/vimrc because that path works identically on every
Vim version the framework targets, whereas the native
$XDG_CONFIG_HOME/vim/ path requires Vim 9.1+. It is a compatibility
decision, not a limitation. A user who only runs Vim 9.1+ can move the
config under $XDG_CONFIG_HOME/vim/ and drop ~/.vim entirely.
Acknowledging the genuine holdouts (~/.mozilla, ~/.pki,
~/.claude.json) honestly is
better than a config that claims a clean $HOME it cannot deliver —
but do not pad that list with tools like Vim that are configurable and
that the framework has, in fact, already configured.