Upgrades — Explicit, Opt-in Tool Refresh¶
How to actually move installed tools forward on a machine managed by this repo, and why
chezmoi applydeliberately will not do that for you.See also:
tool-managers.md— the install-side companion to this doc. It maps every tool in the repo to its install mechanism (which manager owns it, where it lands, per-OS dispatch). When you're trying to figure out "why doesn'tjust upgrade-Xmove tool Y forward?", that's where the coverage gaps are documented.
Why install and upgrade are split¶
chezmoi apply (and the ansible phase it triggers) is install-only by design. Ansible roles use state: present + creates: so that running chezmoi apply on a running box is idempotent and never silently bumps every tool to whatever happens to be latest that day. This matters because:
- Predictability — rerunning apply after editing one role file shouldn't ripple-upgrade unrelated tools.
- Offline / flaky network — install-only can be satisfied by what's already on disk;
--upgradeworkflows always hit the network. - Review friction — you want to see "chezmoi says 10 casks would move; proceed?" as a separate decision from "apply my dotfile changes".
The explicit upgrade path lives in scripts/upgrade_tools.sh, exposed via just upgrade-* recipes in justfile. It is the only thing that intentionally moves tools forward on this repo's flow.
Three side-effects worth knowing about:
.chezmoiexternal.toml.tmplentries haverefreshPeriod = "168h"— chezmoi itself will re-fetch those weekly on apply. That's a bounded "nudge", not an upgrade mechanism for installed binaries.- Homebrew casks that carry a
homebrew_caskentry withstate: presentfreeze to that version until a Brewfile hash change forces a re-bundle with--no-upgrade. Still install-only;upgrade-brewis what actually bumps them. - Neovim installed by the macOS official-release fallback is outside Homebrew, so
upgrade-brewdoes not refresh it. The role replaces it only when the activenvimfalls belowneovim_min_version; otherwise update that fallback manually from the official archive. Removing~/.local/bin/nvimand re-applying reinstalls it only when no othernvimonPATHalready satisfies the minimum. Existing Homebrew Neovim installs remain owned byupgrade-brew. - LazyGit is similarly version-gated at >= 0.64.0 because the managed config uses
git.diffRenderers. An older Homebrew formula receives a targetedbrew upgrade lazygit; other stale or shadowed installs fall back to the checksum-verified official release in~/.local/bin. Once the minimum is met, normal updates remain the responsibility ofupgrade-brewor the install source. - mole ships its own updater — never run it here.
mo updatereinstalls into/usr/local/bin, which is exactly where Homebrew keeps itsmole/mosymlinks, so the two fight over the same paths.mo updatebelongs to theinstall.shchannel only; the Homebrew install this repo declares is owned byupgrade-brew, and there is deliberately nojust upgrade-mole. See mole.
Entry points¶
Ghostty follows its GUI package channel: macOS just upgrade-brew; Linux native
apt/PPA via explicit system apt maintenance, or normal Snap refresh for the
classic-Snap fallback. chezmoi apply does not upgrade an existing Ghostty.
See Ghostty installation.
Micro: Homebrew uses just upgrade-brew, apt uses a targeted OS package upgrade,
and the verified Linux user-level fallback uses just upgrade-micro (category
micro, included in all). The latter requires an installer receipt and an
active PATH match; it never overwrites a package-managed binary.
just upgrade-all # externals → brew → mise → uv → npm → cargo → go → dotnet → gem → flatpak → warp → agents → plugins
just upgrade-dry-run # same, but commands are printed, not executed
just upgrade-<category> # run one category in isolation
Or use the script directly when you want the flags:
./scripts/upgrade_tools.sh # same as 'all'
./scripts/upgrade_tools.sh brew uv # two categories
./scripts/upgrade_tools.sh --only brew,mise # same, via --only
./scripts/upgrade_tools.sh --skip agents,plugins # all, minus those
./scripts/upgrade_tools.sh --dry-run all # preview
./scripts/upgrade_tools.sh --help
The script runs categories in the canonical ALL_CATEGORIES order regardless of CLI arg order — dependencies between categories are real (see § Run order).
Category matrix¶
Browser tools are maintained with their packages: macOS terminal-browser uses
brew, while Playwright CLI uses npm (preferring the same mise Node used during
installation). Both repair their package skill links afterward; npm also
prepares and launch-checks matching Chromium only when preloadPlaywrightChromium=true and Playwright CLI is enabled. Otherwise browser downloads remain on demand.
See browser-tools.
| Category | What actually happens |
|---|---|
micro |
Upgrades only the active, receipt-backed Linux release in ~/.local/bin using the official archive checksum; otherwise skips. Brew/apt installs stay with their package manager. Runs after herdr and before agents. |
terminal-browser |
just upgrade-terminal-browser: verified Linux release only, after micro; skips live browsers and foreign installs, refreshes package skills and the per-executable AppArmor profile where needed. macOS uses brew. |
externals |
chezmoi upgrade (the chezmoi binary itself) + chezmoi apply --refresh-externals (force-refresh the 168h externals: oh-my-zsh, TPM, pi-agents, toolkami.rb, fzf). First because a chezmoi version bump may change how later steps behave. |
brew |
brew update → brew upgrade → brew upgrade --cask --greedy → brew bundle --file=~/.config/homebrew/Brewfile without --no-upgrade → Brewfile.{darwin,linux} → brew cleanup. macOS pre-warms the shared sudo session via scripts/lib/sudo_shared.sh so cask pkg installers that shell out to sudo /usr/sbin/installer find a live ticket. |
mise |
mise self-update --yes + mise upgrade (honours the version constraints in ~/.config/mise/config.toml). self-update warns, rather than fails, when mise was installed via brew/apt. |
uv |
Detects install style by binary path (Homebrew vs curl-installer) and dispatches to the right channel: brew upgrade uv for Homebrew/Linuxbrew installs, uv self update for the standalone curl installer. Then uv tool upgrade --all. Covers every tool listed in python_uv_tools/defaults/main.yml and llm_tools/defaults/main.yml. The python_uv_tools ansible role does the same dispatch automatically when uv is below min_uv_version — see docs/this_repo/uv-bootstrap.md. |
npm |
Uses the npm executable beside mise's selected Node, with that runtime first on PATH; otherwise uses system npm -g update. This matches browser-tool installation even when system npm shadows mise. Then repairs Playwright's package skills; Chromium download/repair requires preloadPlaywrightChromium=true. Pi's stable ~/.local prefix is upgraded by agents, not this runtime-prefix bulk command. |
cargo |
If absent, bootstraps the cargo-update crate, then cargo install-update -a. Covers pueue (Linux) plus any future entries in rust_cargo_tools/defaults/main.yml. |
go |
Upgrades non-personal Go manifest entries (Linux gopls) with the existing Go toolchain; no SDK bootstrap. |
personal |
just upgrade-personal: selects the suite policy, then upgrades only installed, identified owners. Brew stays Brew, verified Linux releases use checksums, Legacy Go stays source. New lazyansible/crontab/find/pkg/mermaid/set installs use the same Brew/release owners. Missing/disabled/unknown tools are preserved. Successful changes refresh their Bash/Zsh completion. |
dotnet |
Parses tool names from dotnet_tools/defaults/main.yml and runs dotnet tool update --global <name> per tool (via the mise dotnet shim). Falls back to dotnet tool list --global if parsing finds nothing. |
gem |
gem update --system + gem update via the mise ruby shim. |
flatpak |
flatpak update --user --noninteractive --assumeyes for user-scope Flathub apps (Discord et al. when discordChannel=flatpak — see docs/playbooks/linux-gui-apps.md). Skipped when flatpak is absent OR when no user-scope apps are installed. System-scope (flatpak update --system) is intentionally NOT covered — it requires sudo and is rare in this repo's flow; run manually if needed. |
warp |
Linux only. sudo apt-get update + sudo apt-get install --only-upgrade -y warp-terminal via the shared sudo session. macOS Warp is handled by cat_brew (cask warp + --greedy); this category short-circuits with SKIPPED on Darwin. The on-disk binary is replaced but the running Warp process is not restarted — quit + relaunch Warp to load the new version. The in-app warp_finish_update <token> graceful-restart only works from inside a live Warp session (Warp-injected shell function, not on $PATH otherwise) — see docs/tools/warp.md for the full mechanism. |
agents |
Updates tools already present only. Pi invokes exact ~/.local/bin/pi update --self with an npm reinstall into its stable prefix as fallback; OMP invokes exact ~/.local/bin/omp update with omp.sh/install --binary fallback. PATH shadows are ignored. Claude Code, OpenCode, Cursor CLI, Ollama (Linux), llmfit (Linux), tokens (Linux, TOKENS_NO_SERVICE=1 installer into ~/.local/bin), and RTK retain their guarded self-update/installer paths. Bootstrap list mirrors coding_agents. |
atuin |
macOS: brew upgrade atuin (no-op when atuin was installed via the upstream installer rather than brew). Linux: re-runs curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh \| env ATUIN_NO_MODIFY_PATH=1 sh so the upstream installer drops the new binary into ~/.atuin/bin/ while leaving PATH wiring to dot_config/shell/15_atuin.sh. Skipped with SKIPPED if atuin is not on PATH. The local SQLite history (~/.local/share/atuin/) and config (~/.config/atuin/config.toml) are untouched. |
herdr |
Only from outside herdr, and only for a self-managed install. herdr update --handoff — the live path that replaces the server without killing pane processes, so coding agents running in panes survive. SKIPPED when the binary came from brew/mise/Nix (upstream disables herdr update there; this repo installs the GitHub-release binary on both platforms precisely so the handoff stays available — the message tells you to re-run chezmoi apply to migrate) and SKIPPED with instructions when HERDR_ENV/HERDR_PANE_ID show you are inside a pane. After a successful update, the category refreshes ~/.agents/skills/herdr/SKILL.md from that exact binary's herdr --skill output, then reports stale agent integrations without auto-reinstalling them. See docs/tools/herdr.md and pitfalls/herdr-update-handoff-refuses-inside-pane.md. |
plugins |
nvim --headless "+Lazy! sync" +qa → ~/.tmux/plugins/tpm/bin/update_plugins all → refresh installed claude-hud via claude_hud_sync.py → pre-commit autoupdate (on the dotfiles repo root) → tldr --update → gh extension upgrade --all. Each step is guarded on the relevant binary being present. |
yazi-plugins |
ya pkg upgrade — bumps Yazi plugins (piper.yazi, duckdb.yazi, git.yazi, …) declared in dot_config/yazi/package.toml to their latest upstream revs. SKIPPED when ya or the lockfile is absent. Install-only chezmoi apply pins the committed revs via ya pkg install; after upgrading, copy the regenerated ~/.config/yazi/package.toml back into the chezmoi source to persist the new revs. Current git.yazi activates on Yazi 26.8.15+; the managed guard safely no-ops on older hosts. See docs/tools/yazi.md. |
Mason-managed Neovim tools (including the shared Prettier fallback) have a separate
upgrade path: run :MasonUpdate, wait for completion, then :MasonInstall prettier
to reinstall from the refreshed registry. :MasonUpdate alone updates metadata;
just upgrade-plugins updates Neovim plugins but not installed Mason tool versions.
LazyVim's ensure_installed only fills missing tools.
Tasks suite¶
Tasks is installed with coding agents, but its upgrade owner
is its install channel. Homebrew/Linuxbrew installs use just upgrade-brew, or
run brew update followed by brew upgrade marcus/tap/tasks. Refreshing Homebrew
metadata first avoids an outdated tap reporting that the current version is
already installed. The formula updates tasks, tasks-tui, and tasks-api
together.
The Linux GitHub-release fallback has no automatic upgrade category. For an
explicit update, download the selected version's Linux amd64/arm64 archive and
checksums.txt from the official release,
verify that archive's checksum, and extract into a temporary directory. Verify
--version on all three extracted executables, then replace the three
release-owned binaries in ~/.local/bin as one suite. Keep the existing config
and data store; do not replace package-managed paths with release binaries.
Restart a running Tasks TUI/API yourself when ready. chezmoi apply does not
upgrade a complete existing suite or reset its data configuration.
Run order¶
flowchart LR
externals["externals<br/>(chezmoi + externals)"] --> brew
brew["brew<br/>(formulas + casks greedy + Brewfile)"] --> mise
mise["mise<br/>(self-update + runtimes)"] --> uv
uv["uv<br/>(self update + tools)"] --> npm
npm["npm<br/>(-g update)"] --> cargo
cargo["cargo<br/>(install-update -a)"] --> go
go["go<br/>(install @latest)"] --> dotnet
dotnet["dotnet<br/>(tool update --global)"] --> gem
gem["gem<br/>(gem update)"] --> flatpak
flatpak["flatpak<br/>(--user update)"] --> warp
warp["warp<br/>(Linux apt-only)"] --> atuin
atuin["atuin<br/>(brew or setup.atuin.sh)"] --> herdr
herdr["herdr<br/>(update --handoff, self-managed)"] --> micro
micro["micro<br/>(verified user-level Linux release)"] --> terminal_browser
terminal_browser["terminal-browser<br/>(verified Linux release, idle only)"] --> agents
agents["agents<br/>(self-update + installer fallbacks)"] --> plugins
plugins["plugins<br/>(Lazy, TPM, pre-commit, tldr, gh)"] --> yazi_plugins
yazi_plugins["yazi-plugins<br/>(ya pkg upgrade)"] --> summary((Summary))
.NET SDK installs use the managed dotnet = "10" and dotnet.isolated = true
settings. mise upgrade dotnet explicitly upgrades within that major;
just upgrade-dotnet upgrades global tools separately. After either, check
mise exec -- azure-cost --help before testing Azure authentication. Avoid
mise use -g dotnet@latest, which rewrites the chezmoi-managed version pin.
Rationale: package managers themselves go first (externals to maybe swap chezmoi; brew because mise/uv/npm/cargo/dotnet/gem may be Homebrew-installed; mise before the language-scoped ones because mise upgrade can swap the runtime npm/cargo/go/dotnet/gem belong to). agents + plugins last because they depend on everything above being current.
claude-hud stays out of .chezmoiexternal.toml.tmpl: it uses a versioned cache path and rewrites ~/.claude/plugins/installed_plugins.json, so it fits the explicit plugins upgrade path better than chezmoi externals. Upstream v0.0.12+ also switched usage rendering to Claude Code's official stdin rate_limits, which means the old credential-derived Max badge may disappear after upgrade.
Because the install path is claude_hud_sync.py --only-if-missing (install-only, per the repo-wide install/upgrade split), a box that never runs just upgrade-plugins silently sits on whatever version it was first seeded with — this host sat on 0.0.11 from 2026-03 until 2026-07 while upstream reached v0.6.0. Symptom to recognise: a peer machine's HUD shows elements yours doesn't. Almost every element added since is opt-in, default false, so upgrading alone changes nothing visible; the flags live in dot_claude/plugins/private_claude-hud/config.json. Notable post-0.0.11 additions: showCost (v0.0.12), showPromptCache + promptCacheTtlSeconds (v0.1.0, TTL default 300s), showEffortLevel (renders ultracode/xhigh from stdin effort), showSkills, showMcp, showSessionTokens, showCompactions, showSessionStartDate, showLastResponseAt, and language: "zh-Hant" (v0.4.0).
Upgrading does not require the plugin to be enabled — claude_hud_sync.py never reads enabledPlugins, and claude-hud@claude-hud is deliberately false (see lsp.md § Via Claude Code plugins). The statusline picks the newest cached version via sort -V | tail -1, so the previous version dir is left in place and rollback is just deleting the newer one.
Reading the enabled elements correctly is its own topic — Tokens is a cumulative sum rather than a water level and excludes subagents, Cost is sourced differently and includes them, and the cache countdown is a snapshot that freezes when idle. In v0.8.0, lines wrap between segments when terminal width is known; a single overlong segment is still truncated. The managed order puts cost and the Claude Code version before secondary labels. See tools/claude-hud.md, including the plan/auth, provider, advisor, and output-style options and recovery from a cached failed version probe.
Semantics: best-effort, not all-or-nothing¶
Each category is wrapped in a run_category helper:
- Individual command failures inside a category do not abort the rest of the category. Everything is best-effort.
- A category failure does not abort the next category. The script keeps going.
- Categories return
77when the prerequisite is missing (e.g. nodotnetbinary) — that is reported as SKIPPED, not FAILED. - The final summary groups into three buckets:
[INFO] Upgrade Summary
[SUCCESS] OK: externals brew mise uv npm cargo agents plugins
[WARN] SKIPPED: gem (prerequisite missing)
[ERROR] FAILED: dotnet
[ERROR] - dotnet: rc=1
Overall process exit code is 1 iff at least one category is in FAILED. --dry-run always exits 0 because no real command runs.
Sample: just upgrade-dry-run¶
────────────────────────────────────────────
[INFO] upgrade_tools.sh — categories: externals brew mise uv npm cargo dotnet gem agents plugins (dry-run)
[WARN] DRY-RUN MODE — commands are printed, not executed
────────────────────────────────────────────
[INFO] ── category: externals ──
[INFO] Upgrading chezmoi binary itself
+ chezmoi upgrade
[INFO] Force-refreshing chezmoi externals (.chezmoiexternal.toml.tmpl)
+ chezmoi apply --refresh-externals
[SUCCESS] category 'externals' completed
────────────────────────────────────────────
[INFO] ── category: brew ──
+ brew update
+ brew upgrade
+ brew upgrade --cask --greedy
+ brew bundle --file=/Users/me/.config/homebrew/Brewfile
+ brew bundle --file=/Users/me/.config/homebrew/Brewfile.darwin
+ brew cleanup
[SUCCESS] category 'brew' completed
...
Things intentionally excluded¶
- No
state: latestrewrites of ansible roles.chezmoi applysemantics stay conservative; the upgrade path uses package-manager commands directly. - No
apt upgrade/ system package bumps. Keeps the default scope sudo-light and noise-free. Runsudo apt upgrade && sudo apt autoremovemanually if you want that. - No Linux Steam upgrade category. The Ubuntu Desktop Steam launcher/runtime packages are apt-managed through Valve's repo, so they move with your normal system apt upgrade cadence; the Steam client itself self-updates on launch. macOS Steam is a Homebrew cask and is covered by
just upgrade-brew. - No daemon restart for LiteLLM / Ollama / pueued / anything
systemd-managed. We only refresh the binaries; restarting is your call. - No automatic scheduling. There is no cron, launchd, or systemd timer wired up. Running
just upgrade-allis an intentional action you take when you have time to review diffs / deal with breakage. scripts/**is ignored by chezmoi (.chezmoiignore.tmplline 29) — the script is not deployed to$HOME. It runs from the dotfiles repo checkout directly (wherechezmoi cdtakes you). That is a deliberate choice: upgrade logic is repo-local, not per-user.
Troubleshooting¶
chezmoi upgradefails.chezmoi upgradeonly works when chezmoi was installed via the official install script orgo install. Homebrew/apt installs will surface an error — upgrade via that channel instead. The script treats this as a warning, not a failure.mise self-updatefails. mise refuses when installed via a system package manager. Treated as a warning.uv self updateno-ops on Homebrew uv. Handled automatically —cat_uv()detects the install style and runsbrew upgrade uvinstead. Seedocs/this_repo/uv-bootstrap.mdandpitfalls/uv-self-update-homebrew-noop.md.- macOS cask pkg installer hangs on sudo. The script calls
sudo_session_init "upgrade-brew"on macOS, which prompts once and warms the ticket. If you ran the script over SSH with no TTY, that step becomesnon-interactiveand cask pkg bumps may stall. Fix: run locally, orssh -t, or pre-warm withsudo -vyourself beforejust upgrade-brew. cargo install-update -afails halfway through. One crate break does not abort the rest (best-effort). Re-runjust upgrade-cargo— cargo-update is itself rebuilt if missing.pre-commit autoupdatebumped a hook that now errors. That is a repo-file change, not an environment change. Review.pre-commit-config.yamldiff;git checkout -p .pre-commit-config.yamlto partially revert if needed.
Extending¶
Three ways to add a new tool to the upgrade flow:
- Tool already managed by an existing category (brew / uv / npm / cargo / go / dotnet / gem / mise). Nothing to do — the generic
upgrade-<category>picks it up because it uses the package manager's own bulk command. - New
curl | bashinstaller. Add a guarded block insidecat_agents()inscripts/upgrade_tools.sh. Gate on the binary being present so the upgrade path never bootstraps on machines that don't have the tool.
if command -v newtool >/dev/null 2>&1; then
info "Upgrading newtool"
_run_sh "curl -fsSL https://newtool.dev/install.sh | bash" || any_fail=1
ran_any=1
fi
- Brand-new strategy (e.g. GitHub release binary with custom download logic). Add a
cat_<name>function, register it in: ALL_CATEGORIESarray- The dispatch
casein the main loop - A matching
just upgrade-<name>recipe injustfile - This doc's category matrix
Keep the return-value contract: 0 = success, 77 = skipped (prerequisite missing), any other non-zero = failure.
Cross-references¶
AGENTS.md§ Hard repo invariants — short version (install-vs-upgrade rule) written for AI agents touching this repo.README.md§ Keeping tools up-to-date — short version written for humans arriving from the front page.scripts/upgrade_tools.sh— the implementation.scripts/lib/sudo_shared.sh— the sudo-session helper reused for macOS cask installers..chezmoiexternal.toml.tmpl— therefreshPeriodentries thatupgrade-externalsforce-refreshes.- Ansible role defaults consumed for enumeration:
dotnet_tools,go_tools,python_uv_tools,llm_tools,coding_agents.
Herdr UI follows its own package owner: macOS Homebrew; Linux App Updates (PolicyKit) or the official installer. Apply only installs missing copies. just upgrade-herdr remains daemon-only. See Herdr UI.