A two-part installer that turns a fresh Windows + WSL machine into a cozy, opinionated-but-configurable shell: an Ubuntu distro running zsh + starship + modern CLI tools, surfaced through a themed Windows Terminal profile in a Cascadia Code Nerd Font. Every component is opt-in — pick the parts you want and skip the rest.
The Windows half (install.ps1) handles WSL, the distro, the font, and the terminal profile. The Linux half (comfort-shell-bootstrap.sh) runs inside the distro and does all the shell customization. They are designed to be runnable independently — the bootstrap is a standalone script you can scp onto any Ubuntu host and run by itself.
- Goals
- Prerequisites
- Usage
- What this configures
- Scripts
- install.ps1 (Windows side)
- comfort-shell-bootstrap.sh (Linux side)
- Customization
- Design decisions
- Known caveats
- Plenty of user options. Pick zsh or bash; opt in or out of starship, the modern CLI bundle (
fzf,rg,fd,bat,eza,zoxide), clipboard/openshims (pbcopy,pbpaste,open), Homebrew, and the git defaults — independently per flag, or in bulk with--minimal. Interactive runs prompt you for each toggle;--non-interactiveaccepts the defaults. - One command end-to-end.
.\install.ps1from a fresh Windows machine takes you to a fully configured, themed Windows Terminal profile — including the WSL platform install, distro install, font, and reboot dance. - Idempotent. Safe to re-run. Managed blocks in dotfiles are replaced in place, the WT fragment is rewritten with a deterministic GUID, and already-installed packages are skipped.
- Standalone halves.
comfort-shell-bootstrap.shdoes not requireinstall.ps1— drop it on any Ubuntu host (WSL or not) and it works.
- Windows 11 with Windows Terminal (
wt.exe) installed and on PATH. The installer hard-fails if it can't findwt.exe. wingetavailable (used only for the Nerd Font; the rest of the flow tolerates its absence).- Internet access for the WSL distro download, Homebrew, starship, and apt.
- The bootstrap requires Ubuntu (any supported LTS). Other distros are rejected at preflight.
Full setup (recommended):
.\install.ps1You'll be prompted at each step: pick a distro, accept the bootstrap plan inside WSL, reboot if WSL was just installed.
Unattended:
.\install.ps1 -NonInteractivePicks Ubuntu (latest LTS) as the distro and forwards --non-interactive to the bootstrap.
Re-target an existing distro:
.\install.ps1 -Distro Ubuntu-24.04Pass options to the bootstrap:
.\install.ps1 -BootstrapArgs '--shell=bash','--no-brew'Bootstrap only (e.g. inside an existing Ubuntu shell):
./comfort-shell-bootstrap.sh # interactive
./comfort-shell-bootstrap.sh --non-interactive # defaults, no prompts
./comfort-shell-bootstrap.sh --dry-run # preview without changes- WSL + an Ubuntu distro (installs both if missing, with a reboot + auto-resume in between).
- Default login shell set to zsh (or bash, if you opt in).
- starship prompt with a minimal config.
- ~12 apt packages for modern CLI tools:
fzf,ripgrep,fd-find,bat,jq, pluseza/btop/tmuxwhen available. - Homebrew with
gh,direnv,zoxideformulae layered on top. - Clipboard /
openshims in~/bin:pbcopy,pbpaste,open,xdg-open— bridged toclip.exe, PowerShellGet-Clipboard, andcmd /c start. - Managed dotfile blocks in
~/.zprofile+~/.zshrc(or~/.profile+~/.bashrc) for PATH, brew shellenv, prompt init, aliases, and (zsh) keybindings. - Git defaults:
init.defaultBranch=main,pull.rebase=false,core.autocrlf=input. - JetBrainsMono Nerd Font via winget.
- A Windows Terminal profile fragment named
Comfort Shell - <distro>, with a custom Catppuccin-ish dark scheme, the sunglasses icon, andwsl.exe -d <distro>as the command line.
| Script | Runs on | Role |
|---|---|---|
install.ps1 |
Windows (PowerShell 5.1 or 7) | Orchestrator: ensures WSL, picks/installs a distro, invokes the bootstrap inside it, installs the font, drops the WT profile. |
comfort-shell-bootstrap.sh |
Ubuntu (inside WSL or bare-metal) | Installer: configures the shell, prompt, tools, shims, Homebrew, git defaults, and dotfiles. |
The script runs five labeled steps and updates the console title with the current step:
| Step | What it does | Key behavior |
|---|---|---|
| 1. Ensuring WSL platform | wsl.exe --status probe. If WSL is missing, runs wsl.exe --install --no-distribution and exits via reboot. |
Registers a RunOnce auto-resume so the script picks up where it left off after the reboot. |
| 2. Choosing Ubuntu distro | Lists installed Ubuntu* distros from wsl -l -q. If none, offers the 4 supported LTS lines. |
Install-NewDistro retries up to 3 times (5s → 15s backoff) and gives actionable hints on failure (DNS, proxy, VPN). |
| 3. Running Comfort Shell bootstrap | Stages comfort-shell-bootstrap.sh to %TEMP%, copies it into the distro's $HOME, strips CRLFs, makes it executable, runs it. |
Uses Invoke-NativeConsole so the child sees a real TTY (needed for /dev/tty prompts in the bootstrap). |
| 4. Installing JetBrainsMono Nerd Font | winget install --id DEVCOM.JetBrainsMonoNerdFont --silent. |
Non-fatal: a missing winget or a "already installed" exit code is logged and skipped. |
| 5. Installing Windows Terminal profile | Writes a JSON fragment under %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\ComfortShell\comfort-shell-<slug>.fragment.json. |
Deterministic per-distro GUID (MD5 of comfort-shell:<distro>) so re-runs update in place and multiple distros coexist. Touches settings.json mtime to nudge WT's hot-reload. |
| Parameter | Default | Description |
|---|---|---|
-NonInteractive |
$false |
Skip all prompts: auto-pick the default distro (Ubuntu) and forward --non-interactive to the bootstrap. |
-Distro <name> |
(prompt) | Use a specific Ubuntu distro by name (Ubuntu, Ubuntu-24.04, Ubuntu-22.04, Ubuntu-20.04). Installs it if not present. Other distros are rejected. |
-BootstrapArgs <string[]> |
@() |
Extra arguments forwarded verbatim to comfort-shell-bootstrap.sh. Example: -BootstrapArgs '--shell=bash','--no-brew'. |
-ResumeEncodedArgs <base64> |
— | Internal. Set by the RunOnce launcher after a reboot; round-trips the original arguments as base64-encoded JSON. |
When wsl.exe --install requires a reboot, the script:
- Registers
HKCU\...\RunOnce\ComfortShellResumewith an encoded launcher that re-opens Windows Terminal and re-invokesinstall.ps1. - Round-trips
NonInteractive,Distro, andBootstrapArgsas base64-JSON via-ResumeEncodedArgsso the post-reboot run picks up the original intent. - Optionally reboots immediately (10-second cancellable countdown) or hands control back to the user.
On a successful run the RunOnce key is cleared at the top of the script (so an unrelated subsequent reboot doesn't re-fire the installer).
The script also calls Reset-TerminalInputMode after every native console invocation. wsl.exe enables Win32 Input Mode and focus reporting on the parent console and doesn't always restore them; without this, later Read-Host calls echo escape sequences like ^[[I and ^[[9;15;9;0;0;1_.
The bootstrap shows a plan + asks for confirmation, then runs N labeled steps (count depends on enabled modules). All steps are idempotent.
| Step | Function | What it does |
|---|---|---|
| Shell | install_shell |
Installs zsh (with zsh-autosuggestions + zsh-syntax-highlighting) and chsh's the user to it. In skel mode, edits /etc/adduser.conf DSHELL= instead. |
| Prompt | install_prompt |
Installs starship via the upstream install.sh. Writes ~/.config/starship.toml with a minimal config. |
| CLI tools | install_cli_tools |
apt-get install for the required list (build-essential, pkg-config, fzf, ripgrep, fd-find, bat, jq, unzip, curl, wget, git, ca-certificates) and optional list (eza, btop, tmux). Drops ~/bin/fd and ~/bin/bat shims when the Debian binary names differ. |
| CLI shims | install_cli_shims |
Writes ~/bin/pbcopy (→ clip.exe), ~/bin/pbpaste (→ Get-Clipboard | tr -d "\r"), ~/bin/open (→ cmd.exe /c start), ~/bin/xdg-open (→ open). Marked # comfort-shell shim so the script can recognize its own files. |
| Homebrew | install_homebrew |
Runs the upstream install.sh with NONINTERACTIVE=1 CI=1. Then brew install gh direnv zoxide. In skel mode this is deferred — see Skel mode. |
| Git defaults | install_git_defaults |
git config --global for init.defaultBranch=main, pull.rebase=false, core.autocrlf=input. Only sets values that aren't already set (or with --force). |
| Shell config | install_shell_config |
Replaces a # >>> comfort-shell >>> managed block in ~/.zprofile + ~/.zshrc (or ~/.profile + ~/.bashrc) with PATH, brew shellenv, prompt init, aliases (ls/ll/lt/cat/grep/find + git shortcuts), and zsh keybindings for Windows Terminal (Ctrl+Left/Right, Home/End, Ctrl+Backspace, etc.). |
Before the steps run, heal_wsl_issues cleans NUL bytes from /etc/wsl.conf (a known WSL corruption that produces Invalid key name warnings on every shell launch).
All output is teed to ~/.comfort-shell-install.log (using stdbuf -o0 tee when available, so partial lines flush in real time).
| Flag | Effect |
|---|---|
--non-interactive |
Accept all defaults; no prompts. |
--shell=zsh|bash |
Pick the default login shell. Default zsh. |
--no-brew |
Skip Homebrew (and its formulae). |
--no-shims |
Skip pbcopy/pbpaste/open/xdg-open. |
--no-prompt |
Skip starship. |
--no-tools |
Skip the apt CLI tools list. |
--minimal |
--no-brew --no-shims --no-tools. |
--force |
Overwrite existing configs (e.g. ~/.config/starship.toml) instead of preserving them. |
--dry-run |
Print what would happen; make no changes. |
--help / -h |
Print usage. |
When comfort-shell-bootstrap.sh runs as root (typical for pre-baking a distro image), it switches to skel mode:
HOMEis redirected to/etc/skelso dotfiles land in the template every new user is created from.- Default shell is set via
/etc/adduser.conf'sDSHELL=, notchsh. - starship installs to
/usr/local/bin(PATH-visible to every user). - Homebrew install is deferred: an installer script is dropped at
/usr/local/share/comfort-shell/install-brew.sh, and/etc/skel/.comfort-shell-first-runis created as a marker. The skel.zshrccontains a first-run hook that runs the brew installer once on the new user's first interactive shell, logs the attempt, and removes the marker on success.
This is what makes the Windows-side flow work on a freshly installed distro that hasn't been opened yet — the bootstrap runs as root via wsl -d <distro> -- bash, the user account is created on first launch from the Windows Terminal profile, and Homebrew installs itself on that first shell.
- Pick a shell. Default zsh; pass
--shell=bash(bootstrap) or-BootstrapArgs '--shell=bash'(install.ps1) to use bash. - Trim the install. Use any combination of
--no-*flags, or--minimalfor the smallest sensible footprint (shell + dotfiles + git defaults only). - Change the apt package lists. Edit
COMFORT_APT_REQUIREDandCOMFORT_APT_OPTIONALnear the top ofcomfort-shell-bootstrap.sh. - Change the prompt. Edit the
write_file "$cfg" 644 ...block ininstall_prompt(or setINSTALL_PROMPT="no"and bring your own). - Change the Terminal theme or font. Edit
Install-TerminalProfileininstall.ps1— the color scheme is inline, andfont.face/font.sizeare right there too. - Re-run safely. The script is idempotent. To force overwrites of preserved configs, run the bootstrap with
--force.
| Decision | Rationale |
|---|---|
| Two scripts instead of one | The bootstrap is genuinely useful on its own (any Ubuntu host). Splitting it means install.ps1 is a thin orchestrator and the actual shell setup is portable. |
| Auto-resume via RunOnce + base64-JSON | wsl --install always requires a reboot on a fresh machine. Manually re-running the script with the same arguments is friction; RunOnce + a base64-encoded payload restores the exact original invocation. |
Win32 Input Mode reset after every wsl.exe |
wsl.exe enables Win32 Input Mode and focus reporting on the parent console and doesn't restore them. Without Reset-TerminalInputMode, follow-up Read-Host prompts echo ^[[I etc. |
Invoke-NativeConsole (Start-Process -NoNewWindow -Wait) |
We need the child to see a real TTY so /dev/tty reads inside the bootstrap (interactive prompts) work. Plain & wsl.exe over a pipeline breaks this. |
| Deterministic per-distro WT profile GUID | MD5(comfort-shell:<distro>) gives stable GUIDs: re-runs update in place; different distros (Ubuntu vs Ubuntu-24.04) coexist as separate profiles. |
Touch settings.json mtime |
Windows Terminal re-scans Fragments\*.json when settings.json changes. Touching it makes the new profile appear without a WT restart. |
| Managed blocks in dotfiles | # >>> comfort-shell >>> / # <<< comfort-shell <<< markers let us own a slice of .zshrc without trampling user edits. |
| Skel mode for fresh distros | Lets the Windows-side flow run the bootstrap as root before any user exists. The user is created on first WT launch, inherits the dotfiles, and triggers the deferred brew install. |
stdbuf -o0 tee for logging |
Without it, tee buffers partial lines and interactive prompts can render in the middle of banners. |
Prompts on stderr (not /dev/tty) |
Routing them to stderr keeps prompts in tee'd order with banner output, instead of racing the buffered banner to the terminal. |
| Ubuntu-only | The bootstrap calls apt-get directly and relies on package names that are Debian-family specific. Other distros are rejected at preflight to fail loudly rather than half-install. |
| Area | Caveat |
|---|---|
| Windows Terminal required | The script hard-fails if wt.exe is not on PATH. A Comfort Shell without a WT profile would be half the experience, so we don't degrade. |
| WSL install requires a reboot | First-time WSL installs always reboot. The script registers RunOnce and offers to reboot for you; cancelling the countdown is fine — the auto-resume will fire after any subsequent logon. |
| Ubuntu only | Both halves bail on non-Ubuntu distros. Debian would mostly work but is untested; other families would not. |
wsl -l -q UTF-16 quirk |
wsl.exe --list --quiet emits UTF-16LE with embedded NUL bytes. The script strips NULs in Get-WslSupportedDistros; do the same in any new code that parses wsl -l -q. |
| Homebrew in CI/skel | Homebrew's installer probes for a real user. In skel mode we defer brew to the first interactive shell; the first user pays a one-time multi-minute cost on first login (logged to ~/.comfort-shell-install.log). |
Clipboard / open shims are best-effort |
pbpaste strips \r from Get-Clipboard output; complex clipboard payloads (images, multi-format) won't round-trip. open uses wslpath -w for files and falls back to passing the raw target for URLs. |
chsh requires logout |
After the first run, echo $SHELL won't reflect zsh until you start a fresh login shell. The Windows Terminal "Comfort Shell" profile starts a fresh shell, so just open it. |
fd / bat Debian names |
Debian ships these as fdfind and batcat. The bootstrap drops ~/bin/fd and ~/bin/bat shims; if you install fd/bat elsewhere later, delete the shims. |
| CRLF in the bootstrap | When the bootstrap is staged from Windows, the script sed -i 's/\r$//' inside WSL before executing. If you copy it manually, make sure your editor doesn't re-introduce CRLF. |
NUL bytes in /etc/wsl.conf |
A known WSL bug occasionally writes NUL bytes into /etc/wsl.conf. heal_wsl_issues strips them; you may need to wsl.exe --shutdown afterward for the cleaned config to take effect. |
Idempotency vs --force |
Re-runs preserve user edits to existing configs (e.g. ~/.config/starship.toml). Pass --force to overwrite. The managed dotfile blocks are always replaced wholesale — edits inside the markers are lost. |
This system was inspired by Scott Hanselman's WSL Comfort shell.