[BUG] Plugin marketplace auto-update is silently disabled entirely on non-native (Homebrew/package-manager) installs, regardless of `autoUpdate: true`
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
Plugin marketplace auto-update (the background "Plugin autoupdate" pass that refreshes marketplaces with autoUpdate: true) is gated behind the same kill switch as the binary's own self-updater, with no independent toggle:
function YNe(){return she()&&!ct(process.env.FORCE_AUTOUPDATE_PLUGINS)}
...
function G6l(){return(async()=>{
if(YNe()){C("Plugin autoupdate: skipped (auto-updater disabled)");return}
...
let n=await r4m(); // set of marketplaces with autoUpdate:true
if(n.size===0)return;
...
})()}
she() reflects whether Claude Code's native binary self-update is disabled — which it is, by design, on any package-manager-managed install (Homebrew, apt, etc.), since the package manager owns the upgrade path instead. That's the correct behavior for the binary updater. But because plugin-marketplace autoupdate reuses the exact same flag, it means:
Any Homebrew/apt/non-native install of Claude Code never auto-refreshes plugin marketplaces, period — no matter how correctly autoUpdate: true is set, whether via local settings.json, extraKnownMarketplaces, or org-wide server-managed settings (Admin Settings > Claude Code > Managed settings in the claude.ai console). The only log line produced is a single "Plugin autoupdate: skipped (auto-updater disabled)", and there is no user-facing indication anywhere (docs, /status, claude plugin marketplace list) that this coupling exists.
We only found this by decompiling the installed binary after ~12 days of a marketplace staying stale across multiple releases despite a correctly-configured, server-delivered autoUpdate: true (confirmed present in ~/.claude/remote-settings.json since before the staleness window began).
Why this is easy to hit and hard to diagnose
- Nothing in the server-managed settings docs or settings reference mentions that plugin-marketplace autoupdate depends on the native self-updater being enabled.
claude doctorand/statusgive no indication that plugin autoupdate is being skipped for this reason.- The only escape hatch is an undocumented environment variable,
FORCE_AUTOUPDATE_PLUGINS, discovered only via the binary strings — it isn't listed in the environment variables reference (this appears to be the same gap flagged in the now-closed docs issue #17627, "FORCE_AUTOUPDATE_PLUGINSmissing from Environment Variables reference"). - Any org relying on Homebrew-distributed Claude Code (common for teams that manage their dev tooling via
brew) will have this silently affect 100% of their fleet, with server-managedautoUpdate: truesettings having zero effect.
What Should Happen?
One or more of:
- Decouple plugin-marketplace autoupdate eligibility from the binary self-updater flag entirely — a disabled binary self-updater has no logical bearing on whether a plugin marketplace should refresh.
- At minimum, log a distinguishable reason (e.g.
"Plugin autoupdate: skipped (native self-updater disabled; set FORCE_AUTOUPDATE_PLUGINS=1 to override)") instead of the current generic message, and document theFORCE_AUTOUPDATE_PLUGINSoverride in the environment variables reference. - Surface this state in
/statusorclaude plugin marketplace list --jsonso it's diagnosable without decompiling the binary.
Steps to Reproduce
- Install Claude Code via Homebrew (
brew install --cask claude-codeor equivalent), not the native installer. - Register a git-sourced marketplace with
autoUpdate: true(via localsettings.json'sextraKnownMarketplaces, or via org server-managed settings). - Install a plugin from it.
- Push a new commit to the marketplace remote bumping a plugin's version.
- Fully quit and relaunch Claude Code repeatedly over several days. Do not run
/plugin marketplace updatemanually. - Observe: the marketplace clone never advances,
installed_plugins.jsonstays on the old version, and no log output (even at--debug) indicates why — only the generic "skipped (auto-updater disabled)" line, which doesn't name plugin autoupdate as the affected subsystem in a way that's discoverable without already knowing to look for it. - Confirm the mechanism:
export FORCE_AUTOUPDATE_PLUGINS=1, relaunch — the marketplace now refreshes.
Environment
- Claude Code: 2.1.197
- Install method: Homebrew Cask (
/opt/homebrew/Caskroom/claude-code/2.1.197/claude) - Platform: macOS (Darwin 25.5.0)
- Marketplace source:
github,autoUpdate: truedelivered via org server-managed settings (~/.claude/remote-settings.json)
Related Issues
- #60772 (closed, stale) — same symptom (
autoUpdate: truenot pulling the clone across relaunches), but the reports there didn't identify this root cause. - #84401 (open) — general "plugin autoupdate is silent" observability gaps; complementary but distinct (that reporter is on a native install, so the eligibility gap they hit is different from the one here).
- #17627 (closed, docs) — flagged
FORCE_AUTOUPDATE_PLUGINSas missing from the env var reference; still true today, and now more clearly load-bearing than that issue's framing suggested.
3 Comments
Update: this affects more than Homebrew — including the Claude Desktop app's own bundled CLI
Traced the actual gate more precisely across two different binaries/versions:
2.1.197, at/opt/homebrew/Caskroom/claude-code/2.1.197/claude2.1.227, at~/Library/Application Support/Claude/claude-code/2.1.227/claude.app/Contents/MacOS/claude, launched directly by Electron (launchd → Claude.app → disclaimer helper → claude, no shell in the chain at all)Both contain byte-identical gating logic (only identifier names differ across the two builds):
So the actual condition is
installMethod !== "native"(plus theautoUpdatesconfig flag), not "Homebrew" specifically."native"appears to mean strictly the official standalone curl/pkg installer flow. Anything else that produces a runningclaudebinary — a package manager, or an app that bundles and version-manages its own copy — fails that check and gets treated as self-update-disabled, which was the correct call for the binary updater in both cases (Homebrew shouldn't race Claude Code's self-updater; neither should the Desktop app, which manages its own bundled copy's version directory itself). The bug is that plugin-marketplace autoupdate is silently killed by the exact same flag, with no distinction between the two concerns.Practical implication: this isn't a Homebrew-only edge case. The Claude Desktop app's own embedded CLI runtime — the default experience, not a power-user choice — appears to hit the identical gate for the identical reason. An org relying on Admin Settings > Claude Code > Managed settings (server-managed
extraKnownMarketplaces.<name>.autoUpdate: true) to keep an internal marketplace current gets silently no-op'd regardless of whether their users are on Homebrew, npm-global, or just using the Desktop app as shipped.Caveat: I've confirmed the code path is identical in both binaries, but haven't fully traced exactly how
installMethodresolves for the Desktop-bundled copy specifically — I couldn't find it persisted anywhere in~/.claude.jsonor elsewhere on disk, so it looks like it's computed live per-process rather than cached, but I don't have the exact detection logic pinned down for that binary. Filing this as an addendum rather than editing the original report since the repro/environment there (Homebrew) is still accurate as written — just narrower in scope than the actual bug.Addendum: on the Desktop/remote path the trigger is the env branch, not the config branch — which means native installs are affected too
Hit this independently on Linux and can answer the open caveat from the comment above:
It isn't
installMethodat all. On the Desktop/remote runtime the gate fires one line earlier, onDISABLE_AUTOUPDATER, which the runtime injects into the CLI process itself. The config branch is provably exempt on my machine.Environment
2.1.237, running as~/.claude/remote/ccd-cli/2.1.237(the desktop/remote-control runtime, launched with--output-format stream-json)~/.local/bin/claude→~/.local/share/claude/versions/2.1.212autoUpdate: trueinextraKnownMarketplaces(usersettings.json) and mirrored intoknown_marketplaces.jsonThe config branch is exempt here
From
~/.claude.json:Against the resolver you quoted:
autoUpdatesisfalse, butinstallMethod === "native"andautoUpdatesProtectedForNative === true, so the parenthesized condition isfalseand this branch returns nothing. This is the exemption that's supposed to keep native installs auto-updating, and it works as intended.The env branch is what actually fires
Same gate in
2.1.237(identifiers differ again, logic identical):DISABLE_AUTOUPDATER=1is set in the CLI process environment, and it comes from the runtime, not from the user's shell:It is not in
.bashrc,.bash_profile,.profile,.zshrc,.zprofile,/etc/environment,/etc/profile,/etc/profile.d/*,settings.json,settings.local.json, or~/.claude.json— I grepped all of them. Thesrv/.../serverparent doesn't carry it; theccd-cliinvocation does. So the runtime adds it per-launch.Setting it is defensible for the binary updater, for exactly the reason you gave — the desktop runtime version-manages its own copy under
~/.claude/remote/ccd-cli/and shouldn't race a self-updater. It's the coupling that's the bug, same as your report.Why this widens the scope
Your framing implies the affected population is "anything but the official curl/pkg installer." That undersells it. Because the env check precedes the config check,
autoUpdatesProtectedForNativeis bypassed entirely — a plain native install gets plugin autoupdate silently killed the moment it's driven through the desktop app or a remote session. The exemption Anthropic added to protect native installs cannot help, because the branch that would grant it is never evaluated.Observed impact
Nothing had auto-updated in over a month, across four plugins on a marketplace with
autoUpdate: true:| plugin | installed | HEAD | behind |
|---|---|---|---|
| A |
f45e7d3db60a(2026-07-15) |fa07506994c7(2026-08-20) | 11 commits || B |
05f4b34b28db(2026-05-20) |24b966d51169(2026-08-18) | 3 commits || C |
36e938cb5da8(2026-06-26) |5b7be98c8007(2026-08-06) | 1 commit || D |
1043c6c39483|1043c6c39483| 0 (no upstream commits — masked the problem) |Worth flagging for anyone else diagnosing this:
installed_plugins.jsonis actively misleading here.lastUpdatedadvanced on three of these entries whileversionstayed pinned and 11 commits stale, so it is not an indicator that an update happened. (I did not identify what writes it in that state — the sweep certainly wasn't running.) Separately,gitCommitShalags one version behindversionafter an update. Compare theversionfield against the remote, not either of those.Workaround, verified
FORCE_AUTOUPDATE_PLUGINSworks fromsettings.jsonatuserSettingsscope, which makes it org-distributable — worth documenting alongside the #17627 gap:Confirmed rather than assumed:
SessionStarthook probing the CLI's ownprocess.envin a sandboxedCLAUDE_CONFIG_DIRprintedFORCE=[1] DISABLE=[1]with the block present,FORCE=[] DISABLE=[1]without it — sofilterSettingsEnvdoesn't drop the key, and it lands well before the sweep (which runs from a REPLuseEffect, long after settings env is applied).FORCE_AUTOUPDATE_PLUGINSis declaredqe.bool()→$n(), which accepts["1","true","yes","on"], so the string"1"parses true.qis a lazy proxy overprocess.env(per-key getters), so the value is re-read at sweep time — no startup ordering race even in principle.After adding it, the sweep ran unattended and pulled all four plugins to HEAD, correctly leaving a deliberately SHA-pinned marketplace entry alone.
Two smaller things in the same pass
Both make this harder to confirm once the gate is open, so they're worth fixing alongside:
await Mr(Math.floor(Math.random()*XtA), void 0, {unref:!0})withXtA=600000. Because the timer isunref'd, any session that ends before the random draw elapses no-ops silently. The 0–10 min window is noted in #84401's repro; theunrefconsequence isn't.io.useEffect(()=>{if(xo===1)h1c(Q.host,J)},…)→startBackgroundHousekeeping→sng().claude -pnever sweeps, at any duration. Non-obvious for anyone driving Claude Code headlessly or in CI and expecting plugins to stay current.Ask
Same as the original: decouple plugin-marketplace autoupdate from the binary self-updater flag. If the coupling has to stay, please at least have the log line name which branch fired and which knob overrides it —
"skipped (auto-updater disabled)"is identical forDISABLE_UPDATES,DISABLE_AUTOUPDATER, and the config branch, which is precisely why the mechanism above took a decompile to pin down twice, by two different people.Confirming this is still live on 2.1.266, and adding the exact condition — the gate is not the install method, which may be why it reproduces for some package-manager users and not others.
The gate on 2.1.266
The plugin auto-update pass begins with:
FZ()is the shared "auto-updater disabled reason" thatclaude doctoralso reports, and it resolves in this order:| condition | result |
| --- | --- |
| env
DISABLE_UPDATES| disabled || env
DISABLE_AUTOUPDATER| disabled || env
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC| disabled ||
autoUpdates === falsein~/.claude.json, unlessinstallMethod === "native" && autoUpdatesProtectedForNative === true| disabled || otherwise | enabled |
So the install method only supplies an escape hatch, not the trigger. The native installer writes both keys for exactly that purpose:
A non-native install has no equivalent, so there
autoUpdates: falseis terminal — the coupling you describe. Note also that this gate runs before any per-marketplace eligibility check, which is why localsettings.json,remote-settings.jsonand managed settings all look equally ignored.I could not inspect 2.1.197, so none of this contradicts your repro — it is just the current condition. The logic is unchanged in 2.1.259, 2.1.265 and 2.1.266 (only minified identifiers differ).
How a non-native install ends up with
autoUpdates: falseinstallMethodis absent, the config migration derives it andautoUpdatesfrom the oldautoUpdaterStatus:"disabled"→{ installMethod: "unknown", autoUpdates: false }. That state has no native protection and never expires, so anyone who turned the updater off long ago is permanently excluded from plugin auto-update — including after later moving to Homebrew.DISABLE_AUTOUPDATER: "1"insettings.jsonand does not mention that it also stops marketplace and plugin auto-update. The same applies toCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, which people set for traffic or telemetry reasons rather than to freeze plugins.Two things worth adding to the report
1. The UI hides the switch. The same
F4()guard gates the marketplace Enable auto-update / Disable auto-update entries in/plugin→ Marketplaces, so an affected user sees no toggle and no explanation. The/configpanel does explain an env-var reason ("UnsetXto re-enable auto-updates"); the plugin view says nothing.2. Explicit updates are not gated, which is a workaround for anyone who would rather not rely on an env var. Verified on 2.1.266:
Self-check for anyone landing here
claude doctorprints the sameFZ()result:Suggestion
Decouple the two, or at least stop reading "the binary cannot or should not self-update" as "do not refresh marketplaces". A package-manager install is expected not to self-update, yet its marketplaces are ordinary git checkouts that Claude Code owns and can refresh safely. Failing that, showing the reason in
/plugin→ Marketplaces instead of hiding the toggle would turn silent multi-day staleness into something a user can diagnose.