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

bash
curl -fsSL https://install.kristoffer.dev/dotfiles | bash

It fetches the repo’s own install.sh. Piped, that script clones the repo to ~/dotfiles and runs the copy on disk, which:

  1. 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.
  2. Installs Oh My Zsh and Powerlevel10k when they are not there yet, without letting Oh My Zsh overwrite the .zshrc it is about to be linked to.
  3. Links .config into ~/.config and .claude into ~/.claude with 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.
  4. Adds one line to ~/.zshenv, pointing zsh at its config (more on that below).
  5. 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 --:

bash
curl -fsSL https://install.kristoffer.dev/dotfiles | bash -s -- --dry-run
FlagDoes
--dry-runPrints what would be installed, linked and moved aside, and changes nothing. It clones to a temporary folder to look
--link-onlySkips 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

  1. 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.
  2. 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.
  3. Open a new Ghostty window. The prompt is already configured: .p10k.zsh is in the repo, so there is no wizard to answer.
  4. 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 commit fails.
  5. gh auth login.
  6. nvim. The first launch installs every plugin. Give it a minute and do not panic at the wall of text.

Ubuntu, Debian and WSL

  1. sudo apt install -y curl
  2. Run the one-liner. It installs git, Stow, zsh, tmux and jq with apt, and asks for sudo to do it.
  3. 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.
  4. chsh -s "$(command -v zsh)", then log out and in again.
  5. 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:

PackageLinked toWhat it brings
zsh~/.config/zshOh My Zsh + Powerlevel10k, aliases, tmux helpers, CLI completions
tmux~/.config/tmuxCtrl+a prefix, vim-style panes, mouse on, OSC 52 clipboard
nvim~/.config/nvimLazyVim with .NET, Go, Vue, Tailwind, DAP and Claude Code extras
ghostty~/.config/ghosttyCatppuccin Mocha, OSC 52 clipboard, no stray mouse input after sleep
git~/.config/gitGlobal git config; every commit and tag signed with an SSH key
lazygit~/.config/lazygitNerd Font v3 icons
opencode~/.config/opencodeNine agents (companion, reviewer, architect …) and skills
claude~/.claudeClaude 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:

bash
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:

bash
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:

text
 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% ↻3d11h

The 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:

AliasRunsWhy
nnvim .Open the editor on the current directory
llls -alLong listing, dotfiles included
gssgit status -sShort status, the one I type most
gaagit add .Stage everything
gcmgit commit -mCommit with a message
gpshgit pushPush
ccclaudeClaude Code
ocopencodeOpenCode
dtabledocker ps --format table …Containers as a readable table
dstartdocker compose up -d && docker compose logs -fStart the stack and tail it
tmxtmx <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:

KeysDoes
Ctrl+aPrefix (instead of Ctrl+b)
Ctrl+a |Split left/right
Ctrl+a -Split top/bottom
Ctrl+a h/j/k/lMove between panes
Ctrl+a H/J/K/LResize the pane (repeatable)
Ctrl+a [Copy mode, vi keys
Ctrl+a rReload 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:

bash
nvim ~/.config/zsh/.zshrc      # = ~/dotfiles/.config/zsh/.zshrc
cd ~/dotfiles && gss           # see what changed
gaa && gcm "zsh: add alias" && gpsh

On 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:

  • alias lists them all
  • ll ~/.config shows the links Stow made
  • cat ~/.config/zsh/.zshrc and cat ~/.config/git/config print what is in them
  • cc draws the Claude Code status line
  • n opens Neovim, as far as the splash screen
  • tmux and neofetch do what you would expect
  • dotfiles diff lines 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:

bash
cd ~/dotfiles
stow -D -t ~/.config .config
stow -D -t ~/.claude .claude
sed -i.bak '/ZDOTDIR/d' ~/.zshenv