Bootstrap for a macOS dev machine, plus an nvim/ overlay it copies over the
LazyVim starter.
On a machine with nothing on it, this is the whole command:
curl -fsSL https://raw.githubusercontent.com/brianburrous-nominal/device-setup/main/bootstrap.sh | bashFrom then on it's apply, from anywhere — and jarvis to see what you have.
Three pieces, with a clear division of labour:
bootstrap.sh |
gets the repo onto a bare machine: Xcode Command Line Tools, then the clone. Curled, because it runs before the repo exists. |
setup.sh |
installs what can't install itself — Homebrew, Oh My Zsh, rustup, the LazyVim starter — and needs sudo. Ends with the identity step. |
bin/apply |
reconciles a machine that already exists. No sudo, no prompts, cheap when there's nothing to do. |
bootstrap.sh exists because of a bootstrapping problem setup.sh can't solve
from inside the repo: /usr/bin/git on a bare Mac is a stub that opens a GUI
dialog and fails, so there's no way to clone anything until the Command Line
Tools are installed. It installs them headlessly — no dialog to click — clones,
and hands off. It needs nothing but what macOS already ships, and is bash-3.2
clean, because Homebrew's newer bash doesn't exist yet when it runs.
All three are safe to re-run: every step checks for an existing install first, an existing checkout is updated rather than re-cloned, and every line added to a shell config is added only once.
| Package managers | Homebrew, uv, rustup (cargo), pnpm, juliaup |
| Shell | Oh My Zsh, atuin, zoxide, direnv, fzf |
| CLI | ripgrep, fd, jq, yq, eza, bat, sd, scc, gh, git, lazygit, wget, ast-grep, gum, shellcheck |
| Python | ruff, JupyterLab, httpie — installed with uv tool install (isolated venv, shim in ~/.local/bin) |
| Julia | current stable release, via juliaup add release |
| Editor | Neovim + LazyVim, node (for mason's npm-based LSP servers) |
| Network | nmap, arp-scan — plus netscan and netif in bin/, which need neither |
| Nominal | nomctl (the nominal-cli crate, via cargo install) |
| Other | just for project commands, try for scratch directories, JetBrainsMono Nerd Font |
Nothing is needed for trash — macOS 15 added /usr/bin/trash, which is why
Homebrew's trash and macos-trash formulae are both keg-only now.
This table is the summary; jarvis is the version that can't go stale, since it
reads the same declarations the installer does.
It also links the shell config and the scripts in bin/ into place — see
Shell config below.
jarvis is the front door to all of it. Every tool this repo puts on the
machine in one browsable list, with what it's for and what to type:
jarvis browse: type to filter, enter for the full page
jarvis show rg one tool in full — docs, examples, where it came from
jarvis list search one category, as plain text
jarvis search json match on name, summary or description
jarvis status what's installed and what isn't, per source
jarvis install install everything declared but missing
jarvis install ripgrep install one tool, whichever manager owns it
jarvis upgrade upgrade every manager on the machine
jarvis upgrade brew uv ...or just some of them
jarvis doctor check the registry, the catalog and the machine agree
In the browser: type to filter, enter for the full page, esc to quit.
ctrl-y copies the first example, ctrl-o opens the homepage, ctrl-r
re-probes what's installed. On the full page, esc, backspace and q all go
back.
tab is the one worth knowing. It takes one of the tool's examples and puts it
on a real shell prompt, ready to edit and run:
➜ setup git:(main) netscan --json | jq .
Nothing runs until you press enter, which is what makes it safe to land on an
example that carries a sudo or a recursive delete — you read the line first.
And it's an interactive shell with your own config loaded, so the aliases and
functions in the catalog actually work: run ls from a script's subshell and
you'd get /bin/ls, not the eza alias. exit comes back to the browser.
The point is that you stop having to remember which manager owns what. A brew
formula, a uv tool, a cargo crate, a script in bin/ and an alias in
zsh/rc.zsh all look the same in the list and answer to the same commands —
which matters most for the ones that aren't packages at all. ls is an eza
alias, rm is a function wrapping trash, netscan is a script in bin/.
None of those can be declared in lib/packages.sh, because nothing installs
them, but all of them are things you type.
Run it with no terminal — piped, redirected — and you get the plain list
instead of the picker, so jarvis | grep … does what you'd expect.
One file knows, and everything else asks it:
lib/managers.sh |
how each manager installs and upgrades. Bash- and zsh-safe, because both lib/reconcile.sh and bin/jarvis source it |
That's why apply -u and jarvis upgrade can't drift: they are the same walk
over the same list. Adding a manager is a case branch in that one file, and
every caller picks it up.
Two honest gaps it reports rather than papers over. cargo install has no
upgrade command — it only ever installs the latest and refuses when that's what
you already have — so crates go unchecked unless cargo-update is installed,
and jarvis upgrade cargo says so instead of running rustup update and
looking like it checked. And brew upgrade already covers casks, so the cask
step is a stated no-op rather than a second pass over the same work.
Three lists have to agree: what lib/packages.sh declares, what
lib/catalog.sh documents, and what is actually on the machine. Nothing keeps
them in step by construction, so doctor checks all three against each other:
$ jarvis doctor
==> Registry ↔ catalog
! lib/catalog.sh documents fd as brew:fd, but lib/packages.sh doesn't
declare it — other machines won't get it
==> Installed here but not declared
! brew formula 'yq' — add 'brew_formula yq' to lib/packages.sh
That second direction is the one worth having. A formula you installed by hand
months ago works fine here and is simply absent everywhere else, and nothing
else in the repo would ever mention it. It reads brew leaves --installed-on-request, uv tool list and cargo install --list, so it only
sees what you asked for, not the dependency graph underneath.
The handful of tools something other than the registry installs — juliaup,
which reconcile_julia handles — carry a -n note in the catalog saying why,
which is what stops the honest exceptions being reported as drift on every run.
Two lines. One in lib/packages.sh, so every machine installs it:
brew_formula hyperfine # benchmarking
uv_tool pre-commit
cargo_crate some-crate itsbinary...and one in lib/catalog.sh, so it turns up in jarvis with its docs:
tool hyperfine dev brew:hyperfine \
-s "benchmark a command properly — warmup runs, statistics, outlier detection" \
-d "The longer explanation. Say why you'd reach for it over the obvious
alternative, not just what it does." \
-x "hyperfine 'rg foo' 'grep -r foo .'" "compare two commands" \
-l "https://github.com/sharkdp/hyperfine"Do one and forget the other and jarvis doctor tells you, in whichever
direction you missed.
Commit it, and every other machine says so at its next shell prompt:
* 1 declared package(s) not installed — run apply
That notice is the point of the layout. lib/packages.sh is the single list of
what should be installed, and four different things read it:
setup.sh |
bootstraps a bare machine, then installs whatever is missing |
bin/apply |
reconciles a machine that already exists |
zsh/rc.zsh |
notices at startup that something isn't installed, and says so |
bin/jarvis |
shows install status per tool, and reconciles the list against the docs |
Declaration is separated from installation. Sourcing lib/packages.sh only
probes — it never installs, never touches the network, and never forks a
process. Every probe is a shell builtin (zsh's $commands hash, or [[ -e ]]
for the handful of things it can't see), so the whole file costs about 0.5ms and
an interactive shell can afford to run it on every startup. The install half
lives in lib/reconcile.sh and runs only from setup.sh and apply.
Without that split, an installer that only ever ran once is the only record of what a machine should have — so a tool added on the laptop is just quietly absent on the desktop until someone remembers to re-run it.
| File | Holds |
|---|---|
lib/packages.sh |
what should be installed. Declaration only; bash- and zsh-safe |
lib/catalog.sh |
what every tool is, and what to type. Read only by jarvis |
lib/managers.sh |
how each manager installs and upgrades. Bash- and zsh-safe |
lib/reconcile.sh |
how to install it, plus the symlinks and the nvim overlay |
lib/common.sh |
output helpers shared by setup.sh and apply |
lib/identity.sh |
SSH key, GitHub auth, git identity. Interactive; not used by apply |
bootstrap.sh |
Command Line Tools + clone, for a machine without this repo |
A few tools aren't one-line declarations and live in lib/reconcile.sh instead:
juliaup, because "installed" has two levels there — the tool, then a channel.
lib/catalog.sh is separate from lib/packages.sh for the same reason the
registry is separate from the installer. The registry is sourced on every shell
startup and is allowed to know nothing but "should this be installed?"; the
catalog is read only by jarvis and can be as long as the documentation needs
to be. It is also a strict superset — a third of it isn't a package at all —
so folding the two together would mean teaching the installer about tools it
can't install.
The default probe for a package is its own name. Two entries override it with an
absolute path, because macOS ships its own copy under /usr/bin and the bare
name would resolve on a machine that never got Homebrew's newer one:
brew_formula git "$SETUP_BREW_PREFIX/bin/git" # /usr/bin/git always exists
brew_formula jq "$SETUP_BREW_PREFIX/bin/jq" # macOS 26 ships jq 1.7.1-appleCasks get the same treatment by default — they rarely put anything on PATH, so
the probe is the Caskroom entry Homebrew creates for every installed cask.
apply # pull, then install anything declared but missing
apply -u # ...and upgrade what's already installed first
apply --skip-pull # leave git aloneapply -u walks every manager in lib/managers.sh — the same walk jarvis upgrade does, and the same code.
apply re-execs itself after the pull. Without that, a run that fetched a
change to apply, lib/packages.sh, or lib/reconcile.sh would go on using
the code that was on disk when it started, and the change wouldn't take effect
until the next run.
A failed pull — dirty tree, unreachable remote — is a warning, not an abort. It still reconciles against the checkout you have.
Shell config is tracked here and symlinked into place, so it syncs across
machines by git pull rather than by copy-paste.
| Path in repo | Symlinked to | Holds |
|---|---|---|
zsh/rc.zsh |
~/.config/zsh/rc.zsh |
PATH, exports, aliases, tool init, functions (rgv) |
bin/* |
~/.local/bin/* |
standalone scripts — apply, identity, mdget, netif, netscan, nomprofile |
ssh/setup.conf |
~/.ssh/config.d/setup.conf |
agent + keychain settings, pulled in by an Include |
setup.sh adds exactly two lines to ~/.zshrc and nothing else:
[[ -f "$HOME/.config/zsh/rc.zsh" ]] && source "$HOME/.config/zsh/rc.zsh"
[[ -f "$HOME/.zshrc.local" ]] && source "$HOME/.zshrc.local"Three deliberate choices there:
.zshrcitself isn't tracked. Oh My Zsh owns that file — its installer rewrites it on a fresh machine, and it carries the theme and plugin settings. Tracking it would mean vendoring OMZ's template and re-merging on every upstream change.- Sourced near the end, after OMZ has loaded. The
ezaaliases need to come after OMZ's ownlsalias, since the last definition wins. - Via
~/.config/zsh/, not the repo path..zshrcdoesn't need to know where you cloned this, so the same file works on a machine that keeps it somewhere else.
Symlinks rather than copies: edits to zsh/rc.zsh are live in the next shell,
and they show up in git status immediately instead of drifting out of sync
with the repo.
Anything machine-specific or secret goes here. It's untracked, and sourced
last so it can override anything rc.zsh set. Use it for API tokens, per-host
PATH entries, work-vs-personal differences — anything you don't want in a
GitHub repo.
curl -fsSL https://raw.githubusercontent.com/brianburrous-nominal/device-setup/main/bootstrap.sh | bashClone somewhere other than ~/dev/setup by setting SETUP_DIR first.
After that, syncing a shell change is git pull — nothing to re-run, the
symlink already points at the updated file. Syncing a package change is
apply, which does the pull for you.
The last step of setup.sh, and a command of its own afterwards:
identity # every step; each is a no-op if already doneIt generates an ed25519 key if there isn't one, loads it into the agent with the
passphrase in the macOS keychain, links this repo's ssh/setup.conf into
~/.ssh/config.d/, runs gh auth login, uploads the public key to GitHub, sets
user.name / user.email, and switches this repo's remote from HTTPS to SSH.
It runs last because it's the only interactive part — everything before it is
unattended, so the prompts are all in one place at the end rather than
scattered through a twenty-minute install. It is deliberately not part of
apply, whose contract is no sudo and no prompts.
The git identity is guessed from this repo's own last commit, not from the
GitHub API. That sounds circular and isn't: it's your repo, you wrote those
commits, and the machine has a full clone of them before this step runs. The
API is the worse source — plenty of accounts expose neither a name nor an
email, and the @users.noreply.github.com address that gh falls back to
would quietly start a second identity that matches nothing you've authored
before. The guess is only ever a default; you confirm it with return.
ssh/setup.conf is symlinked to ~/.ssh/config.d/setup.conf and pulled in by
an Include written to the first line of ~/.ssh/config. Both details are
load-bearing, and neither matches the intuition from shell config:
- An
Includeis evaluated inside whateverHostblock is in scope where it appears. Appended to a file ending in aHost myserverblock, it would apply tomyserverand nothing else —ssh -vvvlogs the rest asparse only. - ssh keeps the first value it obtains for a keyword and ignores every later one. That's the opposite of zsh, where the last definition of an alias wins.
So unlike ~/.zshrc.local, a host block lower down cannot override what
setup.conf sets. To override one of these for a specific host, put that block
above the Include line — there's a comment in the generated file saying so.
(IdentityFile is the exception: it accumulates rather than being overwritten,
so additional keys elsewhere are additive, with this one tried first.)
rm moves to the Trash. It's a function in rc.zsh wrapping
/usr/bin/trash, so it's recoverable in Finder with "Put Back". rm's flags are
dropped rather than forwarded — rm -rf build still works, because trash
needs neither -r (a directory moves whole) nor -f (it never prompts), and
would otherwise reject -rf as an unrecognized argument. Interactive shells
only: scripts, subprocesses, and command rm all still get the real rm. Use
command rm when you mean it — something too big for the Trash volume, or a
path that has to be gone now.
mdget <url> fetches a page as markdown via the r.jina.ai reader proxy —
nav, ads and script tags stripped. Good for reading docs in the terminal
(mdget url | bat -l md) and for piping a page into an LLM without 200KB of
markup around it. Anonymous requests are blocked by ASN on some networks, AT&T
included, so put a free key from https://jina.ai/api-dashboard/ in
~/.zshrc.local as export JINA_API_KEY=....
netif is an interactive viewer and editor for macOS network interfaces —
an fzf list of every device with its service, IPv4, mask, config method and link
state, and a detail view per interface. enter copies a field, ctrl-e edits
the rows marked +, ctrl-d puts a service back on DHCP. Edits go through
networksetup, so they're persistent and need sudo. Needs fzf and gum, both
already declared in lib/packages.sh.
The IPv4 rows show the stored config from networksetup, not the live address
off ifconfig, so an unplugged adapter still shows the static IP you gave it and
stays editable — a separate Active IPv4 row carries the live value, and a
service whose hardware is absent is listed with link state absent. Blanking the
router bounces the service through DHCP, the only way networksetup will clear a
router; -setmanual with an empty router argument keeps the old one.
It re-invokes itself to render the preview pane, resolving its own path with
${0:A} — which resolves symlinks, so it finds the repo copy rather than the
~/.local/bin symlink and keeps working under the link.
netscan lists every device on a local IPv4 subnet — address, hostname, MAC
and vendor — in about three seconds for a /24:
$ netscan
IPV4 HOSTNAME MAC VIA VENDOR
10.10.60.1 unifi 0c:ea:14:4e:20:c9 icmp Ubiquiti
10.10.60.115 vltest5 88:a2:9e:ac:75:4f icmp Raspberry Pi (Trading)
10.10.60.130 Alexs-iPhone.local f2:cf:24:a2:fc:00 arp (randomized)
10.10.60.253 NOM-Brian-Burrous.local d6:ae:ae:43:49:a1 self (randomized)
With no arguments it takes the subnet from the interface behind the default
route; -i en1 picks another one, or pass a range (netscan 192.168.4.0/24, or
a bare IP, which is read as /24). --json emits the same rows as objects for
scripting, -A skips the sweep and just prints the current ARP cache (instant),
-n skips hostname lookups.
The table styles itself for whoever is reading it. On a terminal: addresses
right-aligned so the octets line up, an underlined header that doubles as the
rule beneath itself, MACs and vendors held back in grey so the eye lands on the
address and hostname, and VIA coloured by what answered — green for a ping
reply, amber for ARP-only, magenta for this machine. A dash, and (randomized)
where a vendor would be, are dimmed: both mean "nothing to know", not a value.
Columns are measured from the data, and if the result is wider than the terminal
the vendor column goes first (the MAC beside it already implies the vendor) and
only then is the hostname truncated. Redirect it to a pipe or a file and all of
that drops away — plain text, every column in full, nothing to strip before
awk sees it. --no-color, NO_COLOR and TERM=dumb all force the plain form
on a terminal too, and COLUMNS overrides the detected width.
Discovery is a parallel ICMP sweep — 256 pings in flight by default — followed
by a read of the ARP cache. The sweep is really there to force ARP resolution:
a device that drops pings still has to answer the ARP who-has to stay on the
network, so the cache is a superset of the ping replies. That's what the VIA
column records, and arp there is a real signal — the host is up but filtering
ICMP, not absent. It needs no root and no scanner binary; ping, arp and
dig all ship with macOS.
Hostnames come from two sources, because on a typical network neither one knows
every device: reverse DNS first (whatever the router registered from DHCP), then
multicast DNS for the addresses unicast DNS didn't answer, which is where Macs,
phones, printers and Pis announce themselves. mDNS is queried by pointing dig
at 224.0.0.251:5353 rather than by parsing dns-sd, which has no
non-interactive mode and won't exit on the first answer. Our own address is
added by hand and named from scutil — nothing ARPs for or pings itself, and
nothing else on the network announces us.
Vendors are looked up in nmap's bundled IEEE OUI table. Nothing runs nmap;
only its data file is read, and the column is dropped if the file isn't there.
It beats arp-scan's ieee-oui.txt, whose snapshot is years old and missing
Raspberry Pi and half of Ubiquiti. A MAC with the locally-administered bit set
is labelled (randomized) rather than left blank — every current phone and
laptop rotates one per network, so there is no vendor to find.
add_path is how rc.zsh builds PATH: prepend, but only if the directory
exists and isn't already there. Plain PATH="$dir:$PATH" lines duplicate every
entry when the file is sourced twice, which a nested shell does routinely.
typeset -U path alongside it cleans duplicates that arrived in the inherited
environment, keeping the first occurrence so precedence survives.
The LazyVim starter on its own leaves a handful of :LazyHealth complaints.
setup.sh copies these four files over the top to clear them:
| File | Why |
|---|---|
lua/config/options.lua |
Disables the perl / python3 / ruby / node remote-plugin providers. Nothing here is a remote plugin, so they only ever surfaced as "install pynvim" warnings. |
lua/config/lazy.lua |
rocks.enabled = false. No plugin needs luarocks, so the hererocks bootstrap is skipped instead of sitting there half-installed and erroring. |
lua/plugins/conform.lua |
Drops LazyVim's fish → fish_indent mapping, rather than installing fish for a shell that isn't used. |
lua/plugins/catppuccin.lua |
auto_integrations = false. Catppuccin's plugin auto-detection calls vim.pack.get(), which on Neovim 0.12 creates an empty site/pack/core/opt directory as a side effect. Since :LazyHealth runs Lazy! load all, that fired on every health check and made both lazy.nvim and vim.pack warn. LazyVim already declares its catppuccin integrations explicitly, so nothing is lost. |
The overlay is reapplied on every run so this repo stays the source of truth.
Any file on disk that differs from the repo's copy is saved as
<name>.bak-<timestamp> before being replaced — your edits are never dropped
silently.
Use jupyter-lab, not jupyter lab. uv tool install only creates shims for
the entry points of the package you named, and the bare jupyter dispatcher
belongs to jupyter-core — a dependency — so it never gets one. Installing
jupyter-core separately would shim jupyter, but into its own venv with no
view of JupyterLab.
Installing a Nerd Font doesn't point your terminal at it, and that's the single most common reason LazyVim renders as a grid of empty boxes.
Set your terminal font to JetBrainsMono Nerd Font Mono (iTerm2: Settings → Profiles → Text → Font).
Pick the Mono variant: it constrains icon glyphs to a single cell, which is
what keeps the statusline and file tree aligned. JetBrainsMonoNL Nerd Font Mono is the same thing without programming ligatures, if you prefer to see
!= and -> as literal characters.
These are expected and not worth chasing:
- snacks.image — iTerm2 doesn't implement the kitty graphics protocol, so inline images can't work regardless of what's installed. Switch to Ghostty, WezTerm, or Kitty if you want them.
- mason — Go, PHP, Composer, Java, luarocks "not available". Only
matters if you install a mason package for one of those languages.
(Julia used to be on this list;
juliaupnow satisfies it.) - blink.cmp and which-key — purely informational. which-key's own output says not to report its overlap warnings.