diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index 65e773d96..e88d432d7 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -158,6 +158,16 @@ describe('resolveClaudeCommand', () => { expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(bunPath) }) + it('finds native Windows claude.exe in user-local bin', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const nativePath = join(root, '.local', 'bin', 'claude.exe') + makeExecutable(nativePath) + + expect(resolveClaudeCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe( + nativePath + ) + }) + it('returns the bare command when no filesystem candidate exists', () => { const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) @@ -195,4 +205,12 @@ describe('getVersionManagerBinPaths', () => { expect(paths).toContain(join(root, '.local', 'share', 'pnpm')) expect(paths).not.toContain(join(root, 'Library', 'pnpm')) }) + + it('includes Windows user-local bin for native CLI installers', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root }) + + expect(paths).toContain(join(root, '.local', 'bin')) + expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm')) + }) }) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 2215583ac..c11cf07ff 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -98,6 +98,9 @@ function getVersionManagerDirectories( } if (platform === 'win32') { + // Why: Anthropic's native Windows installer places claude.exe here, and + // GUI-launched Orca may not inherit the user's PATH entry for it. + directories.push(join(homePath, '.local', 'bin')) directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 794394324..2552323e9 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,3 +1,4 @@ +import { homedir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -70,9 +71,9 @@ describe('patchPackagedProcessPath', () => { // fallback install locations for the opencode and Pi CLI install scripts. // Without them on PATH, GUI-launched Orca reports both as "Not installed" // even when `which` resolves them in the user's shell. - expect(segments).toContain('/Users/tester/.opencode/bin') - expect(segments).toContain('/Users/tester/.vite-plus/bin') - expect(segments).toContain('/Users/tester/bin') + expect(segments).toContain(join('/Users/tester', '.opencode/bin')) + expect(segments).toContain(join('/Users/tester', '.vite-plus/bin')) + expect(segments).toContain(join('/Users/tester', 'bin')) }) it('leaves PATH untouched when the app is not packaged', async () => { @@ -88,6 +89,23 @@ describe('patchPackagedProcessPath', () => { expect(process.env.PATH).toBe('/usr/bin:/bin') }) + + it('prepends Windows user-local CLI dirs for packaged Start Menu launches', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('win32') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + const pathDelimiter = process.platform === 'win32' ? ';' : ':' + process.env.PATH = `C:\\Windows\\System32${pathDelimiter}C:\\Windows` + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(pathDelimiter) + const userLocalBin = join(homedir(), '.local', 'bin') + expect(segments).toContain(userLocalBin) + expect(segments.indexOf(userLocalBin)).toBeLessThan(segments.indexOf('C:\\Windows\\System32')) + }) }) describe('configureDevUserDataPath', () => { diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 66d842beb..7a23599b3 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -5,6 +5,10 @@ import { getMainE2EConfig } from '../e2e-config' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 +function getProcessPathDelimiter(): string { + return process.platform === 'win32' ? ';' : ':' +} + function requestDevParentShutdown(): void { app.quit() @@ -36,35 +40,39 @@ export function installUncaughtPipeErrorGuard(): void { } export function patchPackagedProcessPath(): void { - if (!app.isPackaged || process.platform === 'win32') { + if (!app.isPackaged) { return } const home = process.env.HOME ?? '' - const extraPaths = [ - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/bin', - '/usr/local/sbin', - '/snap/bin', - '/home/linuxbrew/.linuxbrew/bin', - '/nix/var/nix/profiles/default/bin' - ] + const extraPaths: string[] = [] - if (home) { + if (process.platform !== 'win32') { extraPaths.push( - join(home, 'bin'), - join(home, '.local/bin'), - join(home, '.nix-profile/bin'), - // Why: several agent CLIs ship install scripts that drop binaries into - // tool-specific ~/./bin directories (opencode's documented fallback, - // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH - // without shell rc files, so these stay invisible to `which` probes — and - // the Agents settings page reports them as "Not installed" even when the - // user can run them from Terminal. See stablyai/orca#829. - join(home, '.opencode/bin'), - join(home, '.vite-plus/bin') + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + '/usr/local/sbin', + '/snap/bin', + '/home/linuxbrew/.linuxbrew/bin', + '/nix/var/nix/profiles/default/bin' ) + + if (home) { + extraPaths.push( + join(home, 'bin'), + join(home, '.local/bin'), + join(home, '.nix-profile/bin'), + // Why: several agent CLIs ship install scripts that drop binaries into + // tool-specific ~/./bin directories (opencode's documented fallback, + // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH + // without shell rc files, so these stay invisible to `which` probes — and + // the Agents settings page reports them as "Not installed" even when the + // user can run them from Terminal. See stablyai/orca#829. + join(home, '.opencode/bin'), + join(home, '.vite-plus/bin') + ) + } } // Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm, @@ -72,14 +80,20 @@ export function patchPackagedProcessPath(): void { // resolveCodexCommand() can locate the codex binary in these directories, but // spawning it still fails if node itself isn't in PATH. Adding version manager // bin paths here fixes all spawn sites (login, rate limits, usage tracking). + // On Windows this also seeds user-local installer dirs, since shell hydration + // is POSIX-only and Start Menu launches can miss user-level PATH updates. extraPaths.push(...getVersionManagerBinPaths()) - const currentPath = process.env.PATH ?? '' - const existing = new Set(currentPath.split(':')) + const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' + const currentPath = process.env[pathKey] ?? '' + const pathDelimiter = getProcessPathDelimiter() + const existing = new Set(currentPath.split(pathDelimiter)) const missing = extraPaths.filter((path) => !existing.has(path)) if (missing.length > 0) { - process.env.PATH = [...missing, ...currentPath.split(':').filter(Boolean)].join(':') + process.env[pathKey] = [...missing, ...currentPath.split(pathDelimiter).filter(Boolean)].join( + pathDelimiter + ) } }