Skip to content

Claude Code keeps working when the default Node or npm version changes #177

Closed

Description

@ooloth

Current state

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

Activity

  1. ooloth commented on Oct 20, 2026

    @ooloth
    OwnerAuthor

    Approach

    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.

    Options ⚖️

    Option P1 P2 P3 P4 P5 P6
    🏆 Homebrew claude-code@latest cask, npm copies removed (chosen) ✅ ✅ ✅ ✅ ✅ ✅
    Native installer (curl -fsSL https://claude.ai/install.sh | bash), updates itself ✅ ✅ ✅ ❌ ✅ ✅
    npm into Homebrew's Node only, fnm copies removed ✅ ❌ ✅ ❌ ✅ ✅
    Not yet: keep npm in the fnm default Node ❌ ❌ ❌ ❌ ✅ ✅

    Decided 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.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.

    Deferred ⏳

  2. ooloth commented on Oct 20, 2026

    @ooloth
    OwnerAuthor

    Design

    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:

    1. It finds every Node install that holds an npm copy of Claude Code. That means Homebrew's prefix plus each fnm Node version.
    2. It deletes each copy, along with the claude link that points into it, and prints a warning naming the copy.
    3. It installs the claude-code@latest cask if it isn't installed yet.
    4. 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.json env 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.

  3. ooloth commented on Oct 23, 2026

    @ooloth
    OwnerAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions