From 572a5716ef5f47a624096266bc2c8be46ebb8752 Mon Sep 17 00:00:00 2001 From: Weilin Cai <1261249659@qq.com> Date: Sun, 5 Apr 2026 12:44:05 +0800 Subject: [PATCH 1/2] fix: broaden Claude CLI discovery paths --- electron/main.ts | 123 ++++++++++----- .../unit/platform-claude-paths.test.ts | 51 +++++++ src/lib/platform.ts | 143 +++++++++++------- 3 files changed, 227 insertions(+), 90 deletions(-) create mode 100644 src/__tests__/unit/platform-claude-paths.test.ts diff --git a/electron/main.ts b/electron/main.ts index 1165a590..b2403692 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -486,25 +486,51 @@ function getExpandedShellPath(): string { const home = os.homedir(); const shellPath = userShellEnv.PATH || process.env.PATH || ''; const sep = path.delimiter; - - if (process.platform === 'win32') { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const winExtra = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - ]; - const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); - return [...new Set(allParts)].join(sep); - } else { - const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; - const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; - const allParts = raw.split(':').filter(Boolean); - return [...new Set(allParts)].join(':'); - } + const pnpmHome = process.env.PNPM_HOME; + const voltaHome = process.env.VOLTA_HOME ? path.join(process.env.VOLTA_HOME, 'bin') : ''; + + const extraDirs = process.platform === 'win32' + ? [ + path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), + path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), + path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), + path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), + pnpmHome, + path.join(home, '.npm-global', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + voltaHome, + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + ] + : [ + '/usr/local/bin', + '/opt/homebrew/bin', + '/usr/bin', + '/bin', + path.join(home, '.npm-global', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + path.join(home, '.volta', 'bin'), + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + pnpmHome, + path.join(home, '.local', 'share', 'pnpm'), + ...(process.platform === 'darwin' ? [path.join(home, 'Library', 'pnpm')] : []), + ]; + + const allParts = [shellPath, ...extraDirs].join(sep).split(sep).filter(Boolean); + return [...new Set(allParts)].join(sep); } function getPort(): Promise { @@ -773,26 +799,53 @@ app.whenReady().then(async () => { // Candidate paths — native first, then bun, then homebrew, then npm const home = os.homedir(); - const candidatePaths = process.platform === 'win32' + const claudeSearchDirs = process.platform === 'win32' ? [ - path.join(home, '.local', 'bin', 'claude.exe'), - path.join(home, '.local', 'bin', 'claude.cmd'), - path.join(home, '.claude', 'bin', 'claude.exe'), - path.join(home, '.claude', 'bin', 'claude.cmd'), - path.join(home, '.bun', 'bin', 'claude.exe'), - path.join(home, '.bun', 'bin', 'claude.cmd'), - path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'), - path.join(process.env.LOCALAPPDATA || '', 'npm', 'claude.cmd'), - ].filter(p => p && !p.startsWith(path.sep)) + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), + path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), + path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), + path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), + process.env.PNPM_HOME || '', + path.join(home, '.npm-global', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + process.env.VOLTA_HOME ? path.join(process.env.VOLTA_HOME, 'bin') : '', + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + ] : [ - path.join(home, '.local', 'bin', 'claude'), - path.join(home, '.claude', 'bin', 'claude'), - path.join(home, '.bun', 'bin', 'claude'), - '/opt/homebrew/bin/claude', - '/usr/local/bin/claude', - path.join(home, '.npm-global', 'bin', 'claude'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + path.join(home, '.npm-global', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + path.join(home, '.volta', 'bin'), + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + process.env.PNPM_HOME || '', + path.join(home, '.local', 'share', 'pnpm'), + ...(process.platform === 'darwin' ? [path.join(home, 'Library', 'pnpm')] : []), + '/opt/homebrew/bin', + '/usr/local/bin', ]; + const candidatePaths = process.platform === 'win32' + ? [...new Set(claudeSearchDirs.filter(Boolean).flatMap(dir => [ + path.join(dir, 'claude.exe'), + path.join(dir, 'claude.cmd'), + path.join(dir, 'claude.bat'), + path.join(dir, 'claude'), + ]))] + : [...new Set(claudeSearchDirs.filter(Boolean).map(dir => path.join(dir, 'claude')))]; + function classifyPath(p: string): 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown' { const n = p.replace(/\\/g, '/'); if (n.includes('/.local/bin/') || n.includes('/.claude/bin/')) return 'native'; diff --git a/src/__tests__/unit/platform-claude-paths.test.ts b/src/__tests__/unit/platform-claude-paths.test.ts new file mode 100644 index 00000000..af428576 --- /dev/null +++ b/src/__tests__/unit/platform-claude-paths.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { getClaudeCandidatePathsFor, getExtraPathDirsFor } from '../../lib/platform'; + +describe('Claude path discovery helpers', () => { + it('darwin search dirs include pnpm, yarn, and claude local compatibility paths', () => { + const home = '/Users/tester'; + const dirs = getExtraPathDirsFor('darwin', home, { + PNPM_HOME: '/Users/tester/Library/pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(dirs.includes('/Users/tester/Library/pnpm')); + assert.ok(dirs.includes('/Users/tester/.claude/local')); + assert.ok(dirs.includes('/Users/tester/.yarn/bin')); + }); + + it('darwin candidates include executable paths for pnpm and claude local wrappers', () => { + const home = '/Users/tester'; + const candidates = getClaudeCandidatePathsFor('darwin', home, { + PNPM_HOME: '/Users/tester/Library/pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(candidates.includes('/Users/tester/Library/pnpm/claude')); + assert.ok(candidates.includes('/Users/tester/.claude/local/claude')); + }); + + it('win32 search dirs include pnpm and claude local compatibility paths', () => { + const home = 'C:\\Users\\tester'; + const dirs = getExtraPathDirsFor('win32', home, { + APPDATA: 'C:\\Users\\tester\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', + PNPM_HOME: 'C:\\Users\\tester\\AppData\\Local\\pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(dirs.includes('C:\\Users\\tester\\AppData\\Local\\pnpm')); + assert.ok(dirs.includes('C:\\Users\\tester\\.claude\\local')); + }); + + it('win32 candidates include pnpm cmd wrappers', () => { + const home = 'C:\\Users\\tester'; + const candidates = getClaudeCandidatePathsFor('win32', home, { + APPDATA: 'C:\\Users\\tester\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', + PNPM_HOME: 'C:\\Users\\tester\\AppData\\Local\\pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(candidates.includes('C:\\Users\\tester\\AppData\\Local\\pnpm\\claude.cmd')); + assert.ok(candidates.includes('C:\\Users\\tester\\.claude\\local\\claude.cmd')); + }); +}); diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 416d52c4..0e490d20 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -62,31 +62,80 @@ export function getRuntimeArchitectureInfo(): RuntimeArchitectureInfo { * Extra PATH directories to search for Claude CLI and other tools. */ export function getExtraPathDirs(): string[] { - const home = os.homedir(); - if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - return [ - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.bun', 'bin'), - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), - ]; + return getExtraPathDirsFor(process.platform, os.homedir(), process.env); +} + +export function getExtraPathDirsFor( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const pnpmHome = env.PNPM_HOME; + const voltaHome = env.VOLTA_HOME ? pathApi.join(env.VOLTA_HOME, 'bin') : undefined; + + const extras = platform === 'win32' + ? [ + pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'npm'), + pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'npm'), + pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'pnpm'), + pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'pnpm'), + pnpmHome, + pathApi.join(home, '.npm-global', 'bin'), + pathApi.join(home, '.local', 'bin'), + pathApi.join(home, '.claude', 'bin'), + pathApi.join(home, '.claude', 'local'), + pathApi.join(home, '.bun', 'bin'), + pathApi.join(home, '.yarn', 'bin'), + pathApi.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + voltaHome, + pathApi.join(home, '.fnm', 'current', 'bin'), + pathApi.join(home, '.nvm', 'current', 'bin'), + pathApi.join(home, '.asdf', 'shims'), + ] + : [ + pathApi.join(home, '.local', 'bin'), + pathApi.join(home, '.claude', 'bin'), + pathApi.join(home, '.claude', 'local'), + pathApi.join(home, '.bun', 'bin'), + pathApi.join(home, '.npm-global', 'bin'), + pathApi.join(home, '.yarn', 'bin'), + pathApi.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + pathApi.join(home, '.volta', 'bin'), + pathApi.join(home, '.fnm', 'current', 'bin'), + pathApi.join(home, '.nvm', 'current', 'bin'), + pathApi.join(home, '.asdf', 'shims'), + pnpmHome, + pathApi.join(home, '.local', 'share', 'pnpm'), + ...(platform === 'darwin' ? [pathApi.join(home, 'Library', 'pnpm')] : []), + '/usr/local/bin', + '/opt/homebrew/bin', + '/usr/bin', + '/bin', + ]; + + return [...new Set(extras.filter((dir): dir is string => !!dir))]; +} + +export function getClaudeCandidatePathsFor( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const dirs = getExtraPathDirsFor(platform, home, env); + if (platform === 'win32') { + const exts = ['.cmd', '.exe', '.bat', '']; + const candidates: string[] = []; + for (const dir of dirs) { + for (const ext of exts) { + candidates.push(pathApi.join(dir, 'claude' + ext)); + } + } + return [...new Set(candidates)]; } - return [ - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.bun', 'bin'), - '/usr/local/bin', - '/opt/homebrew/bin', - '/usr/bin', - '/bin', - path.join(home, '.npm-global', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), - ]; + + return [...new Set(dirs.map(dir => pathApi.join(dir, 'claude')))]; } /** @@ -100,10 +149,22 @@ export function classifyClaudePath(binPath: string): ClaudeInstallType { // Native installer: ~/.local/bin/claude or ~/.claude/bin/claude if (normalized.includes('/.local/bin/')) return 'native'; if (normalized.includes('/.claude/bin/')) return 'native'; + if (normalized.includes('/.claude/local/')) return 'native'; // Bun: ~/.bun/bin/claude if (normalized.includes('/.bun/bin/') || normalized.includes('/.bun/install/')) return 'bun'; // Homebrew: /opt/homebrew/bin or /usr/local/Cellar or homebrew in path if (normalized.includes('/homebrew/') || normalized.includes('/Cellar/')) return 'homebrew'; + // npm-family: npm/pnpm/yarn/Node manager shims that resolve to the same package channel + if (normalized.includes('/Library/pnpm/') + || normalized.includes('/.local/share/pnpm/') + || normalized.includes('/.config/yarn/') + || normalized.includes('/.yarn/bin/') + || normalized.includes('/.volta/bin/') + || normalized.includes('/.fnm/current/bin/') + || normalized.includes('/.asdf/shims/') + || normalized.includes('/.nvm/current/bin/')) { + return 'npm'; + } // npm: npm-global, .npm, AppData/npm if (normalized.includes('/npm') || normalized.includes('npm-global')) return 'npm'; if (normalized === '/usr/local/bin/claude') { @@ -121,6 +182,8 @@ export function classifyClaudePath(binPath: string): ClaudeInstallType { const localAppData = (process.env.LOCALAPPDATA || '').replace(/\\/g, '/'); if (appData && normalized.startsWith(appData + '/npm')) return 'npm'; if (localAppData && normalized.startsWith(localAppData + '/npm')) return 'npm'; + if (appData && normalized.startsWith(appData + '/pnpm')) return 'npm'; + if (localAppData && normalized.startsWith(localAppData + '/pnpm')) return 'npm'; if (normalized.includes(home.replace(/\\/g, '/') + '/.local/bin')) return 'native'; } return 'unknown'; @@ -131,37 +194,7 @@ export function classifyClaudePath(binPath: string): ClaudeInstallType { * Priority: native install > homebrew > npm (deprecated). */ export function getClaudeCandidatePaths(): string[] { - const home = os.homedir(); - if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const exts = ['.cmd', '.exe', '.bat', '']; - // Native first, then bun, then npm paths - const baseDirs = [ - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.bun', 'bin'), - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - ]; - const candidates: string[] = []; - for (const dir of baseDirs) { - for (const ext of exts) { - candidates.push(path.join(dir, 'claude' + ext)); - } - } - return candidates; - } - // macOS/Linux: native first, then bun, then homebrew, then npm paths - return [ - path.join(home, '.local', 'bin', 'claude'), // native installer - path.join(home, '.claude', 'bin', 'claude'), // native alt - path.join(home, '.bun', 'bin', 'claude'), // bun global - '/opt/homebrew/bin/claude', // homebrew (Apple Silicon) - '/usr/local/bin/claude', // homebrew (Intel) or npm global - path.join(home, '.npm-global', 'bin', 'claude'), // npm custom prefix - ]; + return getClaudeCandidatePathsFor(process.platform, os.homedir(), process.env); } export interface ClaudeInstallInfo { From 1dfc6e764ff5c543d7855602b630a14a3f197f69 Mon Sep 17 00:00:00 2001 From: Weilin Cai <1261249659@qq.com> Date: Sun, 5 Apr 2026 13:05:20 +0800 Subject: [PATCH 2/2] fix: preserve Claude CLI discovery priority --- electron/main.ts | 68 ++++++++++++------- .../unit/platform-claude-paths.test.ts | 13 ++++ src/lib/platform.ts | 44 +++++++----- 3 files changed, 84 insertions(+), 41 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index b2403692..76ededff 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -484,19 +484,14 @@ function findGitBashSync(): boolean { */ function getExpandedShellPath(): string { const home = os.homedir(); - const shellPath = userShellEnv.PATH || process.env.PATH || ''; + const shellEnv = { ...process.env, ...userShellEnv }; + const shellPath = shellEnv.PATH || process.env.PATH || ''; const sep = path.delimiter; - const pnpmHome = process.env.PNPM_HOME; - const voltaHome = process.env.VOLTA_HOME ? path.join(process.env.VOLTA_HOME, 'bin') : ''; + const pnpmHome = shellEnv.PNPM_HOME; + const voltaHome = shellEnv.VOLTA_HOME ? path.join(shellEnv.VOLTA_HOME, 'bin') : ''; const extraDirs = process.platform === 'win32' ? [ - path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), - path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), - path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), - path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), - pnpmHome, - path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin'), path.join(home, '.claude', 'bin'), path.join(home, '.claude', 'local'), @@ -507,17 +502,23 @@ function getExpandedShellPath(): string { path.join(home, '.fnm', 'current', 'bin'), path.join(home, '.nvm', 'current', 'bin'), path.join(home, '.asdf', 'shims'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), + pnpmHome, + path.join(home, '.npm-global', 'bin'), ] : [ - '/usr/local/bin', - '/opt/homebrew/bin', - '/usr/bin', - '/bin', - path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin'), path.join(home, '.claude', 'bin'), path.join(home, '.claude', 'local'), path.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + path.join(home, '.npm-global', 'bin'), path.join(home, '.yarn', 'bin'), path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), path.join(home, '.volta', 'bin'), @@ -796,6 +797,7 @@ app.whenReady().then(async () => { ipcMain.handle('install:check-prerequisites', async () => { const expandedPath = getExpandedShellPath(); const execEnv = { ...sanitizedProcessEnv(), ...userShellEnv, PATH: expandedPath }; + const shellEnv = { ...process.env, ...userShellEnv }; // Candidate paths — native first, then bun, then homebrew, then npm const home = os.homedir(); @@ -805,24 +807,26 @@ app.whenReady().then(async () => { path.join(home, '.claude', 'bin'), path.join(home, '.claude', 'local'), path.join(home, '.bun', 'bin'), - path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), - path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), - path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), - path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), - process.env.PNPM_HOME || '', - path.join(home, '.npm-global', 'bin'), path.join(home, '.yarn', 'bin'), path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), - process.env.VOLTA_HOME ? path.join(process.env.VOLTA_HOME, 'bin') : '', + shellEnv.VOLTA_HOME ? path.join(shellEnv.VOLTA_HOME, 'bin') : '', path.join(home, '.fnm', 'current', 'bin'), path.join(home, '.nvm', 'current', 'bin'), path.join(home, '.asdf', 'shims'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), + shellEnv.PNPM_HOME || '', + path.join(home, '.npm-global', 'bin'), ] : [ path.join(home, '.local', 'bin'), path.join(home, '.claude', 'bin'), path.join(home, '.claude', 'local'), path.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', path.join(home, '.npm-global', 'bin'), path.join(home, '.yarn', 'bin'), path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), @@ -830,11 +834,9 @@ app.whenReady().then(async () => { path.join(home, '.fnm', 'current', 'bin'), path.join(home, '.nvm', 'current', 'bin'), path.join(home, '.asdf', 'shims'), - process.env.PNPM_HOME || '', + shellEnv.PNPM_HOME || '', path.join(home, '.local', 'share', 'pnpm'), ...(process.platform === 'darwin' ? [path.join(home, 'Library', 'pnpm')] : []), - '/opt/homebrew/bin', - '/usr/local/bin', ]; const candidatePaths = process.platform === 'win32' @@ -848,9 +850,17 @@ app.whenReady().then(async () => { function classifyPath(p: string): 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown' { const n = p.replace(/\\/g, '/'); - if (n.includes('/.local/bin/') || n.includes('/.claude/bin/')) return 'native'; + if (n.includes('/.local/bin/') || n.includes('/.claude/bin/') || n.includes('/.claude/local/')) return 'native'; if (n.includes('/.bun/bin/') || n.includes('/.bun/install/')) return 'bun'; if (n.includes('/homebrew/') || n.includes('/Cellar/')) return 'homebrew'; + if (n.includes('/Library/pnpm/') + || n.includes('/.local/share/pnpm/') + || n.includes('/.config/yarn/') + || n.includes('/.yarn/bin/') + || n.includes('/.volta/bin/') + || n.includes('/.fnm/current/bin/') + || n.includes('/.asdf/shims/') + || n.includes('/.nvm/current/bin/')) return 'npm'; if (n.includes('/npm')) return 'npm'; if (n === '/usr/local/bin/claude') { try { @@ -861,6 +871,14 @@ app.whenReady().then(async () => { } catch { /* ignore */ } return 'unknown'; } + if (process.platform === 'win32') { + const appData = (shellEnv.APPDATA || '').replace(/\\/g, '/'); + const localAppData = (shellEnv.LOCALAPPDATA || '').replace(/\\/g, '/'); + if (appData && n.startsWith(appData + '/npm')) return 'npm'; + if (localAppData && n.startsWith(localAppData + '/npm')) return 'npm'; + if (appData && n.startsWith(appData + '/pnpm')) return 'npm'; + if (localAppData && n.startsWith(localAppData + '/pnpm')) return 'npm'; + } return 'unknown'; } diff --git a/src/__tests__/unit/platform-claude-paths.test.ts b/src/__tests__/unit/platform-claude-paths.test.ts index af428576..7a17c394 100644 --- a/src/__tests__/unit/platform-claude-paths.test.ts +++ b/src/__tests__/unit/platform-claude-paths.test.ts @@ -23,6 +23,14 @@ describe('Claude path discovery helpers', () => { assert.ok(candidates.includes('/Users/tester/Library/pnpm/claude')); assert.ok(candidates.includes('/Users/tester/.claude/local/claude')); + assert.ok( + candidates.indexOf('/opt/homebrew/bin/claude') < candidates.indexOf('/Users/tester/Library/pnpm/claude'), + 'homebrew candidates should stay ahead of npm-family shims', + ); + assert.ok( + candidates.indexOf('/Users/tester/.claude/local/claude') < candidates.indexOf('/Users/tester/Library/pnpm/claude'), + 'native compatibility wrappers should stay ahead of pnpm shims', + ); }); it('win32 search dirs include pnpm and claude local compatibility paths', () => { @@ -47,5 +55,10 @@ describe('Claude path discovery helpers', () => { assert.ok(candidates.includes('C:\\Users\\tester\\AppData\\Local\\pnpm\\claude.cmd')); assert.ok(candidates.includes('C:\\Users\\tester\\.claude\\local\\claude.cmd')); + assert.ok( + candidates.indexOf('C:\\Users\\tester\\.claude\\local\\claude.cmd') < + candidates.indexOf('C:\\Users\\tester\\AppData\\Local\\pnpm\\claude.cmd'), + 'native compatibility wrappers should stay ahead of pnpm shims on Windows', + ); }); }); diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 0e490d20..d674946b 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -65,23 +65,21 @@ export function getExtraPathDirs(): string[] { return getExtraPathDirsFor(process.platform, os.homedir(), process.env); } -export function getExtraPathDirsFor( +function getPathApiForPlatform(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix; +} + +export function getClaudeSearchDirsFor( platform: NodeJS.Platform, home: string, env: NodeJS.ProcessEnv = process.env, ): string[] { - const pathApi = platform === 'win32' ? path.win32 : path.posix; + const pathApi = getPathApiForPlatform(platform); const pnpmHome = env.PNPM_HOME; const voltaHome = env.VOLTA_HOME ? pathApi.join(env.VOLTA_HOME, 'bin') : undefined; - const extras = platform === 'win32' + const dirs = platform === 'win32' ? [ - pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'npm'), - pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'npm'), - pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'pnpm'), - pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'pnpm'), - pnpmHome, - pathApi.join(home, '.npm-global', 'bin'), pathApi.join(home, '.local', 'bin'), pathApi.join(home, '.claude', 'bin'), pathApi.join(home, '.claude', 'local'), @@ -92,12 +90,22 @@ export function getExtraPathDirsFor( pathApi.join(home, '.fnm', 'current', 'bin'), pathApi.join(home, '.nvm', 'current', 'bin'), pathApi.join(home, '.asdf', 'shims'), + pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'npm'), + pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'npm'), + pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'pnpm'), + pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'pnpm'), + pnpmHome, + pathApi.join(home, '.npm-global', 'bin'), ] : [ pathApi.join(home, '.local', 'bin'), pathApi.join(home, '.claude', 'bin'), pathApi.join(home, '.claude', 'local'), pathApi.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', pathApi.join(home, '.npm-global', 'bin'), pathApi.join(home, '.yarn', 'bin'), pathApi.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), @@ -108,13 +116,17 @@ export function getExtraPathDirsFor( pnpmHome, pathApi.join(home, '.local', 'share', 'pnpm'), ...(platform === 'darwin' ? [pathApi.join(home, 'Library', 'pnpm')] : []), - '/usr/local/bin', - '/opt/homebrew/bin', - '/usr/bin', - '/bin', ]; - return [...new Set(extras.filter((dir): dir is string => !!dir))]; + return [...new Set(dirs.filter((dir): dir is string => !!dir))]; +} + +export function getExtraPathDirsFor( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + return getClaudeSearchDirsFor(platform, home, env); } export function getClaudeCandidatePathsFor( @@ -122,8 +134,8 @@ export function getClaudeCandidatePathsFor( home: string, env: NodeJS.ProcessEnv = process.env, ): string[] { - const pathApi = platform === 'win32' ? path.win32 : path.posix; - const dirs = getExtraPathDirsFor(platform, home, env); + const pathApi = getPathApiForPlatform(platform); + const dirs = getClaudeSearchDirsFor(platform, home, env); if (platform === 'win32') { const exts = ['.cmd', '.exe', '.bat', '']; const candidates: string[] = [];