Interactive macOS bootstrap: Homebrew, CLI tools, fnm/bun, optional Miniconda, SDKMAN!, Google Cloud SDK and the .NET SDK (which provides NuGet), optional Karabiner Elements (via the karabiner-manager submodule), and the standalone self-installing dotfiles repo cloned to ~/kernvex/dotfiles by default (override with env TARGET_DOTFILES).
Optional artifacts are opt-in: they are prompted for on an interactive run, and --upgrade will converge one that is already installed but never introduce one you declined.
Two packagings, chosen per machine with ESETUP_DOTNET_FLAVOR (unset asks):
cask (dotnet-sdk) |
formula (dotnet) |
|
|---|---|---|
| Source | Microsoft's official .pkg |
Homebrew source build (dotnet/dotnet VMR) |
| Installs to | /usr/local/share/dotnet (asks for sudo) |
the brew prefix (no sudo) |
| MAUI | supported | not a supported configuration |
A machine that builds MAUI wants ESETUP_DOTNET_FLAVOR=cask. MAUI is supported on Microsoft's own SDK build; on a source-built SDK it is not. Note the formula gives you no upfront signal — dotnet workload search maui still lists all eight MAUI workloads there and their manifests still install, so trouble surfaces later in a build and looks like a broken project rather than a wrong SDK. That is why the flavour is a deliberate choice at install time.
On the cask, setup then offers dotnet workload install maui, which needs a full Xcode (checked upfront) and sudo when the SDK root is root-owned.
Both flavours symlink a dotnet into the brew prefix, so they collide. Setup refuses to install one over the other and reports it instead of letting Homebrew lose the race.
scripts/apply-toolchain-env.sh writes the fish config that installed toolchains need — DOTNET_ROOT, and gcloud's path.fish.inc so gcloud components install binaries land on PATH. It runs as the last step of setup.sh and is standalone-runnable:
./esetup/scripts/apply-toolchain-env.sh --dry-run # preview
./esetup/scripts/apply-toolchain-env.sh # applyIt detects toolchains by binary on disk rather than by Homebrew receipt, so it is correct however they arrived, and it generates the file wholesale: install a toolchain and re-run to add its config, remove one and re-run to retract it. Output goes to the fish stow package in the Dotfiles Repo (commit it) and is symlinked live; with no Dotfiles Repo present it writes ~/.config/fish/conf.d/ directly.
From the repo root:
./esetup/setup.shNon-interactive / skip conflict checks (CI or advanced):
./esetup/setup.sh --skip-preflightBefore installing casks, the script checks for common issues:
- Docker Desktop vs OrbStack — both provide a Docker engine/CLI; you get a menu to skip OrbStack, install anyway, or abort.
- Both stacks installed — warns if Docker.app and OrbStack are present.
- Rancher Desktop / Colima — warns about overlapping container tooling.
docker infofails — warns if the CLI exists but the daemon/context is broken.~/kernvex/dotfiles(orTARGET_DOTFILES) — warns if the path exists but is not the dotfiles git clone (setup leaves it alone).- Two Homebrew installs — warns if both
/opt/homebrewand/usr/localhavebrew.
Dotfiles are a standalone repo (kernvex/dotfiles); esetup clones it to ~/kernvex/dotfiles (default; override with TARGET_DOTFILES), runs its self-installer (./install, stow --no-folding), and applies the non-stowed artifact areas (raycast/, keyboard/). See docs/adr/0004.
The repo includes a git submodule at karabiner-manager (kernvex/karabiner-manager): TypeScript (rules.ts) generates karabiner.json and writes it to ~/.config/karabiner/karabiner.json (override with env KARABINER_CONFIG_PATH when running yarn build).
Clone with submodules (or initialize later):
git clone --recurse-submodules https://github.com/kernvex/esetup.git
# or, from an existing clone:
git submodule update --init --recursiveVerify the submodule remote (requires GitHub CLI):
gh repo view kernvex/karabiner-manager
git submodule statusDuring ./esetup/setup.sh, after the fnm Node step, you can opt in to install Karabiner Elements, Raycast, and Rectangle (used by window-management shortcuts), back up any existing ~/.config/karabiner/karabiner.json, run yarn install / yarn build in karabiner-manager, and kickstart the Karabiner user daemon so the new JSON loads.
Rebuild by hand after editing rules:
cd karabiner-manager && yarn install && yarn build
launchctl kickstart -k "gui/$(id -u)/org.pqrs.karabiner.karabiner_console_user_server"Raycast follow-up: many Hyper-key bindings open raycast://… deep links (built-in Raycast commands plus third-party extensions such as Toothpick, Silent Mention, and custom script commands). Until those extensions or scripts are installed, some shortcuts may do nothing or show an error in Raycast. A future pass can document required extensions or trim rules.ts to match what you actually install.
The repo includes a git submodule at obsidian-habit-tracker (kernvex/obsidian-habit-tracker): a generator that turns habits.md into a full Obsidian Habits vault (daily-note template, Bases views, heatmaps, streak dashboard). Same pattern as karabiner-manager. See docs/adr/0005.
During ./esetup/setup.sh, after the Karabiner step, you can opt in to
optional_obsidian_habit_tracker: it initializes the submodule, installs the
Obsidian cask, runs bun install && bun run sync, and deploys the vault into
the Obsidian iCloud container (override with OBSIDIAN_HABITS_VAULT), then copies
in the .obsidian config + pinned plugins from the dotfiles obsidian package.
Obsidian ≥ 1.12.4 required on Mac and iPhone — Bases (the dashboard engine) does not exist before then. Rebuild/redeploy by hand after editing habits:
cd obsidian-habit-tracker && bun run add-habit # or: edit habits.md && bun run sync && bun run deploy --applyA second generator submodule at obsidian-lingo (kernvex/obsidian-lingo): a config-driven generator for the Lingo language-learning vault — language-agnostic Concepts + per-language flashcards reviewed with obsidian-spaced-repetition (FSRS). Same submodule pattern as obsidian-habit-tracker; adding a language is one command (bun run add-language Spanish). See docs/adr/0006.
During ./esetup/setup.sh, optional_obsidian_lingo (after the Habits step)
inits the submodule, runs bun install && bun run sync, deploys the scaffold
(templates + .obsidian/types.json) into the iCloud container
(OBSIDIAN_LINGO_VAULT), and copies the .obsidian config + spaced-repetition
plugin from the dotfiles obsidian/lingo package. Your cards are content and sync
via iCloud; the generator never touches them.
Standalone helpers under scripts/ (executable, run directly — not sourced like modules/):
migrate-to-fish.sh— one-shot zsh → fish default-shell migration.install-claude-skills.sh— link the Claude skills submodule into~/.claude/skills(see docs/claude-skills).update-github-remotes.sh— repoint git remotes after a GitHub handle rename (see below).update-github-handle-refs.sh— rewrite GitHub handle references (URLs + repo slugs) inside file contents after a rename, preserving filesystem paths (see below).
After renaming your GitHub account, existing clones keep pointing at the old handle. GitHub redirects them for a while, then stops without warning and pushes start failing. This sweeps every git repo under ~ and repoints only the remotes that still reference the old handle — repos with multiple remotes keep their other remotes untouched. It matches both SSH (git@github.com:old/…) and HTTPS (https://github.com/old/…) URLs.
scripts/update-github-remotes.sh # dry run: preview what would change
scripts/update-github-remotes.sh --apply # rewrite the matching remotes
scripts/update-github-remotes.sh --verify # git ls-remote each new remote to confirm accessThe handles default to this repo's own migration (6eniu5 → kernvex); override for a future rename:
OLD_HANDLE=oldname NEW_HANDLE=newname scripts/update-github-remotes.sh --applyEvery run writes a timestamped report to ~/.github-remote-updates/. Dry run and --verify never change anything; rewriting is idempotent and safe to re-run. --verify uses BatchMode/GIT_TERMINAL_PROMPT=0, so an unreachable remote is logged as FAIL instead of hanging on a credential prompt. Other flags: --help; env ROOT and MAXDEPTH tune the search (default ~, 5 levels deep).
Note: submodule remotes (
karabiner-manager,obsidian-*, dotfiles'nvim) are stored as.gitfiles, not directories, so this script skips them. Update those withgit config -f .gitmodules+git submodule sync, or in the submodule's own checkout.
update-github-remotes.sh fixes git remotes; this fixes the handle written into file contents — clone commands in docs, submodule URLs in .gitmodules, and default URLs in setup scripts.
It rewrites the handle in two forms: inside a GitHub URL (github.com/OLD, github.com:OLD) and as a bare repo slug (OLD/repo, e.g. `OLD/dotfiles`, gh repo view OLD/x). It never touches the handle when it is a filesystem path: any OLD/ preceded by / or ~ (~/kernvex/dotfiles, $HOME/kernvex/…) is left alone, because the personal folder was itself named after the handle — only renaming the directory can safely change those (that rename has since happened: ~/6eniu5 → ~/kernvex). It also ignores non-slug bare uses (git identity, the ssh key filename OLD_id_ed25519, the Bonjour hostname, the gh account) — each a separate, deliberate change. Use --scan to audit everything it leaves.
scripts/update-github-handle-refs.sh # dry run: show refs (old -> new)
scripts/update-github-handle-refs.sh --apply # rewrite them (backs up every file first)
scripts/update-github-handle-refs.sh --scan # audit ALL bare OLD occurrences it won't touchBackups for each --apply go to ~/.github-remote-updates/backups-<timestamp>/ (mirroring the original paths), plus a timestamped report. Skips history/app-state/build noise (.git, .claude, .cursor, node_modules, target/dist/build, shell histories, transcripts). Handles/root are env-overridable (OLD_HANDLE, NEW_HANDLE, ROOT). Needs ripgrep with PCRE2 (rg -P) for the path-vs-slug lookarounds.
Caveat: because the local directory shares the handle's name, a bare
OLD/dotfilesthat actually means the local path (not the GitHub repo) is indistinguishable from a repo slug by regex — review those after--apply. Also rungit submodule syncin any repo whose.gitmoduleschanged so the new URL reaches each submodule's.git/config.
- macOS
- Network for Homebrew and git submodules (SSH keys for GitHub)
Dotfiles live in their own self-installing repo, kernvex/dotfiles,
cloned to ~/kernvex/dotfiles. Stow packages (fish, starship, wezterm, tmux,
tmux-sessionizer-config, bin, atuin, git, htop, nvim) plus the nvim/tmux-sessionizer
submodules and the keyboard//raycast/ artifact areas. esetup just clones and runs its ./install;
the repo also deploys standalone (git clone … && ./install). See docs/adr/0004.
setup.sh installs go and tree-sitter-cli via Homebrew; fish adds ~/go/bin to PATH.
In Neovim, Mason auto-installs gopls and goimports on first .go file; format-on-save uses goimports + gofmt.
setup.sh installs the toolchain via the official rustup installer (run_rustup_default_toolchain), not Homebrew — see lesson below. fish already has ~/.cargo/bin on PATH and sources ~/.cargo/env.fish, so no shell changes are needed. rustfmt + clippy ship with the stable profile. In Neovim, Mason auto-installs rust_analyzer on first .rs file; format-on-save uses rustfmt.
- Use the official rustup installer (
curl https://sh.rustup.rs | sh), notbrew install rustup. The Homebrew formula is keg-only and keeps its proxies in the keg — it leaves~/.cargo/binempty, so nothing lands on the PATH the fish config expects. The official installer populates~/.cargo/binand writes~/.cargo/env.fish(the exact layout config.fish was already wired for).
- The CLI is the Homebrew formula
tree-sitter-cli, nottree-sitter(that one is library-only, no binary). nvim-treesitter'smainbranch needs it to compile parsers, else builds fail withENOENT: 'tree-sitter'. - nvim-treesitter is pinned to
branch = 'main'. Use its API (require('nvim-treesitter').install(parsers)), neverrequire('nvim-treesitter.configs').setup{}— that module exists only on the oldmasterbranch and errors onmain(attempt to call field 'install' (a nil value)shows the reverse: a stalemastercheckout under amainspec). Fix a stale checkout:cd ~/.local/share/nvim/lazy/nvim-treesitter && git fetch origin main && git checkout main && git reset --hard origin/main. - The nvim config is a submodule of
kernvex/kickstart.nvim(default branchmaster); push there for new machines. It is now a submodule of the standalonekernvex/dotfilesrepo — commit dotfiles changes there and push (it has a remote), unlike the old local-only stow target.