A single repo that configures a consistent development environment on macOS and Linux (including ephemeral remote hosts). One command to push, install, and activate. Works on machines with zsh or bash, old or current tooling.
- Idempotent — Running setup twice is safe and fast.
- Minimal dependencies — Bootstrap requires only a POSIX shell (
zshon macOS,bash4.2+ on Linux),curl, andgit. The install script is written in bash but invoked via#!/usr/bin/env bashon Linux and can be run under zsh on macOS where bash is ancient (3.2). - Shell-agnostic configs — Shared aliases/env live in POSIX-compatible files sourced by both
.bashrcand.zshrc. - Detect, don't assume — OS, shell, package manager, SSH vs local, tmux presence, sudo availability — all detected at runtime.
- tmux from nightly — tmux must be built from the nightly/overnight build. Upstream has fixes critical for Claude Code that are not yet in released versions.
packages.shhandles building from source when the system tmux is too old or missing these patches. - Fail gracefully — All scripts use
set -euo pipefail. Each setup phase reports success/failure independently. A failed package install does not block config linking. - Tiered deployment —
--minimalfor configs-only, full install by default on local,--remotefor remote hosts.
| Platform | Min Version | Notes |
|---|---|---|
| macOS | 12+ | Homebrew, zsh default |
| Ubuntu | 18.04+ | apt |
| Debian | 10+ | apt |
| RHEL/CentOS | 7+ | yum (7), dnf (8+) |
| Amazon Linux | 2+ | yum (AL2), dnf (AL2023) |
| Fedora | 36+ | dnf |
| Alpine | 3.14+ | apk |
| Arch | rolling | pacman |
Shell compatibility: Scripts must work under both bash 4.2+ (RHEL 7 / Amazon Linux 2) and zsh 5.0+ (macOS default). macOS ships bash 3.2 which is too old — on macOS, install.sh detects this and re-execs under zsh if needed. Scripts must not use bash 4.3+ features (nameref, ${var@Q}, mapfile -d). Use read loops and simple arrays only.
dot/
├── spec.md # This file
├── install.sh # Single-command bootstrap entry point
├── setup/
│ ├── detect.sh # OS/shell/tool detection helpers
│ ├── packages.sh # Package installation (brew, apt, etc.)
│ ├── link.sh # Symlink manager (backup, link, idempotent)
│ ├── terminfo.sh # Ghostty terminfo compilation & distribution
│ └── post-install.sh # Final steps (shell change, cache builds, etc.)
├── shell/
│ ├── env.sh # POSIX env vars (PATH, EDITOR, COLORTERM, etc.)
│ ├── aliases.sh # POSIX aliases shared by bash and zsh
│ ├── bashrc # Bash-specific config, sources env.sh + aliases.sh
│ ├── zshrc # Zsh-specific config, sources env.sh + aliases.sh
│ ├── zprofile # Zsh login shell setup
│ ├── bash_profile # Bash login shell setup
│ └── prompt.sh # Built-in prompt (no external dependencies)
├── git/
│ ├── gitconfig.template # Git config — [user] added on local, omitted on remote
│ └── gitignore_global # Global gitignore patterns
├── tmux/
│ └── tmux.conf # tmux configuration
├── ghostty/
│ └── config # Ghostty terminal config (macOS only, not linked on remote)
├── zed/
│ └── settings.json # Zed editor settings (macOS only)
├── claude/
│ └── settings.json # Claude Code settings & statusline (always install latest version)
├── terminfo/
│ └── xterm-ghostty.terminfo # Ghostty terminfo source for remote hosts
└── bin/
├── dot-push # Push dotfiles to a remote host via scp+ssh
├── dot-ssh # SSH with credentials forwarded via env, attaches tmux
└── dot-update # Git pull + re-run setup
git clone <repo> ~/dot && ~/dot/install.shFrom local machine:
dot-push user@host
dot-push user@host --minimal # Configs + terminfo only, no packages
dot-push user@host --no-claude # Skip Claude Code install
# Then connect with credentials forwarded:
dot-ssh user@host # Attaches tmux, forwards git/gh/aws creds
dot-ssh user@host --no-tmux # Shell only, no tmuxOn the remote host itself (if repo is already there):
~/dot/install.shAll scripts use set -euo pipefail. Each phase is wrapped in a function that traps errors and reports status. A failed phase logs the error and continues to the next phase (except detect.sh, which is fatal).
- Source
setup/detect.sh— determine OS, distro, shell, package manager, whether inside SSH, whether tmux is available, whether sudo is available. - Run
setup/packages.sh— install missing packages appropriate to the platform. Skipped with--minimal. - Run
setup/terminfo.sh— compile and install ghostty terminfo if missing. - Run
setup/link.sh— create parent directories (mkdir -p), then symlink config files to their expected locations, backing up any existing files. - Run
setup/post-install.sh— any final steps (e.g., change default shell to zsh if available and not already set). - Write
~/dot/.versionwith git SHA (if in a git repo) or timestamp (if pushed via tarball). - Print summary: what was installed, linked, skipped, and any errors encountered.
All phases support --dry-run, which prints what would happen without making changes:
- Packages that would be installed
- Symlinks that would be created/replaced
- Files that would be backed up
- Terminfo that would be compiled
~/dot/install.sh --dry-run
dot-push user@host --dry-run- All scripts begin with
set -euo pipefail. - Each setup phase runs in a subshell or function with
trapto catch failures. detect.shfailure is fatal — if we can't determine the OS, we can't proceed.packages.shfailure is non-fatal — log what failed, continue to linking. Missing packages are reported in the final summary.link.shfailure is non-fatal per-link — each symlink operation is independent. A failed link logs and continues.terminfo.shfailure is non-fatal — TERM falls back toxterm-256color.- Exit code: 0 if all phases succeeded, 1 if any phase had errors (even if non-fatal). Summary distinguishes warnings from errors.
- Detect sudo availability: check if
sudoexists and user has passwordless sudo, or is already root. - If sudo is required (package installs) but unavailable: skip package installation, log a warning, continue with config linking.
- Never prompt for a sudo password non-interactively. If
--interactiveis not set, skip packages that need sudo.
Some hosts have zsh, some only bash. Aliases and env vars shouldn't be duplicated.
~/.bashrc → sources shell/env.sh + shell/aliases.sh + bash-specific setup
~/.zshrc → sources shell/env.sh + shell/aliases.sh + zsh-specific setup
shell/env.sh (POSIX):
- PATH construction (deduplicated, order-preserving)
- EDITOR, VISUAL, PAGER
- COLORTERM=truecolor, CLICOLOR=1, LSCOLORS
- TERM handling (see Terminal section) — must be the first thing evaluated, before any command that reads terminfo
- Homebrew shellenv (macOS only, detected)
- Go, npm, local bin paths
shell/aliases.sh (POSIX):
- Navigation:
..,...,.... - Listing:
ll,la,l(usingls --coloron Linux,ls -Gon macOS;ezaif available) - Git shortcuts:
gs,gd,gl,gco,gcm,gp,ga - Tmux:
ta(attach or create),tl(list),tk(kill) - Claude:
cc(claude),ccc(claude --continue) - Grep:
grep --color=auto - Safety:
rm -i,mv -i,cp -i(interactive prompts on destructive ops) - Platform-conditional aliases (e.g.,
pbcopy/xclip) fdnormalization: aliasfd=fdfindon Debian/Ubuntu where the binary is named differently
shell/zshrc (zsh-specific):
- History config (HISTSIZE, dedup, shared)
- Completion system (compinit, menu select, case-insensitive)
- Key bindings (emacs mode, history search)
- Source
prompt.shfor prompt setup - Auto-cd, correction, no beep
shell/bashrc (bash-specific):
- History config (HISTSIZE, dedup, append)
- Completion (bash-completion if available)
- Source
prompt.shfor prompt setup - Key bindings (history search)
shell/bash_profile:
- Sources
~/.profileif it exists (preserves system-level PATH and/etc/profile.d/setup) - Then sources
~/.bashrc
shell/prompt.sh (the prompt — no external dependencies):
- Colorized PS1 with user, host (red
[SSH]badge when remote), abbreviated cwd, git branch - Works in both bash and zsh
- Lightweight — no subshells or external commands in the hot path
- Git branch via direct
.git/HEADread, notgitsubprocess - Colors: green user@host, cyan cwd, magenta git branch, red SSH indicator
~/.local.sh is created by post-install.sh on first run with an empty template. It is never overwritten on subsequent runs. Both bashrc and zshrc source it at the end.
Shell-specific overrides are also supported:
~/.local.zsh— zsh-specific overrides (only from zshrc)~/.local.bash— bash-specific overrides (only from bashrc)
Use these for:
- Extra PATH entries (e.g., LM Studio, OrbStack)
- Machine-specific aliases
- API keys / tokens (never in the repo)
- Tool integrations that vary per machine
Ghostty uses TERM=xterm-ghostty, but remote hosts don't have that terminfo entry. This breaks colors, cursor handling, and features through the chain: Ghostty → SSH → tmux → shell/claude-code.
- Ship the terminfo source in
terminfo/xterm-ghostty.terminfo. setup/terminfo.shcompiles it withticinto~/.terminfo/on every host. Usestic -xfor extended capabilities on systems that support it; falls back toticwithout-xon older ncurses (5.x). Verifies compilation succeeded by checking the output file exists.- tmux.conf sets
default-terminal "tmux-256color"and configures bothterminal-overridesandterminal-featuresfor true color, undercurl, and extended keys. shell/env.shhandles TERM negotiation as the very first thing, before any command that reads terminfo:- Inside tmux → trust tmux's TERM (
tmux-256color), do not override - If
$TERMis set and terminfo exists for it → keep it - If
$TERMis set but terminfo is missing → fall back toxterm-256color - This prevents terminfo warning spam between SSH login and tmux attach
- Inside tmux → trust tmux's TERM (
- COLORTERM=truecolor is always exported.
The setup script prints a color test grid after install to confirm:
- 256-color support
- True color (24-bit) support
- Bold, italic, underline rendering
(Undercurl validation only inside tmux with proper terminal-overrides configured.)
# Terminal — base terminal type
set -g default-terminal "tmux-256color"
# True color support
set -ga terminal-overrides ",xterm-ghostty:Tc"
set -ga terminal-overrides ",xterm-256color:Tc"
# Undercurl support (colored underlines)
set -as terminal-overrides ',*:Smulx=\E[4::%p1%dm'
set -as terminal-overrides ',*:Setulc=\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m'
# Extended features for tmux 3.2+ (ignored silently on older versions)
set -gq terminal-features ",xterm-ghostty:256:RGB:mouse:cstyle:overline:strikethrough:usstyle"
set -gq terminal-features ",xterm-256color:256:RGB"
# General
set -g mouse on
set -g history-limit 50000
set -g escape-time 10 # Low but not zero — safe over SSH latency
set -g focus-events on
set -g set-clipboard on
# Prefix
set -g prefix C-a
unbind C-b
bind C-a send-prefix
# Window/pane management
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
# Vi-style pane navigation
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# Status bar
set -g status-style "bg=default,fg=green"
set -g status-left "#[bold]#S "
set -g status-right "#H %H:%M"
# Reload
bind r source-file ~/.tmux.conf \; display "Reloaded"
No custom vimrc. Use stock vim. Users who want customization can add it via ~/.local.sh or create their own ~/.vimrc — it won't be managed or overwritten by this project.
| OS | Package Manager | Install Method |
|---|---|---|
| macOS | Homebrew | Install brew if missing, then brew install |
| Debian/Ubuntu | apt | sudo apt update && sudo apt install |
| RHEL/Fedora/Amazon Linux | dnf/yum | sudo dnf install or sudo yum install |
| Alpine | apk | sudo apk add |
| Arch | pacman | sudo pacman -S |
Sudo handling: If user is root, run package commands directly. If sudo is available, use it. If neither, skip package installation and warn.
On Ubuntu systems, snap packages of core tools (e.g., lxd, core18, old cmake) can shadow or conflict with apt versions. packages.sh detects snap-installed versions of packages we manage and:
- Removes the snap version (
sudo snap remove <pkg>) - Installs the apt version instead
- Logs what was replaced
This ensures we get current apt-managed versions rather than stale snaps.
Package names vary across distros. packages.sh maintains a mapping:
| Logical Name | apt | dnf/yum | apk | pacman | brew |
|---|---|---|---|---|---|
| fd | fd-find | fd-find | fd | fd | fd |
| ripgrep | ripgrep | ripgrep | ripgrep | ripgrep | ripgrep |
| bat | bat | bat | bat | bat | bat |
On Debian/Ubuntu where fd installs as fdfind, aliases.sh creates alias fd=fdfind.
Core (all platforms):
git(if not present or too old)tmux— must be nightly/HEAD build (see Principle 5).packages.shchecks the installed version; if it lacks required patches, it builds tmux from the nightly source tarball (requireslibevent-dev,ncurses-dev,build-essentialor equivalents, installed automatically).vim(stock, no custom config)curl,wgetjq,unzipripgrep(rg)fd(orfd-find)eza(modern ls — optional, skipped if not in repos, aliases fall back to plain ls)
macOS only:
- Homebrew itself (if missing)
ghostty(if not already installed)
Optional:
eza— Only installed if available in the host's package repos. Never installed from source or curl. Aliases fall back to plainlsif unavailable.
GitHub CLI (gh):
- Always installed/updated to latest.
- On apt: adds GitHub's official apt repo for latest version (distro repos have stale versions).
- On dnf/yum: adds GitHub's rpm repo.
- On apk:
github-clifrom Alpine repos. - On pacman:
github-clifrom Arch repos. - On brew:
brew install gh. - Credentials are not stored on disk —
GH_TOKENis forwarded bydot-sshat connection time.
AWS CLI:
- Always installed/updated.
- On glibc Linux (Ubuntu, Debian, RHEL, Fedora, Amazon Linux, Arch): AWS CLI v2 from official zip installer at
awscli.amazonaws.com. Supports x86_64 and aarch64. - On Alpine (musl): AWS CLI v1 via
pip install awscli(the v2 zip requires glibc). - On macOS:
brew install awscli. - Credentials are not stored on disk —
AWS_*env vars are forwarded bydot-sshat connection time.
Claude Code:
- Detect if
claudeis already on PATH → done. - If not, and
--no-claudeis passed → skip. - If not, and
node/npmare on PATH →npm install -g @anthropic-ai/claude-code@latest. - If node is missing → install node via package manager, then install claude.
- If claude is already installed →
npm update -g @anthropic-ai/claude-codeto ensure latest version. - On
--minimalinstalls: always skipped.
setup/link.sh manages all symlinks.
Before linking: mkdir -p for all target parent directories:
~/.config/~/.config/ghostty/~/.config/zed/~/.claude/~/.dotfiles-backup/
| Source (in repo) | Target (on filesystem) | Condition |
|---|---|---|
shell/bashrc |
~/.bashrc |
always |
shell/bash_profile |
~/.bash_profile |
always |
shell/zshrc |
~/.zshrc |
zsh installed |
shell/zprofile |
~/.zprofile |
zsh installed |
(generated) ~/.gitconfig |
~/.gitconfig |
always (see Git section) |
git/gitignore_global |
~/.gitignore_global |
always |
tmux/tmux.conf |
~/.tmux.conf |
always |
ghostty/config |
~/.config/ghostty/config |
macOS only |
zed/settings.json |
~/.config/zed/settings.json |
macOS only |
claude/settings.json |
~/.claude/settings.json |
claude installed |
Symlink method: Use ln -sfn for atomic replacement (creates link to temp name, then mv on platforms where ln -sfn is not atomic). No window where neither old nor new file exists.
Backup behavior:
- If target exists and is a regular file (not a symlink to our source) → move to
~/.dotfiles-backup/<filename>.<timestamp> - If target is already correctly linked → skip (idempotent)
- If target is a symlink to something else → log what it pointed to, then replace
Stale symlink cleanup: After linking, scan ~/ for symlinks pointing into ~/dot/ that no longer resolve. Report them. Do not auto-delete (user may have renamed files intentionally).
git/gitconfig.template is generated into ~/.gitconfig during install (not symlinked). The template contains [core], [init], [pull], [push], [filter "lfs"], [alias], and [url] sections.
Local installs: A [user] section with name/email is prepended to the generated config. Identity is read from the existing ~/.gitconfig (before backup) or global git config, or prompted interactively. This ensures git commit works without environment variable injection.
Remote installs (--remote): No [user] section. Git identity comes entirely from environment variables (GIT_AUTHOR_NAME, etc.) forwarded by dot-ssh at connection time. Nothing on disk. The [url] SSH rewrite is also stripped, since SSH agent forwarding + GH_TOKEN handle auth.
No secrets are ever written to disk on remote hosts. All credentials live in the process environment only.
dot-ssh user@host connects via ssh -A -t, injecting env vars inline in the remote command, then attaches tmux. tmux's update-environment re-imports fresh values on every attach.
Forwarded credentials:
| Variable | Source | Used by |
|---|---|---|
GIT_AUTHOR_NAME / _EMAIL |
local git config |
git commits |
GIT_COMMITTER_NAME / _EMAIL |
local git config |
git commits |
GH_TOKEN |
gh auth token |
gh CLI, git credential helper |
AWS_ACCESS_KEY_ID |
local env | aws CLI, SDKs |
AWS_SECRET_ACCESS_KEY |
local env | aws CLI, SDKs |
AWS_SESSION_TOKEN |
local env | aws CLI (STS) |
AWS_PROFILE |
local env | aws CLI |
AWS_REGION / AWS_DEFAULT_REGION |
local env | aws CLI, SDKs |
SSH_AUTH_SOCK |
SSH agent (-A) |
git-over-SSH |
tmux propagation: tmux.conf lists all credential vars in update-environment. New panes/windows inherit from tmux server. Re-attach via dot-ssh refreshes all values. Claude Code runs inside tmux panes and inherits everything.
.DS_Store
*.swp
*.swo
*~
.env
.env.local
.idea/
.vscode/
__pycache__/
*.pyc
node_modules/
.direnv/dot-push user@host [--minimal] [--no-claude] [--dry-run]- Create a tarball of the repo excluding:
.git/ghostty/zed/spec.md.DS_Store
scptarball to remote~/dot.tar.gz.sshinto host:- Extract to
~/dot/ - Run
~/dot/install.sh --remote [--minimal] [--no-claude] - Clean up tarball
- Write
~/dot/.versionwith local git SHA + timestamp
- Extract to
- Print summary of what was installed/linked.
Configs + terminfo only. No package installation, no Claude Code. This is the fastest path — useful for hosts where you just need your shell config and colors working.
dot-push user@host # Push configs + install packages (once)
dot-ssh user@host # Connect with credentials (every time)dot-push sets up the host. dot-ssh connects with credentials. Use dot-ssh for all subsequent connections — it forwards git identity, GH token, AWS creds, and SSH agent. Never use plain ssh to connect if you need credentials.
dot-update
# Equivalent to: cd ~/dot && git pull && ./install.sh# From local machine:
dot-push user@host~/dot/.version contains the git SHA (or timestamp) of the deployed version. dot-push compares local vs remote version before pushing and shows a diff summary of what changed.
- SSH keys or secrets — Never committed. Per-host SSH config goes in
~/.local.shor host-specific~/.ssh/config. - Plugin managers — No oh-my-zsh, no vim plugin managers. Keep it lean for ephemeral hosts.
- Desktop environment config — No window manager, dock, keyboard, etc. Only terminal-centric tools.
- Encrypted secrets — No age/gpg integration. Secrets are managed out of band.
- Uninstall — On ephemeral hosts this is unnecessary. Backups in
~/.dotfiles-backup/allow manual restoration.
A test/ directory (not deployed to remotes) contains Dockerfiles for each target platform:
test/
├── Dockerfile.ubuntu18
├── Dockerfile.ubuntu22
├── Dockerfile.amazonlinux2
├── Dockerfile.amazonlinux2023
├── Dockerfile.rhel7
├── Dockerfile.alpine
└── run-tests.sh
run-tests.sh builds each image, runs install.sh, and verifies:
- Symlinks are correct
- Shell sources without errors
- TERM/COLORTERM are set correctly
- Terminfo is compiled
- Aliases resolve
- Git config is generated
- No bash 4.3+ features used (shellcheck with
--shell=bashand version target)
- tmux plugins — No plugin manager. Keep tmux config self-contained.
- Zed remote — Zed handles its own remote server binary on first connect. We don't manage it.
- Claude Code settings — Ship a default
~/.claude/settings.jsonwith statusline config. - Starship — Dropped. Use a simple built-in prompt (
prompt.sh) with no external dependencies. - Vim config — Use stock vim. No managed vimrc.
- Git identity — Local:
[user]in gitconfig, preserved across re-runs. Remote: env vars (GIT_AUTHOR_NAME, etc.) forwarded bydot-ssh, no[user]in gitconfig. - Credentials — All forwarded via
dot-sshenvironment injection + tmuxupdate-environment. Never written to disk. Covers git identity, GH token, AWS credentials, SSH agent. - GitHub CLI — Installed from official repos. Auth via
GH_TOKENenv var. - AWS CLI — v2 from official installer (glibc Linux), v1 via pip (Alpine/musl), brew (macOS).