The tooling that makes a native Windows host satisfy the contract in docs/host-setup.md, through winget and PowerShell 7. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Windows host comes to satisfy it.
install-tools.ps1installs and upgrades the host tools. It reports what each one is installed at, where it came from, and which scope it sits in. Installing, upgrading or reinstallingdockeralso brings the WSL platform up to Docker Desktop's own floor where it is behind.upgrade-host.ps1upgrades the packageswingetmanages and updates the WSL platform. Its-CleanPathaction removes dead, duplicate and emptyPathentries.setup-github.ps1configures the SSH key, git, and commit signing.setup-wsl.ps1installs a WSL distribution and reports how Docker Desktop is integrated with the ones this host runs.install-agents.ps1keeps this user's coding-agent tools current through each tool's own updater, touching only the ones already installed. cswap is the exception to each tool's own updater, going throughuv tool upgradesince its updater cannot replace its own running launcher on Windows. The script installs a tool only when that tool is named. A tool winget owns is left toupgrade-host.ps1. It refuses an elevated session, since the tools live in the user's profile. codex and opencode document no Windows installer outside a package manager, so it installs neither and names the route their documentation gives.
Each runs on its own, and each takes -Help.
host-setup\windows\install-tools.ps1 # report
host-setup\windows\install-tools.ps1 -Install
host-setup\windows\upgrade-host.ps1 -Status
host-setup\windows\setup-github.ps1 -Status
host-setup\windows\setup-wsl.ps1 -Status
host-setup\windows\install-agents.ps1 -Upgradesetup-github.ps1 reports gh's github.com git protocol and -Configure alone leaves it as found, since rewriting a working authentication setup is the operator's call. -Configure -GhSshProtocol sets it to ssh. It warns and sets nothing where gh is not installed or not logged in. A protocol set before a login leaves a tokenless entry that gh reports as a failed login.
PowerShell 7 or later, which is pwsh rather than the powershell.exe that ships with Windows. Each script refuses an older one and prints winget install --id Microsoft.PowerShell --exact --source winget as the remedy. pwsh is deliberately not a managed tool: a host that cannot run these scripts cannot be repaired by them.
winget, which arrives with App Installer from the Microsoft Store.
Script execution. A git clone carries no mark of the web, so these run under the default RemoteSigned policy. A browser-downloaded zip does carry one, and is blocked until Unblock-File clears the mark. The .\ prefix is required when running a script from the current directory, exactly as it is for agent-safety/claude/install.ps1.
pwsh -File .\install-tools.ps1 answers the .\ rule and not the policy, which still applies to it. On a marked file under RemoteSigned it fails with a SecurityError naming the file as unsigned. The form that gets past the mark is pwsh -ExecutionPolicy Bypass -File .\install-tools.ps1, the same shape agent-safety/claude/README.md gives for the host-safety installer. -ExecutionPolicy Bypass sets only the process scope, which a machine- or user-level policy set by Group Policy still overrides. Prefer clearing the mark with Unblock-File over bypassing, since the bypass covers every script that the run touches and does not universally guarantee the run.
Every tool the contract names has a winget package, so nothing here carries a fallback. That is the whole difference from the Linux script. The Linux script needs three kinds of source because the distribution's package trails upstream on gh, on node and on uv. Where winget tracks upstream, the machinery that exists to work around a stale feed has nothing to do.
Ripgrep uses the upstream project's documented BurntSushi.ripgrep.MSVC package. It provides the rg command used for repository searches and rg --files discovery.
A tool that turns out to have no winget package is a finding to raise rather than a second source to add quietly. This is because the moment one tool comes from somewhere else, this directory stops being one program and becomes two.
Run these unelevated. No --scope is passed unless -Scope names one. So winget acts on the copy it finds, and an installer that needs administrator raises its own prompt. That is the path with the fewest failures, for three reasons that point the same way. Forcing user scope installs a second copy beside a working machine wide one rather than upgrading it. Some installers fail outright when launched from an already elevated process. A user scope install made from an elevated process lands in the administrator's profile rather than the caller's.
Nothing here elevates itself. A run that is already elevated says so and carries on, since that is a caution rather than a refusal.
Scope is measured, not assumed. winget list --scope user and --scope machine answer separately, so the report names where each tool actually sits. The report thereby catches the case worth catching, which is a tool installed in both scopes with one copy shadowing the other on PATH.
-Reinstall is the only action that removes anything, and it always asks first. An -Upgrade whose -Scope disagrees with the installed copy refuses and names it, rather than upgrading in place or adding a second copy.
A state that could not be read is never reported as an absence. Where winget does not answer what is installed, the tool reports unreadable rather than missing. In that case an install or upgrade skips it and collects it as a failure. Installing against a state nobody measured is how a second copy lands beside a first one that was there all along.
What provenance can and cannot be detected. Scope is solid, and so is a tool that answers on PATH while winget knows no package for it, which reports as unmanaged. Whether a package was installed by winget is not solid and is not claimed. For an exe or an msi, winget runs the vendor's own installer. The resulting uninstall entry is therefore identical whether winget invoked it or a person did. The one positive marker is the uninstall key winget writes for itself on a portable or archive package. The report names it where it is present and says nothing about where it is absent.
A newer dotnet SDK from outside the managed package is named rather than compared. The dotnet row reads the one winget package the fleet manages, Microsoft.DotNet.SDK.10. Visual Studio lands its own SDKs, previews included, in the same directory, and so does another winget SDK package such as the preview one. The dotnet status still compares the managed package alone, since that is the copy an upgrade acts on. Where dotnet --list-sdks holds a newer SDK, the report names it in a note, because dotnet runs it wherever no global.json pins another.
Some applications update themselves and never rewrite the version recorded at install time. winget reports them as permanently behind, and its manifest marks them as requiring explicit targeting so an upgrade of everything leaves them alone.
These are listed apart, left alone, and printed with no command beside them. Offering one invites a full reinstall over a working, already current copy in pursuit of a number that will not move. MSYS2 is the worked example. It upgrades through pacman from inside the msys shell, and the version winget shows is the installer's rather than the one it runs. install-tools.ps1 reports such a tool as self-updating rather than outdated for the same reason.
install-tools.ps1 and upgrade-host.ps1 warn when the machine and user Path together expand past 3700 characters. install-tools.ps1 checks in its report and after an apply. upgrade-host.ps1 checks in -Status and after an upgrade. Neither upgrade nor install edits Path. upgrade-host.ps1 -CleanPath is the one action that does, and it removes only the entries "Cleaning Path" below names. Which live entry to drop beyond those stays the operator's call.
The failure this warns ahead of reports nothing itself. Past about 4095 expanded characters, Explorer's rebuild of its environment keeps the machine entries and drops the user half of Path. Whatever Explorer launches inherits that. So an editor opened from the Start menu stops finding per-user tools, while a new Windows Terminal tab still finds them.
3700 is a margin chosen below that limit. The check reads the length alone, without asking Explorer what it did. No run of these scripts has pushed a Path over the limit, so the limit is as reported rather than as tested here.
The usual route there is winget. The winget issue tracker explains that it adds one long Path entry per portable package when it cannot create symlinks. That happens when Developer Mode is off and it runs unelevated. Otherwise it links them through a single directory.
To recover, remove entries, then sign out and back in. Shortening an entry with a variable such as %LOCALAPPDATA% shrinks the stored value and leaves the expanded length the same.
upgrade-host.ps1 -CleanPath removes the entries below from Path, and -Status lists them without removing anything.
- A duplicate. Entries are compared after expanding variables, folding case, removing surrounding quotes, and trimming a trailing
\or\.. The first in effective order is kept, machine before user. So a user entry repeating a machine one is removed from the user half. - A dead entry, whose directory does not exist, or which names a file rather than a directory. An entry is dead only when its drive or share root answers and the directory does not. An entry under an unmounted drive or an offline share is listed and kept. So is one holding a variable that does not expand, and one that is not a full path. So too is one holding a variable whose stored value changed after the terminal started, since this process would expand it to the old directory. A state that could not be read is never reported as an absence.
- An empty entry, from a doubled or trailing
;.
It reads and writes each half's raw registry value, keeping its REG_EXPAND_SZ kind and every kept entry's unexpanded text and place. Writing the expanded Path back would freeze %USERPROFILE% and its like into literal paths. It asks first unless -Yes was given, and -DryRun prints the value it would write instead. After the prompt it reads both halves again and writes neither where either changed, so it never reverts a change an install made meanwhile. Where the second write fails after the first succeeded, it says which half was written and still broadcasts the change. Before each write it prints the previous value, so a cleanup can be undone by hand. After writing it broadcasts the environment change, so a program started afterwards sees the new Path without a sign-out.
Elevation is detected and never required. Unelevated, it cleans the user half, lists the machine findings, and exits successfully. It names an elevated run, such as the same command under gsudo, as the way to remove them. Elevated, it cleans both halves. It raises no UAC prompt of its own and refuses no run for its elevation state, which keeps "Elevation and Scope" above.
An elevation as another account opens that account's user Path rather than the signed-in user's, the same hazard "Elevation and Scope" names for a user-scope install. So an elevated run writes the user half only when it runs as the owner of its session's Explorer. Otherwise it cleans the machine half and lists the user findings with the reason. gsudo elevates the same user by default, so its runs clean both halves.
It does not find an entry fully shadowed by earlier ones. Removing one can drop a DLL or a script that something else loads from it. It also does not ask Explorer whether it dropped the user half.
Neither this tooling nor its Linux sibling installs markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, PSScriptAnalyzer, check-jsonschema or ruff. That is a decision rather than a gap.
Each of those runs as a pinned container image or through uvx, which is what keeps a local run and CI the same check. The image tag fixes the version. Installing native copies through winget would put a second, unpinned version of each on the host. A local run would then differ from CI, which is the exact property the pinned images exist to guarantee. The only host requirements any of it creates are docker and uv, and both are already in the registry.
bootstrap.ps1 is a fifth script, and the odd one out. It sits beside bootstrap.sh at the top of host-setup/ rather than here. This is because it is the same concern as that file rather than a fifth member of this registry. It exists to stand up a host that has no git and no checkout, the same problem bootstrap.sh solves on Linux.
An unverified loader is worse than none, and that is why this one does more than fetch a tarball. It runs under Windows PowerShell 5.1, the one shell a fresh Windows host guarantees. It hands off to pwsh only once it has found or installed it through winget. That is because every script in this directory refuses to run under anything older. It pins TLS 1.2 itself rather than assume a fresh console's default reaches GitHub. And it checks a fetched tree for the marker it wrote before removing anything under -Dir, the same rule bootstrap.sh follows on Linux. None of that verifies the tooling it goes on to run, which stays exactly as unverified against a genuinely fresh host as it always was. It verifies the one step earlier this loader adds, standing up the interpreter everything past it depends on.
pwsh -NoProfile -File ..\bootstrap.ps1 -Help
..\bootstrap.ps1 -Report -DryRunmenu.ps1 sits beside menu.sh at the top of host-setup/ for the same reason bootstrap.ps1 sits beside bootstrap.sh. That reason is that menu.ps1 fronts this registry's scripts rather than joining it. host-setup/README.md "The Human Menu" carries menu.ps1's full contract. menu.ps1 shares bootstrap.ps1's PowerShell 5.1 to pwsh handoff, and it adds entries with no Linux peer, since each is a Windows-side concern: setup-wsl.ps1 -Status and upgrade-host.ps1 -CleanPath.
pwsh -NoProfile -File ..\menu.ps1 -Help
..\menu.ps1 -DryRunsetup-wsl.ps1 reports the Docker Desktop integration and never writes it. Docker holds those settings in memory and rewrites its settings file from that copy while it runs. An edit made here is therefore discarded at Docker's next save. An edit made while it is stopped is likewise undone by the next start. Change it in Docker Desktop under Settings, Resources, WSL integration.
upgrade-host.ps1 -Wsl refuses while Docker Desktop is running, because Docker holds the WSL service open and the update then fails part way rather than declining. Quit Docker from its tray icon first, since pausing it is not enough. The refusal fires under -DryRun too, so a dry run reports the truth rather than printing a command that would not have worked.
Docker's own docker-desktop distribution is excluded from every distribution listing, since it is Docker's rather than one an operator installed.
install-tools.ps1 checks, before installing or upgrading docker, that wsl.exe is present and reports a WSL version at or above 2.1.5. That is Docker Desktop's own documented floor for the platform it depends on. Where wsl.exe is missing entirely it skips docker and names the remedy (wsl --install --no-distribution), the one case this still leaves to a person. Standing up WSL from nothing is a different action from bringing an existing platform current. That is the same reasoning that keeps a distribution install in setup-wsl.ps1 rather than folded in here. The same check surfaces as a note under -Report, read-only, before a caller ever runs -Install.
Where WSL is present but behind the floor, or docker itself is about to change version, install-tools.ps1 drives the fix itself. It does so rather than only naming it, asking first unless -Yes was given. Where Docker Desktop is running, it stops it through its own CLI (docker desktop stop, not the tray icon) rather than leave it up through the change. It runs wsl --update where the floor is not met. Once the docker package itself is also settled, it shuts every WSL distribution down with wsl --shutdown and starts Docker Desktop again (docker desktop start). A run that finds Docker Desktop already stopped has nothing to restore, so it stops there too: neither the shutdown nor the restart runs.
This moved a boundary the tool used to hold. WSL used to be read-only here, and upgrade-host.ps1 -Wsl was the only thing that ever ran wsl --update. The reasoning was that an update restarts every distribution and that does not belong as a side effect of installing a different tool. Two things moved it. Docker Desktop's own per-distro WSL integration goes stale across an engine bump often enough to have a name on Docker's own tracker (WSL integration with distro '<name>' unexpectedly stopped). The dialog's own "Restart the WSL integration" button does not clear it. This is because it retries the proxy inside the distro that is already running against the same stale state. A full stop, wsl --shutdown, start cycle does clear it. And once a docker version bump already needed that exact stop-then-restart window to recover its own integration, updating WSL in that window costs nothing extra. Updating WSL in that window also skips the upgrade-host.ps1 -Wsl refusal a Docker Desktop already running would otherwise walk straight into.
wsl --update installs through its own MSI and raises a UAC prompt when this pwsh is not itself elevated. That is the state the rest of this tooling deliberately stays in. Nothing can answer that prompt unattended. So where the process is neither elevated nor at an interactive console, install-tools.ps1 refuses to start the update rather than hang. In that case it restores Docker Desktop to how it found it first.
| Linux | Windows | Why |
|---|---|---|
upgrade-host.sh --release moves to the next distribution release |
no peer | Windows Update owns a feature update, and an action pretending to drive one is the one thing this must not carry |
install-tools.sh carries several functions per tool |
install-tools.ps1 carries one registry record per tool |
Every source is winget, so the per-tool variation those functions exist for does not arise |
git-restore-mtime is managed |
not managed | The spec declares it not applicable on Windows, since it serves a Linux deploy path |
install-tools.sh refuses docker entirely inside a WSL distribution |
install-tools.ps1 checks the WSL platform version before installing docker |
A WSL distribution takes docker only from Docker Desktop's own WSL integration, and Windows needs WSL2 present for Docker Desktop's own backend |
sudo re-runs a command as root |
nothing elevates | winget raises UAC per installer, which is the path with the fewest failures |
install-tools.sh --sudo-timestamp shares one sudo credential cache across a user's terminals |
no peer | Nothing here elevates on Windows, so there is no cached credential to share |
unmanaged means the upstream repository is unconfigured |
unmanaged means the tool is on PATH and winget knows no package for it |
The same question, by a different mechanism |
--json reports mechanism as apt, binary, or docker-desktop |
-Json reports mechanism as winget, source as the winget package id, and adds each tool's scope list |
winget is the one package manager here, and scope is the one column Linux has no peer for. The report's shape is otherwise the one the Linux README describes, and a multiple row's installed is null, its note naming the versions |
python-is-python3 supplies the python name, under --optional |
install-tools.ps1 supplies the python3 name itself, always |
Debian packages the alias and Windows does not, so the one platform installs a package and the other writes the file. This is the only action here that deletes something winget did not install, so it is narrow by construction. It removes an app execution alias only where the reparse tag and the target package both say it is the Microsoft Store placeholder. It reports rather than removes anything else |
credential.helper cache --timeout=3600 |
credential.helper manager, and only where unset |
Git Credential Manager ships with Git for Windows |
ssh-agent is a socket, started per shell |
ssh-agent is a Windows service, reported and not started |
Starting it needs administrator, and nothing here elevates |
| no WSL script | setup-wsl.ps1 |
WSL is a Windows-side concern with no Linux-side peer |
The scripts here share no file with each other. The roughly thirty duplicated lines of logging, the dry-run wrapper and the confirmation prompt are identical rather than merely similar. That is the same rule the Linux scripts follow, for the same reason, and it is stated in host-setup/README.md. Do not factor them out. install-tools.ps1 and upgrade-host.ps1 each carry the Path length functions under the same rule, and a test holds the two copies equal.
Read-only first, and nothing below changes the host.
pwsh -NoProfile -File host-setup\windows\install-tools.ps1 -Help
host-setup\windows\install-tools.ps1 -List
host-setup\windows\install-tools.ps1 -Report
host-setup\windows\upgrade-host.ps1 -Status
host-setup\windows\setup-github.ps1 -Status
host-setup\windows\setup-wsl.ps1 -Status
host-setup\bootstrap.ps1 -Report -DryRunThen the dry runs, which print what each action would do:
host-setup\windows\install-tools.ps1 -Upgrade -DryRun
host-setup\windows\install-tools.ps1 -Install -Repo C:\path\to\repository -DryRun
host-setup\windows\upgrade-host.ps1 -Packages -DryRun
host-setup\windows\upgrade-host.ps1 -CleanPath -DryRun
host-setup\windows\setup-github.ps1 -Configure -DryRun
host-setup\windows\setup-wsl.ps1 -Install Debian -DryRun-Repo adds the repository's install.windows entries to the report or action. Only winget package IDs are accepted. The script does not execute the declaration's remedy text.
Two of those are guards rather than previews, and each prints a refusal rather than a command. They are upgrade-host.ps1 -Wsl -DryRun on a host running Docker Desktop, and an -Upgrade whose -Scope disagrees with the installed copy. A [dry run] line from either means the guard sits in the wrong place.
The scripts are checked by PSScriptAnalyzer, which runs in CI as the peer of the shellcheck step and locally through the invocation in GOVERNANCE.md. tests/test_bootstrap.py asserts that every tool the spec requires is one this registry carries, and that no script here opens with a shebang.