This page is the front door to my dotfiles. It is written for one reader first: me, the next time I am sitting in front of a machine that does not know me yet. If you are someone else, welcome — take whatever is useful.
Everything I care about in a terminal is in that repo: zsh with Powerlevel10k, tmux on a Ctrl+a prefix, Neovim through LazyVim, Ghostty in Catppuccin Mocha, lazygit, a git config that signs every commit, and the settings for the AI agents I work with every day, including a Claude Code status line that gets the bill right. None of it is copied into place. It is linked, so the repo stays the only copy, and a change on one machine is a git pull away from every other.
The one-liner
curl -fsSL https://install.kristoffer.dev/dotfiles | bashIt fetches the repo’s own install.sh. Piped, that script clones the repo to ~/dotfiles and runs the copy on disk, which:
- Installs what the configs need. On macOS: Homebrew if it is missing, then Stow, jq, lazygit, tmux and Neovim. On Linux: git, Stow, zsh, tmux and jq, with apt, dnf or pacman.
- Installs Oh My Zsh and Powerlevel10k when they are not there yet, without letting Oh My Zsh overwrite the
.zshrcit is about to be linked to. - Links
.configinto~/.configand.claudeinto~/.claudewith Stow. A fresh OS often writes a default config on first launch, and Stow refuses to link over a real file, so anything in the way is moved to~/.dotfiles-backup/<timestamp>/first. Nothing is deleted. - Adds one line to
~/.zshenv, pointing zsh at its config (more on that below). - Checks for the commit signing key, and says how to make one if it is missing, because git is set to sign every commit.
Run it twice and nothing breaks: the second run pulls, finds everything installed and linked, and changes nothing.
Options
Flags go after bash -s --:
curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --dry-run| Flag | Does |
|---|---|
--dry-run | Prints what would be installed, linked and moved aside, and changes nothing. It clones to a temporary folder to look |
--link-only | Skips every install and only links. Needs git, Stow and zsh already there |
DOTFILES_DEST=/somewhere/else clones to another folder. From a checkout, it is just ./install.sh.
Not installed for you: Ghostty (brew install --cask ghostty), and Neovim on Linux, where the distro package is usually older than LazyVim supports.
Read it before you pipe it
Piping a URL into a shell is trusting whoever controls the URL. It is my domain, and I still would not do it blind on someone else’s. The script is on GitHub, under four hundred lines of commented bash, next to the configs it links, and CI installs it into a fresh home on macOS and Ubuntu on every change. Everything runs inside a main function called on the last line, so a download cut off halfway runs nothing.
install.kristoffer.dev/dotfiles redirects to the raw file. The address this page used to give, kristoffer.dev/dotfiles/install.sh, still works: it is a few lines that fetch the same script and pass your flags on.
A new machine, start to finish
This is the checklist. It is the reason this page exists.
macOS
- Run the one-liner. It installs Homebrew first if it has to, which asks for your password once and brings the Command Line Tools, and with them git.
brew install --cask ghostty, and use Ghostty instead of Terminal.app from now on. It ships with a Nerd Font built in, which is what makes the Powerlevel10k prompt render properly.- Open a new Ghostty window. The prompt is already configured:
.p10k.zshis in the repo, so there is no wizard to answer. - Set up an SSH key and add it to GitHub twice, once to sign in and once to sign commits (see Signed commits). Until then,
git commitfails. gh auth login.nvim. The first launch installs every plugin. Give it a minute and do not panic at the wall of text.
Ubuntu, Debian and WSL
sudo apt install -y curl- Run the one-liner. It installs git, Stow, zsh, tmux and jq with apt, and asks for
sudoto do it. - Neovim from somewhere newer than apt. apt’s Neovim lags years behind what LazyVim needs, so the installer leaves it out. Homebrew for Linux or the Neovim release tarball both work, and so does lazygit from either.
chsh -s "$(command -v zsh)", then log out and in again.- Same as macOS from step 4: SSH keys,
gh, Neovim’s first launch.
Ghostty on Linux is a separate install; the terminal config is linked either way and waits for it.
Machine-specific settings
Anything that belongs to one machine — a work proxy, a token, a PATH entry for something only installed here — goes in ~/.config/zsh/.zshrc.local. The .zshrc sources it if it exists, and git ignores it, so it never leaves the machine.
git has the same escape hatch: ~/.config/git/config.local is read last, for a work email or a different signing key on one machine.
What’s inside
Each folder in the repo becomes one symlink:
| Package | Linked to | What it brings |
|---|---|---|
zsh | ~/.config/zsh | Oh My Zsh + Powerlevel10k, aliases, tmux helpers, CLI completions |
tmux | ~/.config/tmux | Ctrl+a prefix, vim-style panes, mouse on, OSC 52 clipboard |
nvim | ~/.config/nvim | LazyVim with .NET, Go, Vue, Tailwind, DAP and Claude Code extras |
ghostty | ~/.config/ghostty | Catppuccin Mocha, OSC 52 clipboard, no stray mouse input after sleep |
git | ~/.config/git | Global git config; every commit and tag signed with an SSH key |
lazygit | ~/.config/lazygit | Nerd Font v3 icons |
opencode | ~/.config/opencode | Nine agents (companion, reviewer, architect …) and skills |
claude | ~/.claude | Claude Code settings and a status line in bash + jq that prices tokens correctly |
The trick that makes zsh fit this layout is one line the installer adds to ~/.zshenv:
export ZDOTDIR="$HOME/.config/zsh"zsh reads ~/.zshenv before anything else, and ZDOTDIR tells it to look for the rest of its config in ~/.config/zsh/ — which is a link into the repo. The home directory stays clean: one small file, not a .zshrc, .zprofile and .p10k.zsh side by side.
If symlinks are new to you, I wrote about them in Link up your life. Stow is just a tool that makes a whole tree of them in one command, and removes them again with -D.
Because ~/.config/zsh is a link into the repo, zsh writes its history and completion cache there too. They are gitignored and kept out of the links, so a shell history, which can hold a pasted token, never ends up in a commit.
Signed commits
~/.config/git/config signs every commit and tag with an SSH key, ~/.ssh/id_ed25519.pub. No GPG, no agent to babysit: the same kind of key you already use to push. On a new machine:
ssh-keygen -t ed25519 -C "$(git config user.email)" # skip if the key exists
gh auth refresh -h github.com -s admin:ssh_signing_key
gh ssh-key add ~/.ssh/id_ed25519.pub --type signing --title "$(hostname -s)"GitHub shows the commits as Verified once the key is added as a signing key. An authentication key is not enough, even when it is the very same key, which is the step everyone misses. For git log --show-signature to trust it locally too, add the key to ~/.config/git/allowed_signers.
A Claude Code status line that gets the bill right
~/.claude/statusline/ is the status line I run under Claude Code. Two lines, bash and jq, no Node, about 40 ms a refresh:
dotfiles main ●1 Opus 5.5 · xhigh ▰▰▱▱▱▱▱▱ 26% 256k/1M
session $6.84 · 112k out · +412/-87 · 1h30m │ today $7.52 · cache 98% (saved $63.15) │ 5h 42% ↻1h19m │ 7d 73% ↻3d11hThe first line is where I am: folder, branch and uncommitted files, model and effort, and how full the context window is. The second is what it costs: this session, everything since midnight (subagents included), how much of the input came from cache and what that saved, and how close the five-hour and weekly limits are.
I wrote it because the package I used before got the money wrong. It priced any model it did not know by family name, so claude-opus-5-5 matched plain “opus” and was billed at Opus 4’s $15/$75 per million tokens. One ordinary day showed $38.63. The real figure, checked against the transcripts by hand, was $7.13. Most of the gap was cache reads, charged at seven times their price.
This one takes every price from one file, pricing.json, matches a model by its longest known prefix, and prices five-minute and one-hour cache writes and fast mode separately. A model it has no price for shows ~ instead of a guess. bash ~/.claude/statusline/statusline.sh --report breaks the day down by model.
Daily use
Aliases
The ones I type without thinking:
| Alias | Runs | Why |
|---|---|---|
n | nvim . | Open the editor on the current directory |
ll | ls -al | Long listing, dotfiles included |
gss | git status -s | Short status, the one I type most |
gaa | git add . | Stage everything |
gcm | git commit -m | Commit with a message |
gpsh | git push | Push |
cc | claude | Claude Code |
oc | opencode | OpenCode |
dtable | docker ps --format table … | Containers as a readable table |
dstart | docker compose up -d && docker compose logs -f | Start the stack and tail it |
tmx | tmx <session> | Attach to a tmux session, or offer to create it |
tmux
The prefix is Ctrl+a because Ctrl+b is a reach. Splits are the characters that look like the split:
| Keys | Does |
|---|---|
| Ctrl+a | Prefix (instead of Ctrl+b) |
| Ctrl+a | | Split left/right |
| Ctrl+a - | Split top/bottom |
| Ctrl+a h/j/k/l | Move between panes |
| Ctrl+a H/J/K/L | Resize the pane (repeatable) |
| Ctrl+a [ | Copy mode, vi keys |
| Ctrl+a r | Reload the config |
Clipboard goes over OSC 52, so a yank in tmux or Neovim lands on the laptop’s clipboard even when the shell is on a server over SSH. Ghostty is set to allow it.
Changing things
Because everything is a link, there is no “apply” step. Edit ~/.config/tmux/tmux.conf and you have edited the file in the repo:
nvim ~/.config/zsh/.zshrc # = ~/dotfiles/.config/zsh/.zshrc
cd ~/dotfiles && gss # see what changed
gaa && gcm "zsh: add alias" && gpshOn every other machine, the one-liner again: it pulls and relinks.
To bring a new tool under the repo, move its config folder into ~/dotfiles/.config/ and run ~/dotfiles/install.sh. Stow links it back where it came from.
Try it in the browser
This site has a terminal — press ` anywhere, or the >_ button in the corner. It knows exactly one script, and it is this one.
The button at the top types the one-liner into it: curl -fsSL https://install.kristoffer.dev/dotfiles | bash. It runs the same steps as the real installer, with the same output, and then the terminal becomes mine. The colours turn to Catppuccin Mocha. The prompt turns into Powerlevel10k segments. The title bar says Ghostty. And the aliases work — or at least, do the most a browser tab can:
aliaslists them allll ~/.configshows the links Stow madecat ~/.config/zsh/.zshrcandcat ~/.config/git/configprint what is in themccdraws the Claude Code status linenopens Neovim, as far as the splash screentmuxandneofetchdo what you would expectdotfiles difflines up before and after
It stays installed as you move around the site, the way a real machine keeps it. dotfiles uninstall — or stow -D, if you prefer the real verb — puts the stock terminal back.
It is a toy, but an honest one. curl -fsSL https://install.kristoffer.dev/dotfiles without the pipe prints the actual script, fetched from the repo. The list of packages it links is read from the same data file as the tables on this page, so the demo cannot drift from the documentation.
Troubleshooting
A file I had before is gone. It is in ~/.dotfiles-backup/<timestamp>/, at the same path relative to your home directory. mv it back.
Stow could not link …. The installer moves real files out of the way, and prints Stow’s own message for anything else, such as a link into a different checkout. That one is yours to untangle.
error: Couldn't load public key ~/.ssh/id_ed25519.pub. git is set to sign, and the key does not exist yet. See Signed commits, or point user.signingKey at another key in ~/.config/git/config.local.
Commits are signed but GitHub says Unverified. The key is on GitHub as an authentication key only. Add it again with --type signing.
Homebrew installed, but it is not on PATH. Open a new terminal and run ~/dotfiles/install.sh again.
The prompt shows boxes and question marks. The terminal font has no Nerd Font glyphs. Ghostty does out of the box; anything else needs a Nerd Font set by hand.
Starting over. Unlink everything, and the machine is back to where it was — minus the clone:
cd ~/dotfiles
stow -D -t ~/.config .config
stow -D -t ~/.claude .claude
sed -i.bak '/ZDOTDIR/d' ~/.zshenv