The tooling that makes a Debian or Ubuntu based host satisfy the contract in docs/host-setup.md, Proxmox and WSL included. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Linux host comes to satisfy it.
install-tools.shinstalls and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status. Its--sudo-timestampaction is the one thing it does that is about the host rather than a tool.upgrade-host.shupgrades the packages of the current release. Moving to the next release is a separate action behind its own flag.setup-github.shconfigures the SSH key, git, and commit signing. It checks both key registrations against what GitHub publishes.install-agents.shkeeps this user's coding-agent tools current through each tool's own updater, touching only the ones already installed. A tool a package manager owns is upgraded through that manager for that one package: npm, bun, Homebrew, or mise. apt is left toupgrade-host.sh, and Nix is declarative, so it is reported and not run. An npm directory this user cannot write is reported with its command, and nothing here uses sudo. The script installs a tool only when that tool is named. opencode keeps one managed copy,~/.opencode/bin/opencodefrom its own installer. Another copy found first on PATH is reported with the command that removes it, and is never removed here.install-skills.shdrives the hub's skills installer atscripts/skills_install.pyfrom this tree.install-safety-kit.shdrives the Claude Code safety kit installer athost-setup/agent-safety/claude/install.shfrom this tree. It skips a host where Claude Code is not installed for this user.install-global-rules.shwrites the host-wide agent rules to the global instruction file of each installed Codex or opencode, by runningglobal_rules.pyfrom this tree.
Each runs on its own, and each takes --help.
host-setup/linux/install-tools.sh # report
host-setup/linux/install-tools.sh --install
host-setup/linux/upgrade-host.sh --status
host-setup/linux/setup-github.sh --status
host-setup/linux/install-skills.sh --report
host-setup/linux/install-safety-kit.sh --report
host-setup/linux/install-global-rules.sh --report
host-setup/linux/install-agents.sh --upgradeEach script is LF with a shebang, and its executable bit is tracked in git. A fresh checkout therefore runs each without a bash prefix. That is the Linux form of "this will run", and tests/test_bootstrap.py asserts both.
A Debian or Ubuntu based host, identified from /etc/os-release. A distribution that is neither but declares ID_LIKE debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. install-tools.sh and upgrade-host.sh refuse anything else. setup-github.sh runs anywhere, and stops only when a missing prerequisite needs apt to install it.
Root or sudo. Nothing here elevates wholesale. Each script runs as the caller and puts sudo in front of only the commands that change the host. Every read and all file staging stay unprivileged. A run that is not root and finds no sudo refuses up front rather than failing partway.
A terminal, or --yes. A run with no terminal on standard input and no --yes refuses to change the host. A scheduled run therefore cannot hang on a prompt nobody answers. On upgrade-host.sh, --yes also keeps the installed configuration file on a packaging conflict. An unattended run has nobody to answer dpkg's prompt, and a replaced config is the harder half to notice afterwards.
curl is not a requirement, it is a managed prerequisite. A minimal image carries none, so every upstream read is guarded. An install run puts ca-certificates, curl, gnupg, and gpgv in place before the first tool.
The Windows registry has one source because winget tracks upstream. Here the distro package trails upstream on gh, on node, and on uv, so a tool comes from whichever source keeps up:
- The distro, for
git,python, and Ripgrep, where apt's own package is current enough. - An upstream apt repository, for
gh,node,docker,powershell, andop, and fordotnetas a fallback, where upstream publishes one. - A released binary into
/usr/local/bin, forjq,uv, andgit-restore-mtime, where upstream publishes no repository.
No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases. Every step is idempotent, so a keyring or sources file is written only when its content differs. A re-run repairs drift rather than assuming a clean host.
A keyring is proved, not trusted. Before a fetched signing key is installed, gpgv checks that it signs the repository's own InRelease metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check. A host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason.
Keyrings land in /etc/apt/keyrings, and sources land as deb822 files in /etc/apt/sources.list.d. A predecessor in the old location or the old one-line format is removed, so apt never reads the same repository twice. It goes once its replacement is written, so a key that fails its proof leaves the old source working.
/usr/local/bin precedes /usr/bin, which is why the distro's jq can stay installed and stay shadowed. The upstream binary wins without removing a package something else may depend on.
An rg that no package owns, found first on PATH, is a download that keeps answering ahead of the apt package. An install or upgrade therefore removes it, after a prompt, before apt installs. An op download is removed only at /usr/local/bin/op and only beside an installed package, as the op paragraph below says.
The hazard runs the other way too. A copy of jq, uv, or git-restore-mtime sitting earlier on PATH keeps answering after this script installs a newer one. That reads as an upgrade that did not take, so the report names such a shadow. --upgrade removes it after a prompt. --install removes it only when no managed copy exists yet, since removing a newer shadow would downgrade what PATH resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem. The remedy named there is the PATH order itself. The removal loops, since PATH can stack more than one shadow ahead of /usr/local/bin. A relative PATH entry is never trusted as a shadow at all.
A report changes nothing and reads the apt cache as it stands. An available version is therefore as current as the last apt update. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. docker is read from the CLI rather than from the docker-ce package. On a WSL distribution, Docker Desktop's integration is a working docker with no apt package behind it. Its target is stripped of the epoch and packaging revision for the same like-with-like reason.
unmanaged applies to gh, node, and op, the tools whose sources file the report checks. It means the tool is installed while that file is absent. The package then came from the distro, a vendor's one-line source, or a .deb installed by hand. The one thing the report must not say is that such a tool is current against whatever apt already has. The Windows report uses the same word for a different mechanism, a tool on PATH that winget knows no package for.
--json writes the same report as one JSON object, for a program to read instead of the table. It carries a schema number, the platform, a tools list, and a top-level notes list for what belongs to no tool. Each entry in tools carries tool, installed, available, source, mechanism, status, and that tool's own notes, and a version that was not read is null. Like source, mechanism names how this script manages the tool rather than where the installed copy came from. It is apt for a tool any apt upgrade moves, and binary for a released binary in /usr/local/bin that only this script moves. Inside a WSL distribution, docker reads docker-desktop, since this script leaves it to Docker Desktop. A change to what a field means raises schema.
An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run. An unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. A failed keyring, sources, or Microsoft feed write stops everything too, since the step after it removes a working source or copy.
Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing docker-ce. A native install would run a second engine beside Desktop's. --install and --upgrade therefore always skip it there and point at Docker Desktop's settings instead. The skip counts as success only where docker already answers, so a run cannot exit clean having found nothing working. On a native host, the conflicting packages Docker's own uninstall list names are removed first. Non-root use (usermod -aG docker) is left to the operator, as a group choice rather than a question of presence.
Installing node displaces distro packages. The upstream package carries npm itself and conflicts with the distro's npm and nodejs-doc. The script asks apt what it would remove and puts that list in front of the operator first. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time.
For dotnet, the distro feed is the default and Microsoft's feed is the fallback. The fallback is added only where the distro carries no SDK at all, because mixing the two feeds is what breaks a host. Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the fleet's SDK line and the newest line the feed carries, one package where the two are the same. The fleet's line is the default the hub's global.json baseline selects, and a repository may select another. A host holding only a newer line builds no repository on the default. The script names that line in DOTNET_FLEET_LINE rather than reading it from the hub's catalog/snippets/configs/global.json baseline, so it still runs fetched on its own. tests/test_install_tools.py fails while the two differ, so a move of that baseline edits both, and the Windows installer's major with them. A feed carrying no package for the fleet's line still gets the newest line, and the run carries on so a stand-up completes. A warning and the report both name the missing line. --optional adds every other line, for a host that builds against more than one. During a major move, that is where the old line comes from for a repository not yet moved.
powershell is optional and comes from Microsoft's feed, the same one dotnet falls back to. The distro never carries a package, so there is no distro version to prefer and no mixing concern to weigh. It joins the default selection only under --optional, and naming it on the command line selects it either way. Like dotnet, the feed carries amd64 only, so any other architecture is a named skip. It is not part of the fleet contract, which is why it stays optional. A repository that needs pwsh opts in rather than every host carrying it.
op is optional too and comes from 1Password's own apt repository, as the 1password-cli package. No distro carries it, so unmanaged here means a package installed without 1password.sources behind it, through the vendor's one-line 1password.list or by hand. It joins the default selection only under --optional, the same as powershell. The repository carries amd64, arm64, and armhf builds, and any other architecture is a named skip that leaves an existing copy alone. The vendor's optional debsig policy step is not taken, since the signed repository is the trust path. 1Password's manual install puts the binary in /usr/local/bin, where it would shadow the package's /usr/bin/op. So an install that adds the package removes that copy after a prompt, and so does an upgrade. A failed install therefore leaves the working copy. An install beside a package already there leaves it, as it leaves jq, rather than downgrade what PATH resolves to. An unowned op anywhere else can be a wrapper or a version manager's shim, so it is reported and left in place.
The sources file is 1password.sources, the name the 1Password desktop app's package also writes on each of its installs. That package rewrites the file to name its own keyring under /usr/share/keyrings, and the next run here that installs or upgrades op writes it back. A run finding op current leaves the file as the desktop app wrote it. Either way apt reads one source for the repository rather than two that disagree on Signed-By, which apt refuses. The vendor's documented one-line 1password.list is removed for the same reason, together with its keyring under /usr/share/keyrings. Both go once the new source is written, so a key that fails its proof leaves the vendor's source working. No package owns that keyring as a file, and the desktop app's install script writes it afresh each time along with the sources file. The package itself adds an onepassword-cli group and makes /usr/bin/op set-group-ID to it, which its own install script does rather than this one.
install-tools.sh --sudo-timestamp shares one sudo credential cache across the invoking user's terminals. Sudo's default is one cache per terminal, so a sudo -v answered in one terminal does nothing for a program started in another. The action installs no tool and reports on none. It sits here because a host stand-up already runs this script.
The drop-in is scoped to one user. It lands at /etc/sudoers.d/90-host-setup-sudo-timestamp and names the invoking user, so every other account keeps the per-terminal default. Under sudo the invoking user is $SUDO_USER rather than root, since widening root's cache leaves the caller prompted exactly as before. A run as root with no invoking user to name is refused.
Writing to /etc/sudoers.d is the one change here that can lock a host out. A parse error in any file sudo reads makes every sudo on that host fail, and the remedy then needs a root shell. So the whole set is proved to parse before anything is added to it. The new content then lands under a name sudo skips, since sudo ignores a file name holding a dot. It is proved again where sudo will read it, and only then is renamed over. A rename is atomic and a copy into place is not.
A re-run that would write the same bytes changes nothing. A drop-in carrying something else is replaced, and a timestamp option set in another file is named rather than merged into. Which of the two wins is the order sudo reads them in, not something this can decide.
Ubuntu 25.10 and later ship sudo-rs as the default sudo, and it carries no timestamp_type setting at all. Nothing there shares a cache across terminals. The action therefore asks each installed implementation's own visudo whether it parses the drop-in, rather than reading a version number. Where the active one does not, the run offers to point the sudo alternative at one that does. That changes which sudo every user on the host runs, so it is stated before the prompt, and update-alternatives --auto sudo puts it back. A host where no installed sudo parses the setting is refused, naming apt-get install sudo as the remedy.
The cache stays valid for 60 minutes, from SUDO_TIMESTAMP_TIMEOUT in the script. Removing the drop-in undoes the sharing. Where the run also switched the sudo alternative, update-alternatives --auto sudo is what undoes that half.
upgrade-host.sh splits the routine from the rare. --packages upgrades within the current release, and --release is its own action because the release upgrade is where hosts differ.
A release upgrade is refused where this script cannot carry it safely. Proxmox major upgrades are a documented procedure with their own preconditions, currently the Proxmox upgrade guide, and the refusal points there. A distribution that is neither Debian nor Ubuntu is refused too, since the sources rewrite below has no meaning there. Refusing is the point of running this rather than apt by hand.
One release at a time. Both distributions support exactly that, so a host two releases behind is upgraded by running this twice.
Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors. A third party repository may have no suite for the new release yet. It is therefore named and left alone, and what to do about it is the operator's call. The sources are backed up to /var/backups/upgrade-host first. A backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside debian.org, or one tracking stable rather than a codename, is refused. The refusal says to move such a host by editing its sources itself.
Ubuntu is carried by do-release-upgrade, which handles its own sources. Whether an LTS or every release is offered is the host's own policy in /etc/update-manager/release-upgrades, deliberately not decided here.
Preconditions run before the point of no return. Held packages, half-configured packages from a dpkg audit, and low free space on /var are each surfaced first. A release upgrade failing partway is the worst place to find any of them.
A WSL distribution runs the kernel Windows gives it. A restart there is wsl --shutdown from Windows followed by a relaunch, and the script says exactly that instead of suggesting reboot. Debian does not always write /var/run/reboot-required, so the newest kernel in /boot is also compared against uname -r. A host with no /boot, which a container and a WSL distribution both are, has no kernel of its own to compare.
Two steps cannot be automated, because they happen in a browser. The public key is registered once as an authentication key and again as a signing key. setup-github.sh --configure stops at each, prints the key, and says where to paste it. It then checks that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered. Sending someone to register a key that is already there is the wrong remedy.
--status is read-only end to end. Its SSH probes run in batch mode, so a passphrase prompt cannot hang an unattended run. No probe ever enrolls github.com's host key behind the reader's back. Enrolling is --configure's job, and the first enrollment is the one moment a substituted host key would be accepted for good. The offered key is therefore checked against the fingerprints GitHub publishes at api.github.com/meta before it is recorded. A check that cannot run is a refusal.
The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order. Reading the host first keeps a machine configured for somebody else from being quietly rewritten by a repeatable run.
The managed key is probed on its own, with the host's ssh config and agent excluded. A default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed. The run says so rather than ending in "Done" with the managed key registered nowhere.
Signing is proved end to end, by signing and verifying a commit in a throwaway repository. Reading the settings back cannot catch a wrong allowed_signers entry: that reads as correct and fails only when a signature is checked.
The key path settings are written in tilde form (~/.ssh/id_ed25519.pub), because git expands the tilde. The hosts configured by hand already hold that form, so writing it leaves an already configured host untouched.
--shared-checkout exists because safe.directory and core.sharedRepository are relaxations, not defaults. They are applied only for a path the caller names, and a host one account uses needs neither. * is accepted but called out as turning the ownership check off everywhere.
--gh-ssh-protocol exists because rewriting a working authentication setup is the operator's call. A plain run reports gh's github.com git protocol and --configure alone leaves it as found. --configure --gh-ssh-protocol 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.
The agent tools install into the user's home, so this script refuses root rather than reporting root's empty home as every tool absent. A scheduled run that starts as root reaches it through runuser -u <user> --. It appends ~/.local/bin and ~/.opencode/bin to PATH, since such a run carries none of the per-user directories these installers write to.
--upgrade runs each installed tool's own updater with standard input closed and a time bound. An updater that stops to ask therefore fails rather than hanging the run. --install takes tool names or --all, and with neither it refuses, so nothing is installed by default. herdr refuses to update from inside one of its own panes, so a run there skips that one step and still refreshes the integrations. docs/host-setup.md "Agent Tool Updates" states why the set is opt-in.
The sibling scripts are independently fetchable, and this one deliberately is not. It drives scripts/skills_install.py at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it inside the agent setup, the last step of a stand-up. Run on a host without one, it stops and names the tools step as its prerequisite. install-safety-kit.sh and install-global-rules.sh are exceptions for the same reason. The kit installer needs Python 3.11 or later and runs only where Claude Code is present. The rules writer runs on any Python 3 and asks for 3.11 only where Codex or opencode is present.
Neither this tooling nor its Windows sibling installs markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, PSScriptAnalyzer, check-jsonschema or ruff. That is a decision rather than a gap. Each runs as a pinned container image or through uvx, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are docker and uv, and both are already managed here. The Windows README states the same decision from its side.
bootstrap.sh sits beside bootstrap.ps1 at the top of host-setup/ rather than here. Standing up a host with no git and no checkout is one concern across two platforms, not a fifth member of this directory. It needs only curl, tar, and flock, fetches this repository, and runs the scripts here from that tree.
The comparison is tabulated once, in the Windows README, so the two columns cannot drift apart. The short version: this side needs three kinds of source where winget needs one, and carries the release upgrade Windows Update owns over there. It elevates per command through sudo where winget raises UAC per installer. It also manages git-restore-mtime, which the spec declares not applicable on Windows.
Read-only first, and nothing below changes the host.
host-setup/linux/install-tools.sh --help
host-setup/linux/install-tools.sh --list
host-setup/linux/install-tools.sh # report
host-setup/linux/upgrade-host.sh --status
host-setup/linux/setup-github.sh --status
host-setup/linux/install-skills.sh --reportThen the dry runs, which print what each action would run:
host-setup/linux/install-tools.sh --upgrade --dry-run
host-setup/linux/install-tools.sh --install --repo /path/to/repository --dry-run
host-setup/linux/install-tools.sh --sudo-timestamp --dry-run
host-setup/linux/upgrade-host.sh --release --dry-run
host-setup/linux/setup-github.sh --configure --dry-runTwo of those are guards rather than previews. --release --dry-run on a Proxmox host prints the refusal, not the commands. A docker --upgrade --dry-run inside a WSL distribution prints the skip. A [dry run] line from either means the guard sits in the wrong place.
--sudo-timestamp --dry-run is the one dry run that can ask for a password. Only root reads /etc/sudoers, and what is already set there is what the run reports on.
--repo adds the repository's install.linux entries to the report or action. Only apt package names are accepted. The script does not execute the declaration's remedy text. Reading the JSON needs jq, which the ordinary fleet install provides.
CI checks the scripts with shellcheck and shfmt. It runs both over every .sh file git ls-files returns, plus a tracked, extension-less file whose shebang names bash or sh. A local run uses the same mirror.gcr.io/koalaman/shellcheck:stable and mirror.gcr.io/mvdan/shfmt:latest containers. tests/test_bootstrap.py asserts that every tool the spec requires on Linux is one install-tools.sh can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it.