fix(windows): use absolute path with shell:false for Claude spawn (#143)

On Windows, spawning Claude with shell: true causes the process to
exit immediately with code 1. This happens because shell: true invokes
cmd.exe /c claude <args>, and cmd.exe's PATH resolution differs from
direct process creation, leading to environment inconsistencies.

This fix:
- Adds findWindowsClaudePath() to locate claude.exe absolute path
- Changes spawn to use absolute path with shell: false on Windows
- Maintains Unix behavior (command name works fine with shell: false)
- Adds HAPI_CLAUDE_PATH env var for user override

Tested on Windows 11 with Claude Code v2.1.29 and hapi v0.15.0.

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
tw23
2026-02-03 13:28:14 +08:00
committed by GitHub
co-authored by HAPI
parent 2f6bbcd400
commit 798317cd05
3 changed files with 73 additions and 24 deletions
+7 -2
View File
@@ -9,6 +9,7 @@ import { withBunRuntimeEnv } from "@/utils/bunRuntime";
import { spawnWithAbort } from "@/utils/spawnWithAbort";
import { getHapiBlobsDir } from "@/constants/uploadPaths";
import { stripNewlinesForWindowsShellArg } from "@/utils/shellEscape";
import { getDefaultClaudeCodePath } from "./sdk/utils";
export async function claudeLocal(opts: {
abort: AbortSignal,
@@ -84,11 +85,15 @@ export async function claudeLocal(opts: {
logger.debug(`[ClaudeLocal] Spawning claude with args: ${JSON.stringify(args)}`);
// Get Claude executable path (absolute path on Windows for shell: false)
const claudeCommand = getDefaultClaudeCodePath();
logger.debug(`[ClaudeLocal] Using claude executable: ${claudeCommand}`);
// Spawn the process
try {
process.stdin.pause();
await spawnWithAbort({
command: 'claude',
command: claudeCommand,
args,
cwd: opts.path,
env: withBunRuntimeEnv(env, { allowBunBeBun: false }),
@@ -98,7 +103,7 @@ export async function claudeLocal(opts: {
installHint: 'Claude CLI',
includeCause: true,
logExit: true,
shell: process.platform === 'win32'
shell: false // Use absolute path, no shell needed
});
} finally {
cleanupMcpConfig?.();
+3 -2
View File
@@ -347,8 +347,9 @@ export function query(config: {
stdio: ['pipe', 'pipe', 'pipe'],
signal: config.options?.abort,
env: spawnEnv,
// Use shell on Windows for command resolution
shell: process.platform === 'win32'
// Use shell: false with absolute path from getDefaultClaudeCodePath()
// This avoids cmd.exe resolution issues on Windows
shell: false
}) as ChildProcessWithoutNullStreams
// Handle stdin
+63 -20
View File
@@ -8,19 +8,64 @@ import { execSync } from 'node:child_process'
import { homedir } from 'node:os'
import { logger } from '@/ui/logger'
/**
* Find Claude executable path on Windows.
* Returns absolute path to claude.exe for use with shell: false
*/
function findWindowsClaudePath(): string | null {
const homeDir = homedir()
const path = require('node:path')
// Known installation paths for Claude on Windows
const candidates = [
path.join(homeDir, '.local', 'bin', 'claude.exe'),
path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'),
path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'WinGet', 'Packages', 'Anthropic.claude-code_Microsoft.Winget.Source_8wekyb3d8bbwe', 'claude.exe'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
logger.debug(`[Claude SDK] Found Windows claude.exe at: ${candidate}`)
return candidate
}
}
// Try 'where claude' to find in PATH
try {
const result = execSync('where claude.exe', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
cwd: homeDir
}).trim().split('\n')[0].trim()
if (result && existsSync(result)) {
logger.debug(`[Claude SDK] Found Windows claude.exe via where: ${result}`)
return result
}
} catch {
// where didn't find it
}
return null
}
/**
* Try to find globally installed Claude CLI
* Returns 'claude' if the command works globally (preferred method for reliability)
* Falls back to which/where to get actual path on Unix systems
* On Windows: Returns absolute path to claude.exe (for shell: false)
* On Unix: Returns 'claude' if command works, or actual path via which
* Runs from home directory to avoid local cwd side effects
*/
function findGlobalClaudePath(): string | null {
const homeDir = homedir()
// PRIMARY: Check if 'claude' command works directly from home dir
// Windows: Always return absolute path for shell: false compatibility
if (process.platform === 'win32') {
return findWindowsClaudePath()
}
// Unix: Check if 'claude' command works directly from home dir
try {
execSync('claude --version', {
encoding: 'utf8',
execSync('claude --version', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
cwd: homeDir
})
@@ -31,22 +76,20 @@ function findGlobalClaudePath(): string | null {
}
// FALLBACK for Unix: try which to get actual path
if (process.platform !== 'win32') {
try {
const result = execSync('which claude', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
cwd: homeDir
}).trim()
if (result && existsSync(result)) {
logger.debug(`[Claude SDK] Found global claude path via which: ${result}`)
return result
}
} catch {
// which didn't find it
try {
const result = execSync('which claude', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
cwd: homeDir
}).trim()
if (result && existsSync(result)) {
logger.debug(`[Claude SDK] Found global claude path via which: ${result}`)
return result
}
} catch {
// which didn't find it
}
return null
}