Skip to content

Rationale

Why this setup makes the choices it does.

cmd.exe (the DOS-lineage shell) has no real scripting story: no functions worth the name, no structured data, awkward quoting. PowerShell 7 (pwsh) is the modern default on Windows — an object pipeline, real modules, cross-platform, and first-class init integration for every tool here (starship, zoxide, atuin, fzf, direnv). Windows PowerShell 5.1 still ships in-box but is frozen; we install and target pwsh 7. Everything in this repo — the $PROFILE fragments, aliases, functions, copilot-proxy — is written for pwsh.

cmd.exe isn't scripted against, but it isn't ignored either: some tools spawn it and muscle memory persists. The opt-in installClink toggle gives the DOS prompt a starship prompt plus z dir-jumping and Ctrl-R/Ctrl-T fzf, via Clink (cmd's PSReadLine analog). It reuses the same starship.toml and the XDG env already persisted to the User registry — so the config is shared; only the shell-activation "script" differs (Clink Lua vs the pwsh profile). This is prompt + navigation parity, not feature parity: atuin / direnv / tv and every pwsh function/module stay pwsh-only. pwsh remains the default and primary target; cmd is a scoped secondary. See Shell → cmd.exe via Clink.

(WSL is great, but it's a Linux environment — handled by the cross-platform dotfiles, not this repo. This repo is about a good native Windows shell. The one exception is Docker Desktop's backend: the installWsl toggle enables the WSL2 platform via wsl --install --no-distribution — plumbing for Docker's containers, not a Linux shell — self-elevating with one UAC prompt and a reboot. The optional installWslUbuntu toggle goes one step further: it registers an Ubuntu distro and triggers the cross-platform repo's own installer inside it (a frozen, non-interactive chezmoi init seeded from the Windows-side answers) — even then the Linux config is owned by that repo; this one is just the launcher.)

scoop for CLIs + winget for apps, not Chocolatey

scoop winget Chocolatey
Admin required no (user-scoped) for machine-scope apps usually yes
Best at CLI/dev tools GUI/Store apps broad legacy coverage
Reproducible scoop export/import winget export/import packages.config
Update model scoop update * winget upgrade --all choco upgrade all

scoop installs developer CLIs into a per-user directory with no UAC prompts, easy version pinning, and clean uninstalls — ideal for the ~20 shell tools here. winget is Microsoft's official manager and has the best catalog for GUI apps and first-party installers (VSCode, PowerToys, Steam, …).

Chocolatey was the long-time default and still has the broadest package coverage, but it's admin-heavy and less reproducible for a per-user dev setup. It stays a documented fallback for the rare package missing from both scoop and winget.

Runtimes: scoop natives, not mise on Windows

The macOS/Linux dotfiles use mise to manage language runtimes. On Windows we tried it and backed out — runtimes are installed natively via scoop (plus uv for Python) instead.

Why mise didn't hold up here:

  • Shims / activate don't reliably expose global tools. mise activate's pwsh prompt-hook and the shims dir both failed to put globally-pinned tools (bun, go) on PATH in practice — mise use -g bun "succeeded" yet bun wasn't found.
  • Arch gaps. On Windows-on-ARM, mise use -g bun records the version but ships no windows-arm64 bun binary, leaving a dead shim.
  • It fought the machine's existing node. A pre-existing nvm shadowed mise's node, so npm used the wrong one.

scoop shims live in ~/scoop/shims, which scoop persists to the user PATH (and profile.d/00_env.ps1 also prepends) — so node (nodejs-lts), bun, go, rustup, ruby are simply on PATH, no activation dance. Python is uv-managed (uv python install --default --preview), which also drops unversioned python/python3 into ~/.local/bin.

The tradeoff: no per-project runtime pinning (mise's headline feature). For a Windows dev box that's a fair trade for "the tools are actually on PATH." If you want per-project versions, scoop install mise and use it directly — nothing here depends on it. The full debugging trail lives in backlog/windows-arm64-managed-machine-rough-edges.md.

Terminal multiplexer: WezTerm (stable default) + herdr (native beta)

tmux and zellij are Unix-only — historically there were no native Windows builds, so they're absent here (you can still run them inside WSL). That's now changing: herdr ships an experimental native-Windows beta. The native options:

Option Splits / tabs Detach + persist Notes
WezTerm yes (panes/tabs, Lua wezterm.mux) yes (mux server + wezterm connect) native Windows; stable default
herdr yes (mouse-first panes/splits) yes (persistent sessions, detach/attach) native Windows, preview beta; closest to tmux/zellij — opt-in via installHerdr
Windows Terminal yes (panes/tabs) no great terminal, no session persistence
Alacritty no no fast, minimal; pair with a multiplexer
tmux / zellij Unix-only; use under WSL

WezTerm (wez.wezterm) is the stable default: a native Windows terminal with a built-in multiplexer — split panes, tabs, and a persistent mux server you can detach from and reconnect to (wezterm connect), plus scriptable layouts via its Lua wezterm.mux API. It isn't a 1:1 tmux (the session/persistence model differs), but it's the closest stable native experience.

herdr (opt-in, installHerdr) is the newer, closest-to-tmux/zellij answer: a mouse-first native multiplexer (ConPTY) with panes, splits, persistent sessions, and detach/attach. It has no scoop/winget manifest — it installs via herdr.dev's irm | iex script (Windows is preview-only), and its config is managed at ~/.config/herdr/config.toml (pwsh as the default shell). It's beta, so it stays off by default. Windows Terminal stays installed for its panes/tabs; Alacritty stays as the fast minimal option.

starship, not oh-my-posh

Both are prompt engines; running both is redundant. We use starship because the exact same starship.toml already drives the macOS and Linux dotfiles — one config, one behavior everywhere (starship init powershell). oh-my-posh is excellent and very PowerShell-native, but adopting it would mean a second, Windows-only prompt config to maintain for no functional gain here.

A parallel PowerShell tree, not a ported shell layer

The macOS/Linux dotfiles have a large POSIX shell/*.sh layer. Mechanically translating it to PowerShell would be a maintenance sink (two dialects of the same logic drifting apart). Instead the PowerShell config is written natively in profile.d/*.ps1 — idiomatic pwsh, only the behaviors shared, not the code.

Native copilot-proxy, not a bash shim

The copilot-proxy tool series is reimplemented as a real PowerShell module (Invoke-RestMethod, Start-Process, ConvertFrom-Json) rather than shelling out to the original bash. Only the Bun throttle shim (pure JS) is reused verbatim. See copilot-proxy.

run_onchange pwsh mergers for JSON config, not modify_ scripts

Some JSON config surfaces are owned by the tool at runtime — Claude Code rewrites ~/.claude/settings.json (plugins it installs, permissions you grant), VSCode/Cursor rewrite their settings.json. Deploying a static managed copy would clobber that live state, so these are merged, not overwritten. The macOS/Linux repo merges with a chezmoi modify_ script (a sh + jq filter: read the live file on stdin, emit the merged result on stdout). On Windows that doesn't hold up:

  • Interpreter routing is by the target's extension. chezmoi chooses a modify_/create_ script's interpreter from the managed file's extension via [interpreters.<ext>]. modify_settings.json targets settings.json.json, which maps to no interpreter. On Unix chezmoi falls back to the script's shebang (#!/bin/sh) + exec bit; Windows honors neither, so it can't run the script reliably — and you can't hand it a .ps1 extension, because that's fixed to the target name. Only [interpreters.ps1] = pwsh is wired up, and it only helps when the managed file itself is a .ps1.
  • Wrong toolchain. The parent filter is sh + jq — not guaranteed on a pwsh-first box, and rewriting it in pwsh still hits the routing wall above.

So each merge is a run_onchange_after_*.ps1.tmpl in .chezmoiscripts/ instead: a real .ps1 that routes cleanly through [interpreters.ps1] = pwsh, runs natively with no external deps, and recursively deep-merges a chezmoi-ignored overlay (editors/*.json, claude/settings-overlay.json, embedded via {{ include }}) into the live file — preserving every key the tool wrote. This is invariant #5 in AGENTS.md.

Tradeoff: a modify_ filter re-asserts on every apply; run_onchange only re-fires when its rendered content (the overlay) changes. If the tool later overwrites a managed key, it isn't restored until the overlay changes or you reset state (chezmoi state delete-bucket --bucket=entryState). The parent needs every-apply enforcement because CodeIsland / workmux keep rewriting ~/.claude/settings.json; Windows has no such contenders, so the lighter trigger is fine.

XDG paths, not %APPDATA%

Windows and XDG split directories on different axes — Windows by whether a folder roams (AppData\Roaming vs AppData\Local), XDG by purpose (config / data / state / cache) — so %APPDATA% is not a clean "Windows XDG". Rather than map onto it, profile.d/00_env.ps1 sets XDG_CONFIG_HOME and friends to Unix-style ~/.config / ~/.local/share paths. XDG-aware tools (starship, atuin, zoxide, yazi) then keep a tidy $HOME that mirrors the macOS/Linux dotfiles — one mental model, shared configs. Apps that ignore XDG and hard-code %APPDATA% (VSCode, Cursor, Alacritty) stay there and are managed at that path. Full mapping, plus the PATH mechanics, are in Shell & environment.