You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Claude Code keeps working when the default Node or npm version changes #177
claude stops working whenever the default Node version changes, because Claude Code is installed as a global npm package and each fnm Node version has its own globals. On 2026-10-02, moving the default to v24.21.0 left new shells with a placeholder binary: Claude Code's auto-updater ("installMethod": "global" in ~/.claude.json) installed it with npm 12, which doesn't run install scripts for packages not on its allow-scripts list, so its postinstall never ran. A second, older copy (2.1.260) also lives in Homebrew's Node globals at /opt/homebrew/bin/claude.
Ideal state
claude keeps working after the default Node version or npm version changes
Claude Code installs and updates without npm
u installs and updates Claude Code on every machine
Only one copy of Claude Code is installed
Decisions
Constraints
No recorded decisions found in docs/decisions/ (only its README).
Open
Does Claude Code move to Anthropic's native installer? (new external dependency)
Starting points
tools/claude/update.bash: installs Claude Code through npm today
tools/node/update.bash: changes the default Node and its npm
Install Claude Code from Homebrew's claude-code@latest cask, using a new tools/claude/Brewfile, and stop installing it with npm. The cask downloads the same Anthropic binary that npm and the native installer use, so there's no new source to trust. The cask also pins that binary's sha256 in the Homebrew cask repo. Setting CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 makes Claude Code run brew upgrade on itself between u runs, which matches how it auto-updates today. u removes every leftover npm copy (Homebrew's Node and each fnm Node version), and it fails if any claude other than the cask's is on PATH. uninstall.bash and the allow-scripts lines in both .npmrc files are changed to match.
What a good fix must achieve 🎯
Property
Category
P1
claude runs the same current version in every directory, whatever Node version that directory selects
🛡️ Safety
P2
Changing the Node or npm version, default or install-script policy cannot break or replace claude
🛡️ Safety
P3
Only one Claude Code is on PATH, and u fails if another one appears
🛡️ Safety
P4
Install and update check the binary against a checksum recorded somewhere other than the download server, and run no unverified downloaded script
🛡️ Safety
P5
Install and upgrade work on the work laptop without sudo
🛡️ Safety
P6
A new release reaches each machine within about a day without running u
🙂 Experience
Assumptions 🤔
The cask upgrades on the work laptop without sudo.u upgrades Homebrew casks on the work laptop without failing #179 traces the cask failures to apps that Homebrew moves into /Applications with sudo. claude-code has no app: it only links one binary into /opt/homebrew/bin. I haven't tried it on the work laptop.
The @latest cask gets each release within about a day. Anthropic's setup docs say it "receives new versions as soon as they ship". They also warn that Claude Code can announce an update before Homebrew has it.
Constraints 🧱
/opt/homebrew/bin/claude is currently Homebrew Node's npm copy (2.1.235). The cask can't link its binary there until that copy is gone.
fnm's per-shell bin comes before /opt/homebrew/bin on PATH. Any npm copy in the active Node therefore hides the cask's binary.
Leaving npm is what gets P1 and P2, because each fnm Node keeps its own globals. Today there are four copies: v24.21.0 (2.1.304), v24.4.1 (2.1.251), v23.5.0, and Homebrew's Node (2.1.235). A project that pins a Node version without a copy, such as michaeluloth.com on v25.1.0, silently falls through to the 2.1.235 copy. Of the two options that leave npm, the native installer runs a script piped from the network without checking it first. It then checks the binary against a manifest fetched from the same server, so a compromised server could serve a matching manifest. The cask's checksum lives in the Homebrew cask repo, and Homebrew is already how most tools here are installed. That separates the two on P4. I kept the @latest channel because it's what npm installs today, and because Anthropic's docs say a newly launched model can need a version newer than the stable channel has.
u runs tools/claude/update.bash before the Homebrew step, so Claude Code is moved off npm before brew bundle reads the Brewfile. It runs in four steps:
It finds every Node install that holds an npm copy of Claude Code. That means Homebrew's prefix plus each fnm Node version.
It deletes each copy, along with the claude link that points into it, and prints a warning naming the copy.
It installs the claude-code@latest cask if it isn't installed yet.
It checks every claude on PATH and fails, naming the path, if any of them resolves somewhere other than the cask.
tools/claude/Brewfile declares the cask, so brew bundle and any later Brewfile-based check know about it. The CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 variable in settings.jsonenv keeps it updating between runs. Bash gives no types, so each step's guarantee is held by what the script checks before it moves on. Tests run the script in a sandbox with fake Node installs and stub brew. uninstall.bash runs brew uninstall --cask claude-code@latest without --zap, because the cask's zap stanza deletes ~/.claude.json.
The approach's properties P1 to P6 still apply. The design adds these:
Property
Category
P7
The first u on a machine that still has npm copies leaves it migrated, with no manual step
🙂 Experience
P8
A u run that leaves the machine migrated reports no failure
🛡️ Safety
P9
A Brewfile declares the cask, so brew bundle, and the cleanup in #140 and lockfile in #168 if they land, know about it
🛡️ Safety
P10
Claude Code's install logic lives only in tools/claude/
🙂 Experience
Types 🧬
Progressions considered:
Option
P7
P8
P9
P10
🏆 Brewfile declares the cask; claude/update.bash runs before the Homebrew step, removes npm copies, installs the cask if absent and checks PATH (chosen)
✅
✅
✅
✅
Same script, but in its usual alphabetical place after the Homebrew step
✅
❌
✅
✅
No Brewfile entry; claude/update.bash alone installs the cask
✅
✅
❌
✅
Brewfile entry, npm copies removed by hand once per machine, script only checks PATH
❌
✅
✅
✅
P1 to P6 pass in every row, because every row ends with only the cask installed.
Decided by: Homebrew refuses to link a cask binary over an unrelated file. Its message is "It seems there is already a Binary at '/opt/homebrew/bin/claude'". On a machine that hasn't migrated, brew bundle therefore fails unless the npm copies are already gone. The alphabetical order runs the cleanup too late, so each machine's first u reports a Homebrew failure (P8). Leaving the cask out of every Brewfile would mean the planned brew bundle --cleanup (#140) uninstalls it (P9). Removing copies by hand needs a manual step on each machine (P7). Moving Claude Code ahead of the Homebrew step in features/update/tools.bash is the only arrangement that passes all four. The new order is only needed for each machine's first run. Once both machines have migrated, Claude Code goes back to its alphabetical place as the last step of this issue, and #177 stays open until then.
Progression:
Input: the machine's Node installs (Homebrew prefix, each fnm Node version) and PATH
↓ find_npm_copies()
NpmCopies: the prefixes holding lib/node_modules/@anthropic-ai/claude-code
↓ remove_npm_copy() for each
NoNpmCopies | RemovalFailed(prefix)
↓ ensure_cask()
CaskInstalled | CaskInstallFailed(brew output)
↓ check_claude_on_path()
OnlyCask | ForeignClaude(path → resolved path, …) | NoClaude
After remove_npm_copy, no Node install holds Claude Code, so selecting a different Node can't change which claude runs. A copy is deleted directly rather than through npm uninstall, so the cleanup doesn't depend on any Node or npm working. An npm ls --global --prefix call here hung for over two minutes. (P1, P2, P7)
A prefix's bin/claude is deleted only when it resolves into the package being removed. A claude link that points anywhere else is left alone. (P3)
ensure_cask runs before brew bundle, so the Brewfile entry finds the cask already installed. (P8, P9)
check_claude_on_path resolves every claude on PATH and drops duplicates, so a PATH listing /opt/homebrew/bin twice isn't reported as two copies. (P3)
RemovalFailed keeps the prefix, CaskInstallFailed keeps Homebrew's output, and ForeignClaude keeps each path with what it resolves to. Each is enough to find and remove the problem by hand.
Assertions 🛡️
Bash can't rule out any state ahead of time, so every guarantee above is a check that runs on each u.
FNM_DIR and HOMEBREW_PREFIX are set (boundary validation). They come from the shell environment. A missing one would make the scan skip a whole set of copies without anyone noticing, so the script stops and names the variable.
After a copy is removed, its package directory no longer exists (assertion). Only a bug in the removal can break this. It pins the positive side of the step.
A bin/claude that resolves outside the package is never deleted (assertion). This pins the negative side of the same step.
Every claude on PATH resolves into the cask's Caskroom, and at least one exists (boundary validation). Things outside this script can break it, such as Claude Code's own updater or a manual npm install, so it fails with a message rather than an assertion halt. This check is the runtime observer for P3.
Telemetry 📡
Is something putting npm copies back, such as Claude Code's updater still treating this machine as an npm install? The warning printed for each removed copy answers this. A warning on any u after a machine's first one means a copy came back.
Tests 🧪
The types and checks can't show that the script removes exactly the right copies, leaves everything else alone, or runs in the right order, so these need tests.
Expectation
Property
Technique
For any set of Node installs holding a copy, exactly those copies are removed and each gets a warning
P1, P7
Property
A bin/claude in a prefix that points somewhere other than the package survives
P3
Example
With no npm copies, nothing is removed and no warning prints
P8
Example
The cask is installed when absent and left alone when present
P7, P8
Example
A claude on PATH that resolves outside the cask fails the run and names both paths
P3
Example
The cask listed twice on PATH passes
P3
Example
No claude on PATH fails the run
P3
Example
A copy that can't be deleted fails the run and names its prefix
P3
Example
An unset FNM_DIR fails the run and names it
P1
Example
u runs Claude Code's update after node and before Homebrew
P8
Integration
Property: whether a copy should be removed depends only on whether its prefix holds one, so any subset of installs should give the same answer. A generator covers the empty, single and all-installs cases without listing each one.
Example: each of these is one named edge, such as a link that must survive or a variable that's missing, and is clearer stated once than generated.
Integration: the order only exists in features/update/tools.bash, so the test runs it over stub tool scripts and reads the order they ran in.
settings.json, the .npmrc lines and uninstall.bash don't get tests. I'll check them by hand: claude doctor should report a Homebrew install with package-manager updates on, and a real u should run clean on this Mac.
Fixed by 050a556f, which installs Claude Code from the claude-code@latest Homebrew cask and has u remove every npm copy. On the Air, /opt/homebrew/bin/claude resolves to /opt/homebrew/Caskroom/claude-code@latest/2.1.309/claude, there are no npm copies left under Homebrew's or fnm's Node, and CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 is set in tools/claude/config/settings.json. The work laptop hasn't been checked.
Activity
node: allow Claude Code's install script under npm 12
ooloth commentedon Oct 20, 2026
Approach
Install Claude Code from Homebrew's
claude-code@latestcask, using a newtools/claude/Brewfile, and stop installing it with npm. The cask downloads the same Anthropic binary that npm and the native installer use, so there's no new source to trust. The cask also pins that binary's sha256 in the Homebrew cask repo. SettingCLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1makes Claude Code runbrew upgradeon itself betweenuruns, which matches how it auto-updates today.uremoves every leftover npm copy (Homebrew's Node and each fnm Node version), and it fails if anyclaudeother than the cask's is on PATH.uninstall.bashand theallow-scriptslines in both.npmrcfiles are changed to match.What a good fix must achieve 🎯
clauderuns the same current version in every directory, whatever Node version that directory selectsclaudeufails if another one appearsuAssumptions 🤔
uupgrades Homebrew casks on the work laptop without failing #179 traces the cask failures to apps that Homebrew moves into/Applicationswith sudo.claude-codehas no app: it only links one binary into/opt/homebrew/bin. I haven't tried it on the work laptop.@latestcask gets each release within about a day. Anthropic's setup docs say it "receives new versions as soon as they ship". They also warn that Claude Code can announce an update before Homebrew has it.Constraints 🧱
/opt/homebrew/bin/claudeis currently Homebrew Node's npm copy (2.1.235). The cask can't link its binary there until that copy is gone.bincomes before/opt/homebrew/binon PATH. Any npm copy in the active Node therefore hides the cask's binary.Options ⚖️
claude-code@latestcask, npm copies removed (chosen)curl -fsSL https://claude.ai/install.sh | bash), updates itselfDecided by 🧭
Leaving npm is what gets P1 and P2, because each fnm Node keeps its own globals. Today there are four copies: v24.21.0 (2.1.304), v24.4.1 (2.1.251), v23.5.0, and Homebrew's Node (2.1.235). A project that pins a Node version without a copy, such as
michaeluloth.comon v25.1.0, silently falls through to the 2.1.235 copy. Of the two options that leave npm, the native installer runs a script piped from the network without checking it first. It then checks the binary against a manifest fetched from the same server, so a compromised server could serve a matching manifest. The cask's checksum lives in the Homebrew cask repo, and Homebrew is already how most tools here are installed. That separates the two on P4. I kept the@latestchannel because it's what npm installs today, and because Anthropic's docs say a newly launched model can need a version newer than the stable channel has.Deferred ⏳
CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATElives connects to Move Claude-Code-only exports from shell.zsh into settings.json env #174, which moves Claude-only exports fromshell.zshintosettings.jsonenv. I'd put the new variable insettings.jsonfrom the start.ooloth commentedon Oct 20, 2026
Design
urunstools/claude/update.bashbefore the Homebrew step, so Claude Code is moved off npm beforebrew bundlereads the Brewfile. It runs in four steps:claudelink that points into it, and prints a warning naming the copy.claude-code@latestcask if it isn't installed yet.claudeon PATH and fails, naming the path, if any of them resolves somewhere other than the cask.tools/claude/Brewfiledeclares the cask, sobrew bundleand any later Brewfile-based check know about it. TheCLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1variable insettings.jsonenvkeeps it updating between runs. Bash gives no types, so each step's guarantee is held by what the script checks before it moves on. Tests run the script in a sandbox with fake Node installs and stubbrew.uninstall.bashrunsbrew uninstall --cask claude-code@latestwithout--zap, because the cask's zap stanza deletes~/.claude.json.The approach's properties P1 to P6 still apply. The design adds these:
uon a machine that still has npm copies leaves it migrated, with no manual stepurun that leaves the machine migrated reports no failurebrew bundle, and the cleanup in #140 and lockfile in #168 if they land, know about ittools/claude/Types 🧬
Progressions considered:
claude/update.bashruns before the Homebrew step, removes npm copies, installs the cask if absent and checks PATH (chosen)claude/update.bashalone installs the caskP1 to P6 pass in every row, because every row ends with only the cask installed.
Decided by: Homebrew refuses to link a cask binary over an unrelated file. Its message is "It seems there is already a Binary at '/opt/homebrew/bin/claude'". On a machine that hasn't migrated,
brew bundletherefore fails unless the npm copies are already gone. The alphabetical order runs the cleanup too late, so each machine's firstureports a Homebrew failure (P8). Leaving the cask out of every Brewfile would mean the plannedbrew bundle --cleanup(#140) uninstalls it (P9). Removing copies by hand needs a manual step on each machine (P7). Moving Claude Code ahead of the Homebrew step infeatures/update/tools.bashis the only arrangement that passes all four. The new order is only needed for each machine's first run. Once both machines have migrated, Claude Code goes back to its alphabetical place as the last step of this issue, and #177 stays open until then.Progression:
remove_npm_copy, no Node install holds Claude Code, so selecting a different Node can't change whichclauderuns. A copy is deleted directly rather than throughnpm uninstall, so the cleanup doesn't depend on any Node or npm working. Annpm ls --global --prefixcall here hung for over two minutes. (P1, P2, P7)bin/claudeis deleted only when it resolves into the package being removed. Aclaudelink that points anywhere else is left alone. (P3)ensure_caskruns beforebrew bundle, so the Brewfile entry finds the cask already installed. (P8, P9)check_claude_on_pathresolves everyclaudeon PATH and drops duplicates, so a PATH listing/opt/homebrew/bintwice isn't reported as two copies. (P3)RemovalFailedkeeps the prefix,CaskInstallFailedkeeps Homebrew's output, andForeignClaudekeeps each path with what it resolves to. Each is enough to find and remove the problem by hand.Assertions 🛡️
Bash can't rule out any state ahead of time, so every guarantee above is a check that runs on each
u.FNM_DIRandHOMEBREW_PREFIXare set (boundary validation). They come from the shell environment. A missing one would make the scan skip a whole set of copies without anyone noticing, so the script stops and names the variable.bin/claudethat resolves outside the package is never deleted (assertion). This pins the negative side of the same step.claudeon PATH resolves into the cask's Caskroom, and at least one exists (boundary validation). Things outside this script can break it, such as Claude Code's own updater or a manualnpm install, so it fails with a message rather than an assertion halt. This check is the runtime observer for P3.Telemetry 📡
uafter a machine's first one means a copy came back.Tests 🧪
The types and checks can't show that the script removes exactly the right copies, leaves everything else alone, or runs in the right order, so these need tests.
bin/claudein a prefix that points somewhere other than the package survivesclaudeon PATH that resolves outside the cask fails the run and names both pathsclaudeon PATH fails the runFNM_DIRfails the run and names ituruns Claude Code's update after node and before Homebrewfeatures/update/tools.bash, so the test runs it over stub tool scripts and reads the order they ran in.settings.json, the.npmrclines anduninstall.bashdon't get tests. I'll check them by hand:claude doctorshould report a Homebrew install with package-manager updates on, and a realushould run clean on this Mac.claude: install claude code from the homebrew cask
standards: treat linked config edits as live on save
ooloth commentedon Oct 23, 2026
Fixed by
050a556f, which installs Claude Code from theclaude-code@latestHomebrew cask and hasuremove every npm copy. On the Air,/opt/homebrew/bin/clauderesolves to/opt/homebrew/Caskroom/claude-code@latest/2.1.309/claude, there are no npm copies left under Homebrew's or fnm's Node, andCLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1is set intools/claude/config/settings.json. The work laptop hasn't been checked.