Skip to content
Open
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
145 changes: 108 additions & 37 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,27 +484,54 @@ 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;

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 = shellEnv.PNPM_HOME;
const voltaHome = shellEnv.VOLTA_HOME ? path.join(shellEnv.VOLTA_HOME, 'bin') : '';

const extraDirs = process.platform === 'win32'
? [
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'),
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'),
]
: [
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'),
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<number> {
Expand Down Expand Up @@ -770,34 +797,70 @@ 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();
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(home, '.yarn', 'bin'),
path.join(home, '.config', 'yarn', 'global', 'node_modules', '.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', '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'),
'/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'),
path.join(home, '.volta', 'bin'),
path.join(home, '.fnm', 'current', 'bin'),
path.join(home, '.nvm', 'current', 'bin'),
path.join(home, '.asdf', 'shims'),
shellEnv.PNPM_HOME || '',
path.join(home, '.local', 'share', 'pnpm'),
...(process.platform === 'darwin' ? [path.join(home, 'Library', 'pnpm')] : []),
];

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';
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 {
Expand All @@ -808,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';
}

Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/unit/platform-claude-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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'));
});
Comment on lines +6 to +16
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests verify presence of pnpm/yarn/compatibility paths, but they don't cover the priority/ordering semantics that findClaudeBinary() depends on (first successful candidate wins). Adding an assertion about the relative ordering (e.g. native dirs/candidates appearing before npm-family ones) would help prevent regressions where discovery starts preferring the wrong installation channel.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I added explicit ordering assertions in the path helper tests so they now cover the first-match priority that findClaudeBinary() depends on. Included in 1dfc6e7.


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'));
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', () => {
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'));
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',
);
});
});
Loading