One Desktop Button That Updates Every Tool I Own
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.