A one-command setup for a zsh + neovim + modern-CLI workstation on macOS or Linux.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sleistner/dotfiles/HEAD/install.sh)"Paste that in a macOS Terminal or Linux shell prompt.
The script detects your OS, explains each step as it runs, and prompts before anything with side effects (installing 1Password, changing your default shell). It's idempotent — safe to re-run on a machine that's already set up.
Read about what it does, and what it leaves for you to do, in docs/install-macos.md or docs/install-linux.md.
- Installs Xcode CLT + Homebrew (macOS) or distro packages (Linux) and runs
brew bundleagainst a curated Brewfile of ~115 formulae and ~15 casks. See TOOLS.md for what's in it and why. - Clones itself to
~/config/dotfilesand symlinks every entry underlinked/into~/.<name>and every entry underxdg/into~/.config/<name>. - Installs oh-my-zsh with
--keep-zshrc(leaves the symlinked.zshrcuntouched) and thezsh-autosuggestionsplugin. - Optionally installs 1Password + 1Password CLI so SSH agent and git commit signing Just Work.
- Opts you into a ~130ms zsh startup with Starship prompt, sensible
compinithandling, and dedupedPATH.
- Not a package manager. It delegates to Homebrew/apt/dnf/pacman and pins nothing beyond what's in the Brewfile.
- No secrets, ever. Anything that stores tokens or credentials
(
~/.npmrc,~/.terraformrc,~/.config/gh/hosts.yml,.contentfulrc.json) is deliberately not versioned — use 1Password or a secret manager. - No migration of state. Shell history,
~/.zcompdump, REPL histories,~/Library, and~/.cachestay put; this repo manages config only. - No GUI auto-config. Raycast/Ghostty/OrbStack need their one-time accessibility perms and first-launch wizards — the install doc calls these out explicitly.
- Not a framework. There's no plugin system or per-tool install
script —
./setupis a single ~50-line shell loop. Add a file underlinked/orxdg/, re-run./setup, done.
Add or remove files in linked/ or xdg/, then:
./setupIdempotent — ln -sfn overwrites existing symlinks to the same target.
dotctl is the management CLI for this repo — a git-style dispatcher that
runs dotctl-<cmd> from linked/bin/. Single namespaced entry point, so
generic names like doctor don't collide with tools on PATH (notably
Graphviz's dot).
dotctl # list commands
dotctl update # pull + brew bundle + setup (add -u to brew upgrade too)
dotctl doctor # health-check Brewfile ↔ installed ↔ docs
dotctl tools # render the grouped TOOLS.md referenceBrings a machine back in line with the repo. Runs, in order:
git pull --ff-onlyin the repo (skipped safely if there are uncommitted changes or no upstream — nothing gets clobbered).brew bundle install— installs anything missing from the Brewfile../setup— re-links new entries inlinked/orxdg/.brew upgrade— opt-in only, with-u/--upgrade.
Any failing step aborts with a non-zero exit; the default (no -u) is
deliberately conservative so routine updates don't silently pull in
major-version bumps of every Homebrew formula.
dotctl update # pull + bundle + setup
dotctl update -u # also `brew upgrade`Catches drift between the Brewfile, what's installed, and the docs:
brew bundle check— splits drift into:- missing (FAIL) — formula/cask/tap not installed.
- outdated (warn, non-fatal) — installed but
brew upgradewould bump it. Routine world drift, not repo drift.
- Brewfile ↔ TOOLS.md — every user-facing formula is documented (build/transitive deps are allow-listed in the script).
- Brewfile ↔
install-linux.md— every Rust CLI in the Brewfile has a matching entry in thecargo installblock of the Linux guide.
Failing checks collect runnable fix commands at the end. On a tty,
you can select one by number, a for all, or Enter to skip. In CI the
prompt is suppressed. Exit code is 0 when only warnings fire, non-zero
on any FAIL — CI stays green on outdated packages, red on genuine drift.
Renders TOOLS.md through mdcat for a terminal-friendly
view. Pass a section heading to filter:
dotctl tools # full reference
dotctl tools "rust clis" # just that section
dotctl tools -h # list available sectionsDrop an executable dotctl-<name> into linked/bin/. The dispatcher
picks it up automatically — no edits to dotctl itself.
linked/ -> ~/.<name> Dotfiles that tools read straight from $HOME
xdg/ -> ~/.config/<name> XDG-aware tools that look in $XDG_CONFIG_HOME
shell/ sourced by zshrc Shared shell env (PATH, EDITOR, locale, etc.)
install/ platform bootstrap install-macos.sh, install-linux.sh, common.sh
Every file or directory here becomes ~/.<name>:
| Entry | Symlinked to |
|---|---|
linked/zshrc |
~/.zshrc |
linked/zshenv |
~/.zshenv |
linked/zprofile |
~/.zprofile |
linked/gitconfig |
~/.gitconfig (includes ~/.gitconfig.local for identity) |
linked/gitignore_global |
~/.gitignore_global |
linked/tigrc |
~/.tigrc |
linked/tmux.conf |
~/.tmux.conf |
linked/pryrc |
~/.pryrc |
linked/bin/ |
~/.bin/ (on PATH via shell/env, hosts dotctl + subcommands) |
By default each top-level entry is mirrored into ~/.config/<name>, but
the behavior differs based on type:
- Top-level file — symlinked as a file.
xdg/starship.toml→~/.config/starship.toml - Directory without
.link-as-dirmarker — setup walks the tree and symlinks each file individually, so tools that write state into their own config dir keep ownership. Used when the repo contributes only some files to~/.config/<tool>/.xdg/zed/settings.json→~/.config/zed/settings.json(while~/.config/zed/conversations/,themes/, ... stay intact) - Directory with
.link-as-dirmarker — the whole directory is symlinked. Used when the repo owns the entire config tree.xdg/nvim/→~/.config/nvim/(marker file:xdg/nvim/.link-as-dir)
Current contents:
| Entry | Mode | Mirrored to |
|---|---|---|
xdg/starship.toml |
file | ~/.config/starship.toml |
xdg/nvim/ |
whole-dir | ~/.config/nvim/ |
xdg/ripgrep/ripgreprc |
file-walk | ~/.config/ripgrep/ripgreprc |
xdg/ghostty/config |
file-walk | ~/.config/ghostty/config |
xdg/mise/config.toml |
file-walk | ~/.config/mise/config.toml |
xdg/zed/settings.json |
file-walk | ~/.config/zed/settings.json |
shell/env— sourced fromlinked/zshrc. HoldsPATH,EDITOR, locale,GOPATH,RIPGREP_CONFIG_PATH, ulimit, keybindings, andtypeset -U pathfor auto-dedupe.
linked/gitconfig is shared across machines and intentionally does not
contain [user], [github], or the 1Password SSH-signing program path.
Those live in ~/.gitconfig.local, layered in via:
[include]
path = ~/.gitconfig.localOn first run, ./setup copies
install/gitconfig.local.example
to ~/.gitconfig.local if it doesn't already exist. Edit it to set
name, email, signing key, and (on macOS/Linux respectively) the
gpg.ssh.program path. Existing files are never overwritten. Git
silently ignores the include if the file is absent.
- Find out where the tool reads its config:
~/.toolrc,~/.toolrc.d/, or~/.config/tool/. - Drop the file or directory into
linked/(for~/.*) orxdg/(for~/.config/*). If the tool owns its config dir (writes state into it), keep the file shallow so the file-walk mode applies. If the repo owns the entire tree,touch xdg/<tool>/.link-as-dir. - Run
./setup.
No per-tool edits to the setup script.
Interactive zsh startup is ~130ms. Key tricks in linked/zshrc and
shell/env:
- Single
compinitcall (oh-my-zsh handles it; no other source should). ZSH_DISABLE_COMPFIX=trueskips the compaudit security check.ZSH_THEME=""— starship renders the prompt, so oh-my-zsh doesn't need to load a theme only to have it overwritten.- Plugins kept minimal:
git,zsh-autosuggestions. typeset -U pathauto-dedupes PATH entries across nested sourcings.- Homebrew paths hard-coded (
/opt/homebrew/opt/<pkg>/bin) — nobrew --prefixsubshells on startup.