Fresh-machine setup scripts. Not project-specific, and not dotfiles –
those live in sym-lattice, a private repo that consumes this one as a
submodule. Each script bootstraps one tool
(or tightly related pair) onto a machine that doesn't have it yet, kept
per-tool and per-platform so each concern stays independently useful.
shell/– bash scripts for Linux and macOSpowershell/– PowerShell scripts for Windowsassets/– vendored data consumed by the scripts (currently the audio-eq correction files; provenance pinned inassets/audio-eq/README.md)
Same tool, same filename stem, one script per platform family. The only
unpaired scripts are the ones whose tool is single-platform by nature:
setup-wsl.ps1, setup-pwsh.ps1, and setup-windows-elevated.ps1
(Windows-only) and setup-vimr.sh (macOS-only).
shell/install-all.sh / powershell/install-all.ps1 run every
setup script in order (foundations first: git, uv), deliberately not
fail-fast: each script runs independently, failures are recorded, the
chain continues, and a pass/fail summary prints at the end (non-zero exit
if anything failed). A broken browser install can't block the Python
toolchain, and since every script is idempotent, re-running after a fix
only redoes the broken pieces. setup-wsl.ps1 is excluded from the chain
on purpose – it can require elevation and a reboot, which has no business
mid-way through an unattended run. setup-audio-eq.sh/.ps1 are likewise
excluded on purpose – hardware-scoped (headphone EQ for a specific DAC),
so they belong only on machines that actually have the hardware; run them
by hand there. setup-logi-options.sh/.ps1 too – hardware-scoped
(Logitech peripherals), and its Windows/macOS installers self-elevate,
which has no business prompting mid-chain. setup-game-streaming.sh/.ps1
likewise – purpose-scoped to the one box that streams to the Apple TV,
with a self-elevating installer (Windows) and a system service + firewall
surface (Linux) that don't belong on every machine.
FOOTPRINT.md enumerates every change each script makes per environment – packages, files, services, registry/system – with a verify one-liner per row. It's the review sheet after a fresh install, and rows update in the same commit as the script they describe.
setup-wsl.ps1(Windows only, no shell counterpart) – enables WSL and installs a distro, defaulting to the nameUbuntu, Canonical's rolling "current LTS" alias (pinning a version number would go stale);-Distrooverrides. If the WSL feature isn't enabled yet it needs an elevated session and typically a reboot. First-run unix account creation is interactive by design (--no-launch+ launch it yourself once). Not CI-covered: GitHub's Windows runners can't run WSL.setup-audio-eq.sh/setup-audio-eq.ps1– headphone EQ (Sennheiser HD 800 S, oratory1990 target) from the vendored AutoEq file inassets/audio-eq/, one artifact consumed by both platforms. Linux rides PipeWire's builtinparam_eq(a config drop, no packages, no sudo; PipeWire ≥ 1.2 enforced), with EasyEffects behind--easyeffectsfor Peace-style GUI tweaking (that mode needs a one-time manual export of the EasyEffects json from autoeq.app intoassets/audio-eq/– not vendored yet; the script prints the recipe). Windows manages aDevice:-scopedInclude:block in Equalizer APO's config.txt (elevated shell;-Devicepicks the device match, defaultFiiO), parking any Peace include to prevent double EQ;-Peaceflips management back to Peace,-Statusreports current state,-Remove/--removerestore the pre-script state exactly. Excluded from the chain on purpose – hardware-scoped, run by hand on machines with the DAC. Not CI-covered: needs live PipeWire/APO and the hardware. FiiO firmware is deliberately out of scope (Windows-only DFU tooling, and flashing has no business in idempotent provisioning).setup-uv.sh/setup-uv.ps1– installs uv via its official standalone installer, then a current default Python (uv python install --default, so barepython/python3resolve; the--defaultflag is experimental upstream, so the scripts fall back to a plain managed install if it ever changes –uv run/uv venv/uvxare identical either way).setup-git.sh/setup-git.ps1– installs git. apt/pacman on Linux, Xcode Command Line Tools on macOS, winget on Windows. uv itself doesn't need git (prebuilt downloads, no repo clone), but cloning sym-lattice or anything else does.setup-gh-cli.sh/setup-gh-cli.ps1– installs the GitHub CLI (gh). Install-only, deliberately:gh auth loginis an interactive OAuth/browser flow (or needs a pre-existing$GH_TOKEN) that a bootstrap script has no business automating. Linux apt path adds GitHub's own repo with a GPG-verified keyring per their official docs; Arch hasgithub-cliin its official repos.setup-cli-tools.sh/setup-cli-tools.ps1– a curated bundle of small zero-config tools: jq, ripgrep, fd, fzf, bat, zoxide. One list-driven script rather than a pair per tool, since they all share the same shape; adding one later is a one-line change. On Debian/Ubuntu the script aliasesfdfind→fdandbatcat→batin~/.local/bin(Debian package-name collisions), so dotfiles referencing the real names work identically across distros.setup-7zip.sh/setup-7zip.ps1– archive tooling. Linux gets 7-Zip's official CLI7zz: apt7zip(falling back top7zip-fullon distros that predate the official package), pacman7zip. macOS gets brew'ssevenzipplus the Keka cask for the Finder integration7zzcan't provide – one manual toggle after Keka's first launch (enable its Finder extension in Keka settings). Windows gets NanaZip, not classic 7-Zip: the same engine rebased on upstream releases, plus the Windows 11 top-level context menu upstream has never implemented; the CLI is theNanaZipCexecution alias, and if the fork ever stagnates the fallback is winget id7zip.7zip. The script warns when classic 7-Zip is also installed (its shell extension doubles the context menu until uninstalled).setup-nvim.sh/setup-nvim.ps1– Neovim. Linux installs the official release tarball into~/.local(distro packages, especially Debian stable, are often too old for the modern plugin ecosystem – same prebuilt-binary reasoning as uv); re-running updates to latest. Windows uses winget, which stays current. A foreign nvim already on PATH is left alone. Config is the dotfiles repo's job, not this one's.setup-nvim-tooling.sh/setup-nvim-tooling.ps1– everything the Neovim config (sym-latticedotfiles/vim/nvim) launches. LSP servers: lua-language-server, pyright, bash-language-server, the json/yaml servers (vscode-langservers-extracted, yaml-language-server), taplo (toml), marksman (markdown), roslyn-language-server (C#, a dotnet global tool from the Azure DevOps feed – needs .NET SDK ≥ 10; SDK 10 installs side-by-side when only older SDKs are present (winget on Windows, apt on Debian-family), and the server is skipped entirely when no dotnet is on PATH), and – where pwsh exists – PSScriptAnalyzer plus the PowerShell Editor Services bundle at a fixed per-OS path the config'spowershell_essetup relies on. Linters/formatters: shellcheck, shfmt, stylua, ruff, prettier (markdown). Markdown export (:MdExport): pandoc + typst – apt's pandoc predates--pdf-engine=typst(needs ≥ 3.1.2; Debian 12 ships 2.17) and apt has no typst, so both land as GitHub release binaries in~/.local; winget/pacman/brew package both. Treesitter parser toolchain: the tree-sitter CLI (≥ 0.26.1; winget on Windows, GitHub release binary elsewhere with an npm fallback for old-glibc distros) and a C compiler (MSVC Build Tools via vswhere check on Windows, gcc via apt/pacman elsewhere) – nvim 0.12 bundles only seven parsers and compiles the rest locally. The config deliberately uses no mason.nvim, so this script is where all of it is maintained. apt lacks lua-language-server/stylua → GitHub release binaries into~/.local; pacman and brew package everything.setup-oh-my-posh.sh/setup-oh-my-posh.ps1– oh-my-posh via its official installer (Unix) / winget (Windows). The prompt config in the dotfiles repo depends on this binary existing – without it a fresh machine comes up with a broken prompt. Fonts live insetup-fonts.setup-fonts.sh/setup-fonts.ps1– the preferred Nerd Font families, per-user: JetBrains Mono NF and Fira Code NF (editors, ligatures intact), Meslo LGM NF (terminal prompts – the oh-my-posh recommendation). Installs a curated 11-face subset rather than the ~186 size/spacing variants the release zips ship. Skips WSL (fonts render on the Windows host).setup-neovide.sh/setup-neovide.ps1– Neovide, the GUI frontend for Neovim (embeds whatever nvim is on PATH, sosetup-nvimis the real prerequisite). winget / brew cask / pacman, AppImage into~/.local/binelsewhere. WSL-skips. GUI-specific settings (font, animations) live in the nvim config gated onvim.g.neovide.setup-vimr.sh(macOS only – no Linux/Windows build exists, so no twin; the script self-skips elsewhere) – VimR, the macOS-native Neovim GUI, via brew cask. Reads the same deployed nvim config; Neovide stays the GUI everywhere else.setup-pwsh.ps1(Windows only, NOT in the chain – it's the rung below the chain) – PowerShell 7 (x64, MSI, machine-wide) and Windows Terminal (-SkipWindowsTerminalskips the latter – CI relies on it), written in Windows PowerShell 5.1 syntax because that's the only shell a clean machine ships and every other script here assumes pwsh 7. Windows bootstrap order from scratch:setup-pwsh.ps1(from the stock admin 5.1 shell) → open an elevated pwsh forsetup-windows-elevated.ps1(it enables sudo – the last elevated shell the bootstrap ever needs; thereaftersudocovers everything) →install-all.ps1from a normal shell.--source wingetand--architecture x64are both load-bearing (msstore's same-id MSIX lands per-user in WindowsApps; x86 pwsh loses System32 to WOW64 redirection – both hit live).setup-windows-elevated.ps1(Windows only, NOT in the chain) – the single elevated touchpoint: Windows sudo in inline mode (the deliberate alternative to Developer Mode – elevation stays explicit and per-command,sudo symlink-deploy dotfiles, instead of making symlinks globally unprivileged), the OpenSSH Client capability (ssh/ssh-add/ssh-keygen are not installed by default), the ssh-agent service, and NTFS long paths. Run once:sudo pwsh -NoProfile -File powershell\setup-windows-elevated.ps1(plainsudo <script>.ps1fails – sudo execs binaries, not scripts). Everything else in this repo stays user-scope by design. (setup-wsl.ps1stays separate – it can require a reboot.)setup-ssh-github.sh/setup-ssh-github.ps1– generates an ed25519 key when absent (passphrase-less, the unattended-bootstrap trade – regenerate withssh-keygen -pif wanted) and registers it with GitHub through an authenticatedgh(prints the key + URL when gh isn't ready). Runs everywhere including WSL – a WSL environment wants its own key. On Windows it also loads the key into the ssh-agent service and depends on the OpenSSH capability – both wired bysetup-windows-elevated.ps1– but never needs elevation itself.setup-zed.sh/setup-zed.ps1– the Zed editor. winget / brew cask / Zed's official installer script on Linux (downloaded to disk first, per house rule). WSL-skips. Zed self-updates in-app.setup-obsidian.sh/setup-obsidian.ps1– Obsidian, app only. winget on Windows, brew cask on macOS, the official GitHub-release.debon Debian-family (Obsidian has no apt repo),pacmanon Arch. The Linux script refuses to run under WSL (a Linux GUI app there is almost never what you want – use the Windows script on the host). Vault setup is personal data and deliberately lives in sym-lattice's onboarding, not in this public repo.setup-bitwarden.sh/setup-bitwarden.ps1– the Bitwarden desktop app. winget on Windows, brew cask on macOS, the official GitHub-release.debon Debian-family (no apt repo exists; the release list is filtered todesktop-v*tags because bitwarden/clients interleaves desktop/cli/browser releases, so/releases/latestlies),pacmanon Arch (extra repo, distro-maintained, lags a little).libsecret-1-0rides along on apt – undeclared by the .deb but required for credential storage over the Secret Service API. WSL-skips. The vault is account data, not bootstrap material.setup-logi-options.sh/setup-logi-options.ps1– the Logitech story, split by what each OS can actually do. Windows/macOS get Logi Options+ itself (winget with a fallback to Logitech's own mass-deploy URL +/quiet– the manifest hash goes stale chronically because Logitech overwrites the installer in place; brew cask on macOS, which runs the vendor installer under sudo and wants a reboot). Linux gets the device layer instead – Solaar + logiops via apt (Options+ has no Linux build). Per-APPLICATION mappings on Plasma Wayland need a compositor-side layer on top: InputActions (KWin plugin) or xremap (--features kde --mouse), neither packaged in Ubuntu yet, both deliberate manual installs – and NOT Solaar's own rules, whose per-process conditions are X11/GNOME-only. Excluded from the chain (hardware-scoped, self-elevating installers); CI covers only the packaged Linux path.setup-screenshots.sh/setup-screenshots.ps1– screenshot tooling, per platform: Greenshot on Windows (winget; the 1.3 line is maintained again – with a stop-process guard, since upgrades fail while it runs), Spectacle on Linux (Plasma-native, zero Wayland portal friction, magnifier + timestamp autosave + clipboard all built in; Flameshot was evaluated and passed over – apt's build predates its Wayland fix and packaged builds compile imgur out), Shottr on macOS (the Greenshot App Store port is Intel-era abandonware; Shottr matches the workflow and uploads to any S3-compatible storage, opt-in). Greenshot's bundled imgur plugin is effectively dead upstream – treat link-sharing as a separate concern on every platform. Settings are configure-once-then-symlink-adopt, not seeded.setup-memory-tools.sh/setup-memory-tools.ps1– memory pressure tooling. Windows gets Mem Reduct (winget; tray, manual clean, auto-clean at threshold – configure once in-app). Linux gets earlyoom (-m 10≈ act at 90% utilization on the MemAvailable metric, biased--prefertoward the browser) plus afree-memoryhelper in~/.local/binthat names the real offenders – deliberately NOT a drop_caches button, which cannot touch the anonymous heap where a browser leak lives. macOS is a documented manual step (Memory Meter 3 is paid App Store;mascannot acquire it;sudo purgeis the free flush).setup-game-streaming.sh/setup-game-streaming.ps1– the Moonlight streaming host, split by OS: Apollo on Windows (winget; the setup leans on its built-in virtual display – REVISIT when LizardByte/Sunshine PR #5114 lands integrated virtual display, at which point Sunshine buys security currency and config symmetry), Sunshine on Linux by necessity (Apollo has never shipped a Linux build; the official per-distro deb – flatpak/AppImage can't do KMS capture – with KWin screencast on Plasma Wayland, NVENC/HEVC; SDR only, HDR stays a Windows-boot thing). Both halves arm Wake-on-LAN (NIC power management on Windows – elevated runs only, otherwise the script prints the one-liner; a persistent NetworkManager property on Linux). Excluded from the chain on purpose – purpose- scoped, run by hand on the streaming box. Not CI-covered: vendor service installers. Config deploys from sym-lattice; pairing state stays per-machine.setup-vscode.sh/setup-vscode.ps1– VS Code. winget / brew cask / Microsoft's official apt repo. Arch getscode(the open-source build in official repos – Microsoft's proprietary build is AUR-only, which these scripts don't manage; marketplace/telemetry differ). WSL-skips: use Windows VS Code + Remote-WSL there. Both halves then sync extensions – a fixed-path contract like PES insetup-nvim-tooling: anything listed in~/.vscode/extensions.txt(deployed from dotfiles by symlink-manager) installs if missing; never uninstalls (prune by hand); no file = no-op, so the script stays usable standalone.setup-typora.sh/setup-typora.ps1– the markdown desktop editor. Typora via winget / brew cask / its official apt repo, plus the house theme set (Blackout Gamer, Chernobyl, Drake) into the platform themes dir. Non-apt Linux gets MarkText instead (AppImage into~/.local/bin) – Typora only ships a managed repo for deb, and an unmanaged tarball gets no updates. WSL-skips. Two manual steps by design: the paid license key (Typora > Preferences after first launch), and theme selection (Themes menu → Drake Vue3 – Typora persists it in profile.data alongside window state, which isn't safely pre-seedable).setup-zen-browser.sh/setup-zen-browser.ps1– Zen Browser. winget / brew cask; Linux has no repo at all, so the official release tarball goes into~/.localwith a.desktopentry. Tarball installs don't self-update, and the script skips whilezenis present – remove~/.local/opt/zenand re-run to update. WSL-skips.setup-librewolf.sh/setup-librewolf.ps1– LibreWolf. winget / the officially recommendedextrepopath on Debian-family / the official.dmgfrom Codeberg releases on macOS (the brew cask hard-disables 2026-09-01 because LibreWolf ships unsigned macOS builds, and no official tap exists; the.dmgdoesn't self-update and the script skips whileLibreWolf.appexists – delete it and re-run to update). AUR-only on Arch, so the script points at your AUR helper there rather than pretending. WSL-skips.setup-claude-desktop.sh/setup-claude-desktop.ps1– the Claude Desktop app. winget / brew cask / Anthropic's official signed apt repo (Linux support is beta, Debian-family only – the script self-skips elsewhere and points at the CLI). WSL-skips.setup-claude-code.sh/setup-claude-code.ps1– Claude Code (the CLI) via the official native installer, which auto-updates in the background (the winget/brew/apt alternatives don't by default). Works everywhere including WSL. Install-only: auth is an interactive browser login, same boundary assetup-gh-cli.
All scripts are safe to re-run (CI proves it: every tools job ends by
running the install-all chain against the machine it just provisioned, so
each chain script re-runs as already-installed on every run; the uv pair
and setup-logi-options get explicit re-runs on top).
update-all.sh / update-all.ps1 – one-command update sweep
across every package domain the setup scripts installed: system packages
(winget / apt / pacman / brew), uv tools, npm globals, the Roslyn dotnet
tool, and – Windows script only – PSScriptAnalyzer. Continue-on-error
with a summary, like install-all. Not part of the install chain – run it
when you want updates. Editor-owned updates stay in the editor:
vim.pack.update()
for nvim plugins (reviewed in its confirm buffer, pinned by the
committed lockfile) and :TSUpdate for treesitter parsers. Exempt a
winget package from the sweep with winget pin add <id>.
Deliberate, not accumulated:
- winget is primary. Everything in this repo that can ride winget
does – it's preinstalled, first-party, and
winget upgrade --all(viaupdate-all.ps1) pluswinget pinfor exceptions is a real update story. Its old reputation for gaps and clumsy updates is mostly pre-2024 vintage. - scoop is the designated gap-filler – user-scope, no UAC, exportable manifests; philosophically the closest thing Windows has to Homebrew. But it is not installed until a real gap appears: when a tool isn't on winget, check scoop first, and only then consider anything else.
- chocolatey is retired. Admin-heavy and its niche predates winget; having it, scoop, and winget on one machine is exactly the accumulated-by-convenience state this policy exists to prevent.
This repo originally bootstrapped pyenv/pyenv-win + direnv. That was
replaced wholesale in July 2026:
- Prebuilt, not compiled. uv ships standalone Python builds – installs take seconds and need zero build dependencies. pyenv compiled from source: minutes per install, plus a per-distro build-deps package list to maintain (with real drift, e.g. Ubuntu's ncurses dev-package rename).
- One tool, all platforms. pyenv and pyenv-win are separate codebases
with separate failure modes; essentially all of this repo's hard-won
workaround code (pyenv-win's WSH-blocked version list, shared-root ACL
lockdown,
pyenv initrehash interactions) was platform-split fallout. The full saga is preserved in this repo's git history, pre-pivot. uv runreplaces both activation and direnv.uv run <cmd>transparently uses the project's pinned interpreter/venv with no activation step, identically on Windows and Unix – which also erased the old asymmetry (direnv auto-activation on Unix vs. manualActivate.ps1on Windows) that this repo previously had to document around. direnv is no longer installed; per-directory env vars are out of scope here..python-versioncarries over. uv reads the same per-project pin files pyenv used, and auto-downloads the pinned version on firstuv runin the project.
No system/shared scope. The pyenv scripts grew a --system/-Scope System mode (shared root, permission lockdown so non-admin accounts could
use but not modify it). That was deliberately not ported: uv is per-user
by design, and with per-account setup costing seconds instead of a
compile, the shared install loses its motivation. For a multi-account
machine, run the script once per account – or note that any account with
uv on PATH auto-downloads a project's pinned Python on first uv run,
which is the better "new account inherits tooling" story anyway.
This is not about per-project .python-version / pyproject.toml –
that's handled per-repo. This is only about getting the tooling itself
onto a fresh machine. Updates afterward are uv's own job: uv self update
and uv python upgrade.
- CI (
.github/workflows/test.yml): the uv pair runs on nativeubuntu-latest/macos-latest/windows-latestplus a pinnedubuntu-22.04floor (GitHub has no rolling previous-LTS label – bump the pin when GitHub retires it), and ondebian:stable/debian:oldstable/kalilinux/kali-rolling/archlinux:latestcontainers – rolling aliases on purpose, so new releases roll into the matrix without anyone remembering to bump a version. Each leg verifiesuv run python --versionend-to-end; the native legs additionally re-run the script to prove idempotency. A dedicateduv-no-toolsjob starts from a container with no git at all (repo fetched by plain tarball) to provesetup-git.shgenuinely provides git andsetup-uv.shneeds nothing beyond curl.gh-clijobs cover all three platforms plus the apt-repo-with-GPG-key and pacman paths. Beyond the uv and gh-cli jobs: anexec-bitsgate fails fast when a tracked.shfile lacks its executable bit (twice a 100644 script killed every unix job at exit 126); consolidatedtools-*jobs (native unix/Windows plus debian/arch containers) run essentially every other script, verify the resulting binaries, and finish with an install-all orchestrator smoke test, plus an explicitsetup-logi-optionsidempotency re-run for the chain-excluded case; and akeepalivejob on scheduled/dispatch runs re-enables the workflow to reset GitHub's 60-day auto-disable timer for scheduled workflows. Everything also runs weekly (Monday mornings UTC) to catch drift in things this repo doesn't control; GitHub emails on failure for scheduled runs by default. - Installer hygiene: both uv installers are downloaded to a temp file
and executed from disk rather than piped directly into
sh/iex– same trust either way, but inspection-friendly and immune to executing a truncated stream. - macOS Xcode CLT:
setup-git.shon macOS triggers the Command Line Tools GUI prompt and can't wait on it – confirm the dialog and re-run. On CI runners git is preinstalled, so this path is only exercised on real fresh Macs. - Windows + uv#19622:
on some Windows machines (observed live on a hardened Windows 11 build,
even into a brand-new install dir),
uv python installexits 2 with "Missing expected target directory for Python minor version link" while the interpreter itself lands fine anduv runworks normally. The abort also leaves unstamped trampoline shims in~\.local\bin(uv writes a placeholder exe, then stamps the interpreter path into it; the error lands between those steps), which would shadow any realpythonlater on PATH with a broken one.setup-uv.ps1handles both: it trusts a functional check (uv run --no-project python --version) over the exit code, and removes any shim that doesn't run. Net effect on affected machines: uv-managed flows (uv run,uvx,uv venv) are fully functional, but the barepython/python3shims aren't available until the upstream bug is fixed.
MIT - see LICENSE.