A zsh plugin that surfaces outdated global packages at the start of a shell session and offers a one-keystroke Y/n/s upgrade. Rate-limited to once every four hours, skipped in SSH and scripts, and never blocks shell startup.
▲ 4 updates available
Homebrew
gh 2.60.0 → 2.62.0
fd 10.1.0 → 10.2.0
npm (global)
pnpm 9.0.0 → 9.5.1
claude-code 2.1.0 → 2.1.1
Update all? [Y/n/s] ›
Y(or Enter): run every upgrade in sequence, grouped by manager.n(or Esc): skip everything; no re-nag until the next interval.s: step through per-packageY/nacross all managers.
Supports Homebrew (formulae and casks), npm (global), pnpm (global), uv tools, and RubyGems. Each manager is independently configurable as all, off, or an explicit allowlist.
You already have brew outdated, npm outdated -g, uv tool list --outdated. Running them by hand is friction nobody keeps up with. A session-start nag catches updates at a moment you're at the keyboard and ready to decide, without becoming topgrade (which upgrades everything) or an auto-updater (which decides for you).
Pick whichever matches your setup.
Clone into the custom-plugins directory:
git clone https://github.com/madisonrickert/zsh-pkg-update-nag \
"${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-pkg-update-nag"Add zsh-pkg-update-nag to the plugins=(...) array in your ~/.zshrc, then exec zsh.
# in ~/.zshrc
zinit light madisonrickert/zsh-pkg-update-nag# in ~/.zsh_plugins.txt
madisonrickert/zsh-pkg-update-nagzdot ships a built-in update-nag module that wraps this plugin:
# in your .zshrc, alongside other zdot_load_module calls
zdot_load_module update-naggit clone https://github.com/madisonrickert/zsh-pkg-update-nag ~/.zsh-pkg-update-nag
echo 'source ~/.zsh-pkg-update-nag/zsh-pkg-update-nag.plugin.zsh' >> ~/.zshrc
exec zshOpen a fresh terminal. The scan runs in the background, so your prompt appears immediately. A dim (checking…) notice flashes while the scan is in flight, then results land in place (either the update prompt or All packages up to date.) before your next prompt.
Confirm detected managers and the computed config any time:
zsh-pkg-update-nag --check-envSubsequent shells within the four-hour rate-limit window stay silent. Run zsh-pkg-update-nag --now to force a check on demand.
The plugin doesn't self-update. Your install path already has that covered:
- oh-my-zsh:
omz updatepulls every custom plugin under$ZSH_CUSTOM/plugins/. For periodic auto-updates of OMZ and every plugin, see Pilaton/OhMyZsh-full-autoupdate. - zinit:
zinit update. - antidote:
antidote update. - Standalone:
git -C ~/.zsh-pkg-update-nag pull.
All options are optional. Defaults are sensible.
Config file location: ${XDG_CONFIG_HOME:-$HOME/.config}/zsh-pkg-update-nag/config.zsh (override with $ZSH_PKG_UPDATE_NAG_CONFIG).
# ~/.config/zsh-pkg-update-nag/config.zsh
# How often to check (hours). Default: 4.
zsh_pkg_update_nag_interval_hours=4
# Per-manager: "off", "all", or a zsh array / whitespace-separated list of
# package names to watch. Default shown.
zsh_pkg_update_nag_brew=all
zsh_pkg_update_nag_npm=all
zsh_pkg_update_nag_pnpm=all
zsh_pkg_update_nag_uv=all
zsh_pkg_update_nag_gem=off
# Example: watch only two npm globals.
# zsh_pkg_update_nag_npm=(typescript prettier)
# Minimum release age in days (0 = off, the default). See the section below.
zsh_pkg_update_nag_min_age=0
# zsh_pkg_update_nag_min_age_npm=14 # per-manager override; wins over the global| Variable | Purpose |
|---|---|
ZSH_PKG_UPDATE_NAG_DISABLE=1 |
Disable the plugin entirely. |
ZSH_PKG_UPDATE_NAG_FORCE=1 |
Ignore the rate-limit for this shell. |
ZSH_PKG_UPDATE_NAG_SSH=1 |
Opt in under SSH (default: skipped). |
ZSH_PKG_UPDATE_NAG_BACKGROUND=0 |
Run the scan synchronously at shell load (default: background). |
ZSH_PKG_UPDATE_NAG_DEBUG=1 |
Append diagnostics to $XDG_STATE_HOME/zsh-pkg-update-nag/debug.log. |
ZSH_PKG_UPDATE_NAG_PROVIDER_TIMEOUT |
Per-provider timeout in seconds (default 10). |
ZSH_PKG_UPDATE_NAG_CONFIG |
Override config file path. |
GITHUB_TOKEN |
Used by the brew min-age GraphQL fast path when gh isn't available (5000 req/hr). |
ZSH_PKG_UPDATE_NAG_MIN_AGE_LOOKUP_PARALLELISM |
Concurrency for the brew min-age REST fallback (default 6). Only applies when neither gh nor $GITHUB_TOKEN is set. |
NO_COLOR=1 |
Disable color output (per the NO_COLOR spec). |
Scans run in a background subshell, so plugin load returns immediately and shell startup stays snappy.
- A dim
(checking for package updates in the background…)notice appears at your first prompt while the scan is in flight. - When the scan finishes, the update prompt or
All packages up to date.lands in place before your next prompt. --nowalways runs synchronously, so progress output stays visible while you wait.- If multiple shells open at once, only one runs the scan; the others pick up its result.
To run synchronously instead (mainly useful when debugging), set ZSH_PKG_UPDATE_NAG_BACKGROUND=0 before the plugin loads.
If you use powerlevel10k with instant-prompt enabled, the plugin auto-adapts: the dim "(checking…)" notice is suppressed (cosmetic only), and the results display is deferred one prompt so it lands after p10k finalizes. No action needed.
Optional supply-chain safety net. When zsh_pkg_update_nag_min_age is set to N > 0 days, an update is only surfaced once the new version has been published for at least N days. Fresh releases get a quarantine window during which yanked or compromised versions usually surface.
# ~/.config/zsh-pkg-update-nag/config.zsh
zsh_pkg_update_nag_min_age=7 # global baseline
zsh_pkg_update_nag_min_age_npm=14 # stricter for npm
zsh_pkg_update_nag_min_age_brew=0 # off for brewPer-manager overrides (zsh_pkg_update_nag_min_age_<manager>) win over the global, even when set to 0. Leave a per-manager variable unset to inherit the global.
If your package manager has minimum-release-age built in, use that for those packages. It acts at install time (so it also protects ad-hoc installs) and avoids the per-package lookup this plugin does.
| Manager | Native minimum-age support | Recommendation |
|---|---|---|
| brew | None (homebrew/core is human-curated) | Use this plugin's setting |
| npm | min-release-age (npm 11+) |
Prefer npm's; this plugin's setting is the fallback when you can't change .npmrc |
| pnpm | minimumReleaseAge in .npmrc |
Prefer pnpm's; this plugin's setting is the fallback |
| uv | --exclude-newer DATE (per-invocation) |
This plugin's setting is easier for ongoing use |
| gem | None | Use this plugin's setting |
| (out of scope) cargo | --minimum-release-age |
Prefer cargo's |
Each outdated package needs one publish-date lookup the first time it's seen. Lookups are cached forever in $XDG_STATE_HOME/zsh-pkg-update-nag/age_cache.tsv (publish dates don't change), so steady-state cost is near-zero. Most users see ~95% cache hits after a day or two.
| Manager | Lookup source | Cold-cache cost |
|---|---|---|
| brew | brew info --json=v2 (~1.2 s fixed) plus a single GitHub GraphQL query covering every package via gh api graphql or curl with $GITHUB_TOKEN. Falls back to per-package serial REST when neither is available. |
~3 s for any number of packages on the fast path; ~N seconds on the unauth REST fallback |
| npm | npm view <pkg> time --json + jq |
~350–650 ms (network) per package |
| pnpm | https://registry.npmjs.org/<pkg> via curl + jq |
~100–300 ms per package |
| uv | https://pypi.org/pypi/<pkg>/json via curl + jq |
~100–300 ms per package |
| gem | https://rubygems.org/api/v1/versions/<pkg>.json via curl + jq |
~100–300 ms per package |
Real-world: cold cache, 35 outdated brew packages with gh authenticated → about 7 s total. Steady state with cache populated: ~3–4 s regardless of N. Background mode keeps that latency entirely off the startup path. Each provider call is wrapped by ZSH_PKG_UPDATE_NAG_PROVIDER_TIMEOUT (default 10 s) so a hung HTTP request can never extend the scan past that cap. If a single manager is too slow even in the background, set its per-manager override to 0 to disable the lookup for it.
The brew lookup hits the GitHub API. In order of preference:
ghCLI installed and authenticated (gh auth login): single batched GraphQL call per scan; 5000 req/hr quota. Strongly recommended if you're enabling brew min-age.$GITHUB_TOKENset: same GraphQL fast path viacurl; same 5000 req/hr quota.- No auth: per-package REST against the 60 req/hr public cap. Enough for ~30–60 unique brew packages per hour; the lookup fails-open once exhausted.
- Fail-open. If the age can't be determined (network down, missing
curl/jq, third-party brew tap, malformed registry response), the update is shown anyway and a line lands in the debug log. - Brew signal. The brew "publish date" is the formula file's commit time in
Homebrew/homebrew-coreorhomebrew-cask, not the upstream project's release time. For homebrew-core that's typically within hours of upstream; for third-party taps the lookup fails-open.
zsh-pkg-update-nag --now # run the check immediately
zsh-pkg-update-nag --check-env # show detected managers, config, and next-check time
zsh-pkg-update-nag --helpTab-completion ships as _zsh-pkg-update-nag. oh-my-zsh picks it up automatically; standalone users may need to run compinit (or open a fresh shell) once after installing.
The check is skipped on purpose in any of these cases:
- Non-interactive shells (scripts, here-docs).
- Dumb terminals (
TERM=dumb), non-TTY stdin/stdout,INSIDE_EMACSset. CIenvironment variable set.- SSH sessions, unless
ZSH_PKG_UPDATE_NAG_SSH=1. - Within the rate-limit window.
- Another shell is mid-check (file-lock held).
If you expect a check and it's not running:
ZSH_PKG_UPDATE_NAG_FORCE=1 zsh-pkg-update-nag --now # bypass the rate-limit
zsh-pkg-update-nag --check-env # confirm detection
ZSH_PKG_UPDATE_NAG_DEBUG=1 zsh-pkg-update-nag --now # write diagnostics
cat "${XDG_STATE_HOME:-$HOME/.local/state}/zsh-pkg-update-nag/debug.log"Remove the plugin from your manager, then optionally wipe its state and config:
# oh-my-zsh
rm -rf "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-pkg-update-nag"
# remove `zsh-pkg-update-nag` from the plugins=(...) array in ~/.zshrc
# zinit
# remove `zinit light madisonrickert/zsh-pkg-update-nag` from ~/.zshrc
# antidote
# remove `madisonrickert/zsh-pkg-update-nag` from ~/.zsh_plugins.txt
# zdot
# remove `zdot_load_module update-nag` from your .zshrc
# standalone
rm -rf ~/.zsh-pkg-update-nag
# remove the `source` line from ~/.zshrc
# optional: wipe state + config
rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/zsh-pkg-update-nag"
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/zsh-pkg-update-nag"- zsh 5.0 or newer.
- Optional, only as needed:
jq: improves Homebrew version-delta display (without it, brew versions show as?); also required for anymin_age > 0lookup.curl: required formin_age > 0on brew/uv/gem. Ships at/usr/bin/curlon macOS and most Linux distros.gh: enables the GraphQL fast path for brew min-age (one API call covering every package). Strongly recommended if you setmin_age > 0for brew. Alternatively, set$GITHUB_TOKENand the same path runs viacurl.timeout/gtimeout: wraps each provider call with a 10-second timeout. On macOS,gtimeoutships incoreutils(brew install coreutils).
- Brew results reflect your last
brew update. The plugin doesn't refresh brew's local index. Runningbrew updatefrom a shell hook would add 5–30 s of network I/O to startup. Brew already auto-updates the index onbrew install/brew upgrade(everyHOMEBREW_AUTO_UPDATE_SECS, default 24 h), so the list catches up the next time you upgrade anything. Runbrew updatemanually if you suspect the cache is stale.
Issues and PRs welcome. Tests use bats-core:
brew install bats-core
bats tests/The codebase is zsh-specific (not bash-portable). Conventions:
- Every function begins with
emulate -L zsh; setopt local_optionsso user shell options can't alter behavior. - Commands are invoked array-style (
local cmd=(brew upgrade "$pkg"); "${cmd[@]}"), neverevalon user-or-network-derived strings. - Internal symbols are prefixed
_zpun_; public env vars areZSH_PKG_UPDATE_NAG_*. shellcheckoutput is noisy on zsh-specific syntax (${(f)…},$+functions[…]);zsh -nis the authoritative parse check.
MIT. See LICENSE.