Personal development environment provisioned with chezmoi and mise.
One command on a fresh machine sets up shell, editor, CLI tools and config — on macOS, Linux, WSL2, or native Windows. Idempotent and safe to re-run.
First-time setup on macOS / Linux / WSL2 — the POSIX bootstrap assumes nothing beyond a POSIX sh and network access:
sh -c "$(curl -fsSL https://raw.githubusercontent.com/<you>/dotfiles/main/bootstrap.sh)"Or, if you've already cloned the repo:
git clone https://github.com/<you>/dotfiles.git ~/.dotfiles
sh ~/.dotfiles/bootstrap.shbootstrap.sh installs the system prereqs (curl, git, ca-certs), drops chezmoi and mise into ~/.local/bin, writes the chezmoi config, and runs chezmoi apply. Everything else — the full system package list, mise install, oh-my-zsh + powerlevel10k, NvChad starter, git hooks, chsh -s zsh — is handled by the chezmoi run_once_* scripts.
First-time setup on native Windows — run PowerShell as your normal user:
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
git clone https://github.com/<you>/dotfiles.git $HOME\.dotfiles
pwsh -NoProfile -ExecutionPolicy Bypass -File $HOME\.dotfiles\bootstrap.ps1bootstrap.ps1 uses winget for native prerequisites (Git.Git, twpayne.chezmoi, jdx.mise), writes the same chezmoi config under ~/.config/chezmoi/chezmoi.toml, and runs chezmoi apply. On Windows, chezmoi deploys a guarded PowerShell profile and PowerShell-native run scripts; the Unix zsh and POSIX run scripts are ignored by home/.chezmoiignore.
After initial setup, use the CLI wrapper:
dotfiles install # Apply chezmoi + install every mise tool
dotfiles update # Update chezmoi, mise tools, omz, p10k, nvim plugins
dotfiles link # Re-apply chezmoi (re-create managed files)
dotfiles check # Dry-run — preview what chezmoi would change
dotfiles doctor # Verify all tools are present
dotfiles test # Run smoke, Bats, and optional static checks
dotfiles edit # Open the repo in $EDITORAdd
~/.dotfiles/binto yourPATHto usedotfilesdirectly.
On Windows, use the native wrapper:
.\bin\dotfiles.ps1 install
.\bin\dotfiles.ps1 doctor
.\bin\dotfiles.ps1 test| Layer | Managed by | Contents |
|---|---|---|
| System prereqs | Distro PM (apt / pacman / dnf / brew) | zsh, git, git-lfs, jq, gnupg, pinentry, openssh, ssh-askpass, curl, wget, build tools |
| Dev tools | mise | bat, eza, lazygit, glow, ripgrep, node, bun, gh, glab, codex, direnv, bats, ShellCheck, shfmt |
| Editor binary | Upstream pre-built tarball | Neovim — pinned in home/run_onchange_after_15-neovim.sh.tmpl, extracted to /opt/nvim-<os>-<arch>, symlinked to /usr/local/bin/nvim so sudoedit / root / cron all see it |
| Shell theming | run_once scripts | oh-my-zsh, powerlevel10k |
| Shell autocomplete | system PM + run_onchange | bash-completion, zsh-syntax-highlighting, zsh-autosuggestions from the distro PM; mise-tool completions generated into ~/.local/share/zsh/completions/ (spec 020) |
| Editor config | run_once scripts | NvChad starter |
| Dotfiles | chezmoi | zshrc, zprofile, gitconfig, tmux.conf, Claude statusline, ... |
On native Windows, winget covers only native prerequisites and helpers such as Git, chezmoi, mise, PowerShell, Git LFS, GnuPG, age, jq, and oh-my-posh. The shared developer CLI set remains mise-managed through home/dot_config/mise/config.toml.
| Platform | Status |
|---|---|
| macOS (Apple Silicon / Intel) | Supported |
| Ubuntu / Debian / Pop!_OS / Mint | Supported |
| Arch Linux / Manjaro / EndeavourOS | Supported |
| Fedora | Supported |
| WSL2 | Supported (auto-detected via uname + /etc/os-release) |
| Windows 10/11 + PowerShell | Supported |
Adding a distro is just one new else if in home/run_once_before_10-system-packages.sh.tmpl.
~/.dotfiles/
├── .chezmoiroot # contains "home" — chezmoi source root
├── bootstrap.sh # POSIX sh bootstrap (zero-dependency)
├── bootstrap.ps1 # native Windows bootstrap (winget + chezmoi + mise)
├── bin/dotfiles # POSIX sh CLI wrapper
├── bin/dotfiles.ps1 # native Windows PowerShell CLI wrapper
├── tests/test_smoke.sh # POSIX sh structural smoke tests
├── tests/windows_smoke.ps1 # Windows PowerShell structural smoke tests
│
└── home/ # chezmoi source — mirrors $HOME
├── .chezmoiignore
│
├── dot_zshrc # → ~/.zshrc
├── dot_zprofile # → ~/.zprofile
├── dot_gitconfig # → ~/.gitconfig
├── dot_gitignore # → ~/.gitignore
├── dot_tmux.conf # → ~/.tmux.conf
│
├── dot_claude/
│ ├── executable_statusline-command.sh # → ~/.claude/statusline-command.sh
│ └── hooks/
│ └── executable_sensitive-file-guard.sh # → ~/.claude/hooks/sensitive-file-guard.sh
│
├── dot_codex/
│ └── hooks/
│ └── executable_sensitive-file-guard.sh # → ~/.codex/hooks/sensitive-file-guard.sh
│
├── dot_config/
│ ├── mise/config.toml # → ~/.config/mise/config.toml
│ └── dotfiles/
│ ├── powershell/profile.ps1 # → ~/.config/dotfiles/powershell/profile.ps1
│ ├── bin/
│ │ └── executable_pinentry-auto # GUI pinentry with TTY fallback
│ ├── modules/ # shell modules sourced by zshrc
│ │ ├── alias.zsh
│ │ ├── auth-unlock.zsh
│ │ ├── functions.zsh
│ │ ├── fzf.zsh
│ │ ├── pkg-quarantine.zsh
│ │ └── ssh-agent.zsh
│ └── hooks/
│ └── executable_pre-commit # plaintext-secret / token scan
│
├── private_dot_gnupg/
│ └── gpg-agent.conf.tmpl # → ~/.gnupg/gpg-agent.conf
│
├── Documents/PowerShell/
│ └── Microsoft.PowerShell_profile.ps1 # → ~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1
│
├── run_once_before_05-windows-packages.ps1.tmpl # winget native prereqs
├── run_once_before_10-system-packages.sh.tmpl # apt/pacman/dnf/brew
├── run_onchange_after_10-mise-install.sh.tmpl # mise install (hash-gated)
├── run_onchange_after_11-mise-windows.ps1.tmpl # Windows mise install (hash-gated)
├── run_once_after_20-ohmyzsh.sh.tmpl # omz + powerlevel10k
├── run_once_after_30-nvchad.sh.tmpl # NvChad starter + Lazy sync
├── run_onchange_after_40-git-hooks.sh.tmpl # install repo pre-commit
├── run_onchange_after_41-ssh-config-auth.sh.tmpl # append SSH auth defaults
├── run_onchange_after_42-gpg-agent-auth.sh.tmpl # restart gpg-agent on config change
├── run_once_after_50-default-shell.sh.tmpl # chsh -s zsh
├── run_onchange_after_60-claude-statusline.sh.tmpl # wire Claude statusline
├── run_onchange_after_61-claude-env.sh.tmpl # wire Claude-scoped env
└── run_onchange_after_62-claude-security.sh.tmpl # wire Claude file guard
Files under home/ follow chezmoi's naming conventions:
| Prefix | Meaning |
|---|---|
dot_ |
Leading . on the destination (e.g. dot_zshrc → ~/.zshrc) |
executable_ |
Mark the destination mode as +x |
private_ |
Mark the destination mode as 0600 |
run_once_before_*.sh.tmpl |
POSIX sh script, runs once, before apply |
run_once_after_*.sh.tmpl |
POSIX sh script, runs once, after apply |
run_onchange_*.sh.tmpl |
Re-runs when the rendered script content changes |
.chezmoiroot at the repo root points chezmoi at home/ as its source directory, keeping the top level reserved for bootstrap.sh, bin/, tests/, and docs.
The shell ships with two environment variables:
| Variable | Points at | Purpose |
|---|---|---|
$DOTFILES_REPO |
$HOME/.dotfiles |
Git checkout — used by bin/dotfiles, git hooks, tooling |
$DOTFILES |
$HOME/.config/dotfiles |
chezmoi-deployed runtime tree — shell modules sourced by zshrc/zprofile |
Splitting the two keeps source and runtime separate: zshrc sources $DOTFILES/modules/*.zsh, which are the materialised files chezmoi drops into the runtime tree. Editing the source file in home/dot_config/dotfiles/modules/ and re-running dotfiles link re-deploys it.
Interactive shells source auth-unlock.zsh before the powerlevel10k instant
prompt. It prepares agent-backed passphrase prompts without prompting during
shell startup:
- GPG gets
GPG_TTY=$(tty)andgpg-connect-agent updatestartuptty /bye, so terminal fallback pinentry follows the current terminal or tmux pane. ~/.gnupg/gpg-agent.confpoints at the managedpinentry-autowrapper.pinentry-autoprefers GUI pinentry programs whenDISPLAYorWAYLAND_DISPLAYexists, which covers WSLg on WSL2, and falls back topinentry-curses/pinentry-ttywhen no GUI session is available.- GPG cache is bounded to a one hour default TTL and four hour max TTL.
When that managed config changes, a
run_onchangescript kills the currentgpg-agent; the next GPG operation starts it with the new pinentry and TTL settings. - SSH gets
SSH_ASKPASSplusSSH_ASKPASS_REQUIRE=preferwhen a GUI session and askpass helper are present, without replacing an explicitly configuredSSH_ASKPASS. - The same helper is exported as
SUDO_ASKPASSwhen unset, so commands that deliberately usesudo -Acan use the GUI prompt and sudo's timestamp cache. ssh-agentstarts with a four hour default identity lifetime. A non-destructiverun_onchangescript appends a managed trailingHost *block to~/.ssh/configwithAddKeysToAgent yes, preserving existing host-specific identities while letting encrypted keys unlocked bysshenter the cache when not otherwise configured.
This covers the common AI-agent interruption case: a long terminal task reaches
git commit -S, ssh, or git push, a GUI unlock prompt appears for the
human, and successful unlocks are reused for the bounded cache window.
home/dot_config/mise/config.toml is the single source of truth for dev tools:
[tools]
bat = "latest"
"aqua:eza-community/eza" = "latest"
lazygit = "latest"
glow = "latest"
ripgrep = "latest"
node = "lts"
bun = "latest"
gh = "latest"
glab = "latest"
codex = "latest"
direnv = "latest"
bats = "latest"
shellcheck = "latest"
shfmt = "latest"
direnvis wired into zsh via a hook inhome/dot_zshrcthat runs aftermise activate, and a globalhome/dot_config/direnv/direnv.tomlsets[global] load_dotenv = trueso bare.envfiles activate alongside.envrc.direnv allowis required per directory on first entry — that is direnv's security model, not a bug.
Neovim is not managed by mise — it ships from the upstream pre-built tarball via
home/run_onchange_after_15-neovim.sh.tmplso the binary lives on/usr/local/bin/nvimand is reachable fromsudoedit, root, and cron without any shell activation.
Add or remove a tool, run dotfiles install, and mise picks up the change. The run_onchange_after_10-mise-install.sh.tmpl script has the config's SHA256 hash embedded in a comment, so chezmoi re-runs mise install automatically when the manifest is edited.
Two complementary mechanisms, both driven by chezmoi itself — there is no bespoke loader.
A. Encrypted files with age. Real secret files ship as ciphertext in this very repo under an encrypted_ prefix and are transparently decrypted on chezmoi apply. One-time setup on each machine:
dotfiles secrets-initThe subcommand is idempotent. It:
- Verifies
age-keygenis onPATH(installed via the system package step ofrun_once_before_10-system-packages.sh.tmpl). - Generates
~/.config/chezmoi/key.txtif missing (never overwrites). - Appends the
encryption = "age"+[age]block to~/.config/chezmoi/chezmoi.tomlif not already present.
After the first run, adding a new encrypted file is a single chezmoi command:
chezmoi add --encrypt ~/.config/dotfiles/credentials.env
# Stores home/encrypted_private_dot_config/dotfiles/credentials.env (ciphertext)
chezmoi edit ~/.config/dotfiles/credentials.env # opens decrypted in $EDITORBack up ~/.config/chezmoi/key.txt to your password manager — without it, encrypted files in the repo cannot be decrypted on a new machine.
B. Runtime pulls from Bitwarden. For items already stored in a password manager, create a chezmoi template instead of committing ciphertext. Example (home/private_dot_config/dotfiles/credentials.env.tmpl):
# chezmoi renders this on apply; requires `bw` CLI logged in and unlocked.
OPENAI_API_KEY={{ (bitwardenFields "item" "My OpenAI Key").api_key.value }}
GITHUB_TOKEN={{ (bitwardenFields "item" "gh pat dotfiles").token.value }}
The repo holds only the template — no ciphertext, no cleartext. Every chezmoi apply refreshes the rendered file by pulling live from Bitwarden. Install the bw CLI (npm install -g @bitwarden/cli or system package) and run bw login && bw unlock before applying. Chezmoi also supports onepassword, pass, keepassxc, keyring, and more — see chezmoi password managers.
Pre-commit guard. home/dot_config/dotfiles/hooks/executable_pre-commit (installed as the repo's own .git/hooks/pre-commit by run_onchange_after_40-git-hooks.sh.tmpl) blocks any plaintext *.env file that is neither encrypted_ prefixed nor a .env.tmpl template, rejects staged age private keys (key.txt, *.age), and scans the staged diff for common provider token patterns (GitLab, GitHub PAT/OAuth/App, OpenAI, AWS, Slack).
Claude Code runtime guard. home/run_onchange_after_62-claude-security.sh.tmpl merges a fail-closed runtime policy into ~/.claude/settings.json: permissions.deny hides ~/.ssh/** and env-like files, permissions.disableBypassPermissionsMode = "disable" blocks dangerous permission bypass mode, and sandbox.enabled + sandbox.failIfUnavailable prevent Bash from silently running without filesystem isolation. The managed ~/.claude/hooks/sensitive-file-guard.sh also runs on UserPromptSubmit and PreToolUse, blocking direct references to ~/.ssh/, .env, .env.*, .envrc, and *.env* without echoing the sensitive path in the block reason.
Codex runtime guard. home/run_onchange_after_63-codex-security.sh.tmpl enables Codex hooks in ~/.codex/config.toml, writes a dotfiles-sensitive filesystem permission profile that allows SSH config files and exact existing ~/.ssh/*.pub public-key files while blocking unknown SSH filenames and env-like files, and merges ~/.codex/hooks.json entries for the managed ~/.codex/hooks/sensitive-file-guard.sh. The hook blocks direct prompt and tool references to env-like files and non-allowlisted ~/.ssh/* targets without echoing the sensitive path.
Most things are fully automated. These require one-time manual action:
| Task | Command | Why manual |
|---|---|---|
| Configure powerlevel10k | p10k configure |
Interactive TUI wizard |
| Approve direnv in a project | direnv allow |
Required once per directory on first entry (security model) |
| Initialise age encryption | dotfiles secrets-init |
Generates ~/.config/chezmoi/key.txt and wires chezmoi.toml |
| Add an encrypted secret file | chezmoi add --encrypt ~/.config/dotfiles/credentials.env |
Per-user content |
Log into Bitwarden (for bw-driven templates) |
bw login && bw unlock |
Requires interactive master password |
| Import GPG keys | gpg --import <keyfile> |
Personal key material |
| Generate SSH keys | ssh-keygen -t ed25519 |
Personal key material |
| Fork NvChad config | Clone, customise, point run_once_after_30-nvchad.sh.tmpl at your fork |
Personal editor preferences |
dotfiles updateRuns in order:
chezmoi update— pulls the repo and re-applies any changed source filesmise upgrade— bumps every managed tool to its latest versionoh-my-zshupgrade.sh— pulls oh-my-zshgit -C powerlevel10k pull— bumps p10knvim --headless "+Lazy! update" +qa— refreshes neovim plugins
# Full local test entrypoint. Runs smoke first, then Bats when installed,
# then optional parse/static gates when their tools are on PATH.
dotfiles test
# Structural smoke tests (POSIX sh, no Bats dependency; chezmoi is optional)
sh tests/test_smoke.sh
# Behaviour tests for shell commands and hooks
bats tests/bats
# Dry-run on your machine — shows what chezmoi would change, changes nothing
dotfiles check
# or:
chezmoi apply --dry-run --verbose# Native Windows test entrypoint. Runs Windows smoke first, then POSIX/Bats
# only when those commands exist on PATH.
.\bin\dotfiles.ps1 test
# Windows structural smoke tests; no winget, chezmoi, mise, or network needed.
pwsh -NoProfile -File tests\windows_smoke.ps1tests/test_smoke.sh verifies: the source tree layout, POSIX-sh parseability of bootstrap.sh and bin/dotfiles, bashism absence, Ansible absence, and (if chezmoi is on PATH) that every run_*.sh.tmpl renders without Go-template errors.
tests/windows_smoke.ps1 verifies the native Windows structure, parses the
PowerShell bootstrap/profile/run scripts, and checks that the Windows platform
split is represented in home/.chezmoiignore.
Bats is the primary behaviour/integration test runner for shell scripts in
this repo. It is intentionally paired with explicit portability gates because
Bats tests run under Bash: keep using sh -n, rendered-template parse checks,
zsh -n, ShellCheck, and shfmt to catch portability and syntax issues outside
the Bats execution model.
- Edit
home/dot_config/mise/config.toml, add the tool line. dotfiles install— the hash-gatedrun_onchange_after_10-mise-install.sh.tmplre-runs andmise installpicks up the new tool.- Optional: add the binary to the
doctortool list inbin/dotfiles.
- Drop the file under
home/using chezmoi naming (dot_prefix, etc). dotfiles link— chezmoi deploys it to$HOME.
Personal configuration. Use as inspiration for your own dotfiles.