This morning I double-clicked a desktop icon and watched twenty-three tools roll forward to latest stable while my coffee cooled — mise and its twelve tools, Rust, uv with six Pythons, the Cloud SDK, both npm prefixes, the whole pip user set, and a dozen self-updating CLIs. No sudo prompt, no half-finished upgrade, no wondering what got left behind — everything lives in userspace, and one button owns all of it.


The 30-second mental model

The system has four layers, and each layer does exactly one job:

Layer Lives at Job
Button ~/Desktop/Update-Tools.desktop Opens a terminal and runs the script
Dispatcher ~/Desktop/Update-Tools.sh Loops the registry, collects failures, prints a summary
Registry ~/.local/share/update-userspace-tools/tools.d/ 23 TOML files, one per tool or tool set
Updaters lib-updater.sh plus native tools Each entry updates the way its ecosystem expects

Nothing here replaces a package manager — the button calls seventeen of them. mise updates mise tools, rustup updates Rust, uv updates Python, and so on down the list. The registry only answers two questions: what exists, and how do you update it.


The registry is the whole trick

Every entry is a few lines of TOML: a kind plus parameters. Generalizing GitHub-binary support once meant every future binary is a five-line file instead of a new script:

kind = "github-release"
repo = "terrastruct/d2"
asset = "d2-{tag}-linux-amd64.tar.gz"
checksums = "sums:SHA256SUMS"
bin = "d2-{tag}/bin/d2"
dest = "~/.local/bin/d2"
version_cmd = "d2 --version"

Downloads are SHA256-verified against the release’s own checksums before anything lands in ~/.local/bin, and the installed version is compared against the latest tag first, so current tools are a cheap no-op instead of a reinstall. The full kind table, all seventeen of them:

Kind Members How it updates
Managed sets mise-all, rustup, bun, uv-self, uv-tools, gcloud, cloudflared, npm-global, dfxvm Each manager’s own update command
github-release conftest, d2, llmfit Latest release, SHA256-verified, compared by version
cargo near, ic-wasm cargo install (no-op when current)
command opencode, brev, agy, ghost Each CLI’s self-update subcommand
npm-prefix HubSpot CLI Global install into its own prefix
npm-binary postman Swap with the official npm-published binary
uv-python 3.9 through 3.14 Latest patch of each minor
pip-user 75 packages Mass upgrade, newest pins kept
playwright chromium + firefox Browser builds matching the libs

Agents register their own tools

The part I’m proudest of isn’t the button — it’s the rule that keeps it complete. Any agent working on this machine follows one standing instruction: install with mise use -g when possible (covered automatically by the mise-all entry), otherwise add one registry entry with tool-register and verify it. Registration is a single command:

$ tool-register --kind cargo --name ripgrep --package ripgrep \
    --version-cmd 'rg --version'

The helper validates the kind, keeps write targets under $HOME, and refuses to finish until the version command reports. New tools join the next click with zero script edits — the dispatcher only loops files, so the registry, not the script, is the source of truth.


What one click actually updated

Yesterday’s full run, all green, twenty-three for twenty-three:

Tool Before After
mise + 7 tools 2026.9.1 / mixed 2026.9.12 / current
wrangler 4.132.0 4.136.1
pnpm 12.3.4 12.5.1
gh 2.100.0 2.101.0
uv + ruff 0.12.10 / 0.16.6 0.12.18 / 0.16.8
Python 3.9 – 3.12 plus 3.13.15, 3.14.7
gcloud 584.0.0 586.0.0
conftest 0.69.0 0.70.1
d2 v0.8.2 v0.9.0
llmfit 1.1.4 1.1.16
postman 1.34.5 1.62.0
near-cli-rs 0.30.0 0.30.1
agy 1.2.0 1.2.8
ghost v0.27.0 v0.27.1
HubSpot CLI 8.5.0 8.15.0
playwright + browsers 1.62.0 1.63.0 + fresh builds
pip set mixed boto3, pandas, jobspy current

Already current and correctly skipped: Rust 1.98.1, Bun 1.4.2, dfx 0.32.0, opencode, brev, cloudflared, OPA, clojure-lsp, and the Clojure CLI. The run also fixed two tools that were already broken: gsutil and bq crashed on a pyOpenSSL mismatch inside the Cloud SDK’s bundled Python, so the gcloud entry now re-aligns that pair on every run.


The warning I chose to keep

mise holds back releases younger than 24 hours, and two of mine were sitting in the waiting room while I worked: glab 1.119.0 and wrangler 4.136.3, each a few hours short of eligible. The holdback is a feature — day-zero releases are exactly the ones that get yanked — but the all-caps warning spam on every invocation was not. So the config now runs quiet globally: the holdback still silently skips fresh releases, errors and version output still print, and the button picks up the held-back tools on the next day’s click.


What I deliberately left alone

Left alone Why
System node, Go, Java, Tofu Pacman owns them; the button stays in userspace
jank Only a rolling pre-release exists — nothing stable to pin
doctl, racket Orphaned mise installs, not in use
Old Playwright builds Pinned projects may still need them; disk is cheap

One button, twenty-three tools, zero sudo — and the next tool registers itself.