fix: detect Claude CLI installed via NVM on Linux/macOS (#623)

* fix: detect Claude CLI installed via NVM on Linux/macOS

When the Electron app launches from a GUI environment (not a terminal),
NVM is not sourced in the shell environment. This causes the npm-based
CLI detection to fail because npm itself is not in PATH.

Added explicit NVM path detection that scans ~/.nvm/versions/node/ for
installed Node versions and checks for the Claude CLI in each version's
bin directory. This ensures Claude CLI installed via npm global install
under NVM can be found regardless of how the app was launched.

Changes:
- Added NVM path scanning in cli-tool-manager.ts
- Added 'nvm' to ToolDetectionResult source type
- Added i18n labels for NVM source (en/fr)
- Added unit tests for NVM detection logic

Fixes Claude CLI detection for users who installed Claude Code via
`npm install -g @anthropic-ai/claude-code` under NVM on Linux/macOS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: prefer newest NVM Node version when locating CLI

---------

Co-authored-by: CraigR <stillknotknown@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-05 13:19:39 +02:00
committed by GitHub
parent 6fb2d48433
commit c27135436d
6 changed files with 363 additions and 1 deletions
@@ -0,0 +1,314 @@
/**
* Unit tests for cli-tool-manager
* Tests CLI tool detection with focus on NVM path detection
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync, readdirSync } from 'fs';
import os from 'os';
import { execFileSync } from 'child_process';
import { app } from 'electron';
import { getToolInfo, clearToolCache } from '../cli-tool-manager';
// Mock Electron app
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: vi.fn()
}
}));
// Mock os module
vi.mock('os', () => ({
default: {
homedir: vi.fn(() => '/mock/home')
}
}));
// Mock fs module - need to mock both sync and promises
vi.mock('fs', () => {
const mockDirent = (
name: string,
isDir: boolean
): { name: string; isDirectory: () => boolean } => ({
name,
isDirectory: () => isDir
});
return {
existsSync: vi.fn(),
readdirSync: vi.fn(),
promises: {}
};
});
// Mock child_process for execFileSync (used in validation)
vi.mock('child_process', () => ({
execFileSync: vi.fn()
}));
// Mock env-utils to avoid PATH augmentation complexity
vi.mock('../env-utils', () => ({
findExecutable: vi.fn(() => null), // Return null to force platform-specific path checking
getAugmentedEnv: vi.fn(() => ({ PATH: '' }))
}));
// Mock homebrew-python utility
vi.mock('../utils/homebrew-python', () => ({
findHomebrewPython: vi.fn(() => null)
}));
describe('cli-tool-manager - Claude CLI NVM detection', () => {
beforeEach(() => {
vi.clearAllMocks();
// Set default platform to Linux
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true
});
});
afterEach(() => {
clearToolCache();
});
const mockHomeDir = '/mock/home';
describe('NVM path detection on Unix/Linux/macOS', () => {
it('should detect Claude CLI in NVM directory when multiple Node versions exist', () => {
// Mock home directory
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
// Mock NVM directory exists
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
// NVM versions directory exists
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
// Claude CLI exists in v22.17.0
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
// Mock readdirSync to return Node version directories
vi.mocked(readdirSync).mockImplementation((filePath, options) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return [
{ name: 'v20.11.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true }
] as any;
}
return [] as any;
});
// Mock execFileSync to return version for validation
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.path).toContain('v22.17.0');
expect(result.path).toContain('bin/claude');
expect(result.source).toBe('nvm');
});
it('should try multiple NVM Node versions until finding Claude CLI', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
// Only v24.12.0 has Claude CLI
if (pathStr.includes('v24.12.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return [
{ name: 'v18.20.0', isDirectory: () => true },
{ name: 'v20.11.0', isDirectory: () => true },
{ name: 'v24.12.0', isDirectory: () => true }
] as any;
}
return [] as any;
});
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.path).toContain('v24.12.0');
expect(result.source).toBe('nvm');
});
it('should skip non-version directories in NVM (e.g., does not start with "v")', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
// Only the correctly named version has Claude
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return [
{ name: 'current', isDirectory: () => true }, // Should be skipped
{ name: 'system', isDirectory: () => true }, // Should be skipped
{ name: 'v22.17.0', isDirectory: () => true } // Should be checked
] as any;
}
return [] as any;
});
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.path).toContain('v22.17.0');
});
it('should not check NVM paths on Windows', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true
});
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\test');
// Even if NVM directory exists on Windows, should not check it
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(readdirSync).mockReturnValue([]);
const result = getToolInfo('claude');
// Should not be found from NVM on Windows
expect(result.source).not.toBe('nvm');
});
it('should handle missing NVM directory gracefully', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
// NVM directory does not exist
vi.mocked(existsSync).mockReturnValue(false);
const result = getToolInfo('claude');
// Should not find via NVM
expect(result.source).not.toBe('nvm');
expect(result.found).toBe(false);
});
it('should handle readdirSync errors gracefully', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readdirSync).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = getToolInfo('claude');
// Should not crash, should fall back to other detection methods
expect(result.source).not.toBe('nvm');
});
it('should validate Claude CLI before returning NVM path', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation(() => {
return [{ name: 'v22.17.0', isDirectory: () => true }] as any;
});
// Mock validation failure (execFileSync throws)
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('Command failed');
});
const result = getToolInfo('claude');
// Should not return unvalidated path
expect(result.found).toBe(false);
expect(result.source).not.toBe('nvm');
});
it('should handle NVM directory with no version subdirectories', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
return String(filePath).includes('.nvm/versions/node');
});
// Empty NVM directory
vi.mocked(readdirSync).mockReturnValue([]);
const result = getToolInfo('claude');
expect(result.source).not.toBe('nvm');
});
});
describe('NVM on macOS', () => {
it('should detect Claude CLI via NVM on macOS', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true
});
vi.mocked(os.homedir).mockReturnValue('/Users/test');
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation(() => {
return [{ name: 'v22.17.0', isDirectory: () => true }] as any;
});
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.source).toBe('nvm');
expect(result.path).toContain('v22.17.0');
});
});
});
+45 -1
View File
@@ -21,7 +21,7 @@
*/
import { execFileSync } from 'child_process';
import { existsSync } from 'fs';
import { existsSync, readdirSync } from 'fs';
import path from 'path';
import os from 'os';
import { app } from 'electron';
@@ -594,6 +594,50 @@ class CLIToolManager {
path.join(homeDir, 'bin', 'claude'),
];
// 4.5. NVM (Node Version Manager) paths for Unix/Linux/macOS
// NVM installs global npm packages in ~/.nvm/versions/node/vX.X.X/bin/
// This is important when the app launches from GUI without NVM sourced
if (process.platform !== 'win32') {
const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node');
try {
if (existsSync(nvmVersionsDir)) {
const nodeVersions = readdirSync(nvmVersionsDir, { withFileTypes: true });
const versionDirs = nodeVersions
.filter((entry) => entry.isDirectory() && entry.name.startsWith('v'))
.sort((a, b) => {
const vA = a.name.slice(1).split('.').map(Number);
const vB = b.name.slice(1).split('.').map(Number);
for (let i = 0; i < 3; i++) {
const diff = (vB[i] ?? 0) - (vA[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
});
for (const entry of versionDirs) {
const nvmClaudePath = path.join(nvmVersionsDir, entry.name, 'bin', 'claude');
if (existsSync(nvmClaudePath)) {
const validation = this.validateClaude(nvmClaudePath);
if (validation.valid) {
return {
found: true,
path: nvmClaudePath,
version: validation.version,
source: 'nvm',
message: `Using NVM Claude CLI: ${nvmClaudePath}`,
};
}
}
}
}
} catch (error) {
// Silently fail if unable to read NVM directory
console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`);
}
}
for (const claudePath of platformPaths) {
if (existsSync(claudePath)) {
const validation = this.validateClaude(claudePath);
@@ -59,6 +59,7 @@ function ToolDetectionDisplay({ info, isLoading, t }: ToolDetectionDisplayProps)
'user-config': t('general.sourceUserConfig'),
'venv': t('general.sourceVenv'),
'homebrew': t('general.sourceHomebrew'),
'nvm': t('general.sourceNvm'),
'system-path': t('general.sourceSystemPath'),
'bundled': t('general.sourceBundled'),
'fallback': t('general.sourceFallback'),
@@ -217,6 +217,7 @@
"sourceUserConfig": "User Configuration",
"sourceVenv": "Virtual Environment",
"sourceHomebrew": "Homebrew",
"sourceNvm": "NVM",
"sourceSystemPath": "System PATH",
"sourceBundled": "Bundled",
"sourceFallback": "Fallback",
@@ -217,6 +217,7 @@
"sourceUserConfig": "Configuration utilisateur",
"sourceVenv": "Environnement virtuel",
"sourceHomebrew": "Homebrew",
"sourceNvm": "NVM",
"sourceSystemPath": "PATH système",
"sourceBundled": "Intégré",
"sourceFallback": "Valeur par défaut",
+1
View File
@@ -17,6 +17,7 @@ export interface ToolDetectionResult {
| 'user-config'
| 'venv'
| 'homebrew'
| 'nvm'
| 'system-path'
| 'bundled'
| 'fallback';