Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions scripts/install_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,49 @@ def verify_claude_installation() -> tuple[bool, str | None, str]:
return result


def remove_npm_claude() -> bool:
"""Remove npm-installed Claude Code to prevent PATH conflicts.

Removes npm global installation of @anthropic-ai/claude-code using the
standard npm uninstall command. This eliminates PATH precedence issues
where npm version shadows native installation.

Should only be called AFTER verify_claude_installation() confirms
native installation success (source == 'native').

Returns:
True if npm installation was removed or did not exist, False if removal failed.

Note:
Safe to call even if npm is not installed - returns True in that case.
"""
npm_path = find_command_robust('npm')
if not npm_path:
# No npm found - nothing to uninstall
return True

info('Removing npm installation to prevent PATH conflicts...')

# Check if npm Claude package is actually installed
# npm list -g returns non-zero if package not found
check_result = run_command([npm_path, 'list', '-g', CLAUDE_NPM_PACKAGE])
if check_result.returncode != 0:
# Package not installed via npm
info('No npm Claude installation detected')
return True

# Perform uninstallation
result = run_command([npm_path, 'uninstall', '-g', CLAUDE_NPM_PACKAGE], capture_output=False)

if result.returncode == 0:
success('Removed npm Claude installation successfully')
return True

warning(f'npm uninstall returned code {result.returncode}')
warning('Manual removal may be needed: npm uninstall -g @anthropic-ai/claude-code')
return False


def parse_version(version_str: str) -> tuple[int, int, int] | None:
"""Parse version string to tuple."""
match = re.match(r'v?(\d+)\.(\d+)\.(\d+)', version_str)
Expand Down Expand Up @@ -1865,6 +1908,8 @@ def install_claude_native_windows(version: str | None = None) -> bool:
is_installed, claude_path, source = verify_claude_installation()
if is_installed and source == 'native':
success(f'Direct download installation verified at: {claude_path}')
# Remove npm installation to prevent PATH conflicts
remove_npm_claude()
return True
if is_installed:
warning(f'Claude found but from {source} source at: {claude_path}')
Expand Down Expand Up @@ -1968,6 +2013,8 @@ def _install_claude_native_windows_installer(version: str = 'latest') -> bool:
is_installed, claude_path, source = verify_claude_installation()
if is_installed and source == 'native':
success(f'Native installation verified at: {claude_path}')
# Remove npm installation to prevent PATH conflicts
remove_npm_claude()
return True
if is_installed:
warning(f'Claude found but from {source} source at: {claude_path}')
Expand Down Expand Up @@ -2053,6 +2100,9 @@ def _install_claude_native_macos_installer(version: str = 'latest') -> bool:
is_installed, claude_path, source = verify_claude_installation()
if is_installed:
success(f'Native installation verified at: {claude_path} (source: {source})')
# Remove npm installation to prevent PATH conflicts (only if native confirmed)
if source == 'native':
remove_npm_claude()
return True
warning('Native installation completed but Claude executable not found')
error('Installation verification failed')
Expand Down Expand Up @@ -2117,6 +2167,8 @@ def install_claude_native_macos(version: str | None = None) -> bool:
is_installed, claude_path, source = verify_claude_installation()
if is_installed and source == 'native':
success(f'Native installation verified at: {claude_path}')
# Remove npm installation to prevent PATH conflicts
remove_npm_claude()
return True
if is_installed:
warning(f'Claude found but from {source} source at: {claude_path}')
Expand Down Expand Up @@ -2203,6 +2255,9 @@ def _install_claude_native_linux_installer(version: str = 'latest') -> bool:
is_installed, claude_path, source = verify_claude_installation()
if is_installed:
success(f'Native installation verified at: {claude_path} (source: {source})')
# Remove npm installation to prevent PATH conflicts (only if native confirmed)
if source == 'native':
remove_npm_claude()
return True
warning('Native installation completed but Claude executable not found')
error('Installation verification failed')
Expand Down Expand Up @@ -2272,6 +2327,8 @@ def install_claude_native_linux(version: str | None = None) -> bool:
is_installed, claude_path, source = verify_claude_installation()
if is_installed and source == 'native':
success(f'Native installation verified at: {claude_path}')
# Remove npm installation to prevent PATH conflicts
remove_npm_claude()
return True
if is_installed:
warning(f'Claude found but from {source} source at: {claude_path}')
Expand Down Expand Up @@ -2414,7 +2471,8 @@ def ensure_claude() -> bool:
if post_install and post_source == 'native':
success('Successfully migrated from npm to native installation')
info(f'Version maintained: {requested_version}')
info('The npm installation can be removed with: npm uninstall -g @anthropic-ai/claude-code')
# Remove npm installation to prevent PATH conflicts
remove_npm_claude()
return True
warning('Migration attempted but native installation not detected')
warning(f'Continuing with npm installation at: {claude_path}')
Expand Down Expand Up @@ -2447,7 +2505,8 @@ def ensure_claude() -> bool:
info(f'Previous version: {pre_migration_version}')
new_version = get_claude_version()
info(f'Current version: {new_version}')
info('The npm installation can be removed with: npm uninstall -g @anthropic-ai/claude-code')
# Remove npm installation to prevent PATH conflicts
remove_npm_claude()
return True
warning('Migration attempted but native installation not detected')
warning(f'Continuing with npm installation at: {claude_path}')
Expand Down
94 changes: 94 additions & 0 deletions tests/test_install_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -1826,3 +1826,97 @@ def test_install_native_windows_calls_cleanup_at_start(self) -> None:
mock_cleanup.assert_called_once()
# Verify installer was called after cleanup
mock_installer.assert_called_once_with(version='latest')


class TestRemoveNpmClaude:
"""Test remove_npm_claude() function for automatic npm removal."""

@patch('install_claude.find_command_robust', return_value=None)
def test_remove_npm_claude_no_npm_installed(self, mock_find: MagicMock) -> None:
"""Test remove_npm_claude returns True when npm is not installed."""
result = install_claude.remove_npm_claude()

assert result is True
mock_find.assert_called_once_with('npm')

@patch('install_claude.run_command')
@patch('install_claude.find_command_robust')
def test_remove_npm_claude_npm_package_not_installed(
self, mock_find: MagicMock, mock_run: MagicMock,
) -> None:
"""Test remove_npm_claude returns True when npm exists but package not installed."""
mock_find.return_value = '/usr/local/bin/npm'
# npm list -g returns non-zero when package not found
mock_run.return_value = MagicMock(returncode=1)

result = install_claude.remove_npm_claude()

assert result is True
mock_find.assert_called_once_with('npm')
# Verify npm list was called to check package
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert args == ['/usr/local/bin/npm', 'list', '-g', '@anthropic-ai/claude-code']

@patch('install_claude.run_command')
@patch('install_claude.find_command_robust')
def test_remove_npm_claude_uninstall_success(
self, mock_find: MagicMock, mock_run: MagicMock,
) -> None:
"""Test remove_npm_claude returns True when npm uninstall succeeds."""
mock_find.return_value = '/usr/local/bin/npm'
# First call: npm list -g returns 0 (package found)
# Second call: npm uninstall -g returns 0 (success)
mock_run.side_effect = [
MagicMock(returncode=0), # npm list -g
MagicMock(returncode=0), # npm uninstall -g
]

result = install_claude.remove_npm_claude()

assert result is True
assert mock_run.call_count == 2
# Verify uninstall command was called with capture_output=False
uninstall_call = mock_run.call_args_list[1]
assert uninstall_call[0][0] == [
'/usr/local/bin/npm', 'uninstall', '-g', '@anthropic-ai/claude-code',
]
assert uninstall_call[1].get('capture_output') is False

@patch('install_claude.run_command')
@patch('install_claude.find_command_robust')
def test_remove_npm_claude_uninstall_failure(
self, mock_find: MagicMock, mock_run: MagicMock,
) -> None:
"""Test remove_npm_claude returns False when npm uninstall fails."""
mock_find.return_value = '/usr/local/bin/npm'
# First call: npm list -g returns 0 (package found)
# Second call: npm uninstall -g returns non-zero (failure)
mock_run.side_effect = [
MagicMock(returncode=0), # npm list -g
MagicMock(returncode=1), # npm uninstall -g fails
]

result = install_claude.remove_npm_claude()

assert result is False
assert mock_run.call_count == 2

@patch('install_claude.run_command')
@patch('install_claude.find_command_robust')
def test_remove_npm_claude_windows_npm_path(
self, mock_find: MagicMock, mock_run: MagicMock,
) -> None:
"""Test remove_npm_claude works with Windows npm path."""
mock_find.return_value = r'C:\Program Files\nodejs\npm.cmd'
mock_run.side_effect = [
MagicMock(returncode=0), # npm list -g
MagicMock(returncode=0), # npm uninstall -g
]

result = install_claude.remove_npm_claude()

assert result is True
# Verify Windows path was used correctly
args = mock_run.call_args_list[0][0][0]
assert args[0] == r'C:\Program Files\nodejs\npm.cmd'
12 changes: 12 additions & 0 deletions tests/test_install_claude_additional.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ class TestNativeWindowsInstallerFunction:
@patch('install_claude.run_command')
@patch('install_claude.verify_claude_installation')
@patch('install_claude.ensure_local_bin_in_path_windows')
@patch('install_claude.remove_npm_claude')
@patch('tempfile.NamedTemporaryFile')
@patch('os.unlink')
@patch('time.sleep')
Expand All @@ -804,6 +805,7 @@ def test_install_claude_native_windows_installer_success(
mock_sleep,
mock_unlink,
mock_temp,
mock_remove_npm,
mock_ensure_path,
mock_verify,
mock_run,
Expand All @@ -826,6 +828,7 @@ def test_install_claude_native_windows_installer_success(

mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
mock_verify.return_value = (True, 'C:\\Users\\Test\\.local\\bin\\claude.exe', 'native')
mock_remove_npm.return_value = True

result = install_claude._install_claude_native_windows_installer(version='latest')

Expand All @@ -834,6 +837,7 @@ def test_install_claude_native_windows_installer_success(
mock_sleep.assert_called()
mock_unlink.assert_called()
mock_ensure_path.assert_called()
mock_remove_npm.assert_called_once()
# Verify 'latest' was passed to the installer
call_args = mock_run.call_args[0][0]
assert 'latest' in call_args
Expand Down Expand Up @@ -1158,6 +1162,7 @@ class TestNativeMacOSInstallerFunction:
@patch('install_claude.urlopen')
@patch('install_claude.run_command')
@patch('install_claude.verify_claude_installation')
@patch('install_claude.remove_npm_claude')
@patch('tempfile.NamedTemporaryFile')
@patch('os.chmod')
@patch('os.unlink')
Expand All @@ -1168,6 +1173,7 @@ def test_install_claude_native_macos_installer_success(
mock_unlink,
mock_chmod,
mock_temp,
mock_remove_npm,
mock_verify,
mock_run,
mock_urlopen,
Expand All @@ -1185,6 +1191,7 @@ def test_install_claude_native_macos_installer_success(

mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
mock_verify.return_value = (True, '/Users/Test/.local/bin/claude', 'native')
mock_remove_npm.return_value = True

result = install_claude._install_claude_native_macos_installer(version='latest')

Expand All @@ -1193,6 +1200,7 @@ def test_install_claude_native_macos_installer_success(
mock_sleep.assert_called()
mock_unlink.assert_called()
mock_chmod.assert_called()
mock_remove_npm.assert_called_once()

@patch('install_claude.urlopen')
def test_install_claude_native_macos_installer_network_error(self, mock_urlopen):
Expand All @@ -1210,6 +1218,7 @@ class TestNativeLinuxInstallerFunction:
@patch('install_claude.urlopen')
@patch('install_claude.run_command')
@patch('install_claude.verify_claude_installation')
@patch('install_claude.remove_npm_claude')
@patch('tempfile.NamedTemporaryFile')
@patch('os.chmod')
@patch('os.unlink')
Expand All @@ -1220,6 +1229,7 @@ def test_install_claude_native_linux_installer_success(
mock_unlink,
mock_chmod,
mock_temp,
mock_remove_npm,
mock_verify,
mock_run,
mock_urlopen,
Expand All @@ -1237,6 +1247,7 @@ def test_install_claude_native_linux_installer_success(

mock_run.return_value = subprocess.CompletedProcess([], 0, '', '')
mock_verify.return_value = (True, '/home/test/.local/bin/claude', 'native')
mock_remove_npm.return_value = True

result = install_claude._install_claude_native_linux_installer(version='latest')

Expand All @@ -1245,6 +1256,7 @@ def test_install_claude_native_linux_installer_success(
mock_sleep.assert_called()
mock_unlink.assert_called()
mock_chmod.assert_called()
mock_remove_npm.assert_called_once()

@patch('install_claude.urlopen')
def test_install_claude_native_linux_installer_network_error(self, mock_urlopen):
Expand Down
Loading