From e6310a20400426cf23774d33fc86df7f8c03d9c4 Mon Sep 17 00:00:00 2001 From: Zxilly Date: Sun, 1 Mar 2026 23:34:05 +0800 Subject: [PATCH 1/2] refactor(cli): fix Windows claude.cmd resolve --- cli/src/claude/claudeLocal.ts | 14 +- cli/src/claude/claudeRemote.ts | 2 - cli/src/claude/sdk/query.ts | 32 ++--- cli/src/claude/sdk/utils.ts | 242 +++++++++++++++++++++++++++------ 4 files changed, 220 insertions(+), 70 deletions(-) diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 5e3e27cfe..b1a8613b8 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -9,7 +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"; +import { getClaudeCodeExecutable } from "./sdk/utils"; export async function claudeLocal(opts: { abort: AbortSignal, @@ -85,16 +85,16 @@ 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}`); + // Get Claude executable info (resolves .cmd to node + entry script on Windows) + const claude = getClaudeCodeExecutable(); + logger.debug(`[ClaudeLocal] Using claude executable: ${claude.command} ${claude.prependArgs.join(' ')}`); // Spawn the process try { process.stdin.pause(); await spawnWithAbort({ - command: claudeCommand, - args, + command: claude.command, + args: [...claude.prependArgs, ...args], cwd: opts.path, env: withBunRuntimeEnv(env, { allowBunBeBun: false }), signal: opts.abort, @@ -103,7 +103,7 @@ export async function claudeLocal(opts: { installHint: 'Claude CLI', includeCause: true, logExit: true, - shell: false // Use absolute path, no shell needed + shell: false }); } finally { cleanupMcpConfig?.(); diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 3d534e3b0..ae618d9fc 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -10,7 +10,6 @@ import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; import { systemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; -import { getDefaultClaudeCodePath } from "./sdk/utils"; export async function claudeRemote(opts: { @@ -123,7 +122,6 @@ export async function claudeRemote(opts: { disallowedTools: initial.mode.disallowedTools, canCallTool: (toolName: string, input: unknown, options: { signal: AbortSignal }) => opts.canCallTool(toolName, input, mode, options), abort: opts.signal, - pathToClaudeCodeExecutable: getDefaultClaudeCodePath(), settingsPath: opts.hookSettingsPath, additionalDirectories: [getHapiBlobsDir()], } diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 725cb3ba5..7d67b2cb9 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -22,7 +22,7 @@ import { type PermissionResult, AbortError } from './types' -import { getDefaultClaudeCodePath, logDebug, streamToStdin } from './utils' +import { getClaudeCodeExecutable, resolveClaudeExecutable, logDebug, streamToStdin } from './utils' import { withBunRuntimeEnv } from '@/utils/bunRuntime' import { killProcessByChildProcess } from '@/utils/process' import { stripNewlinesForWindowsShellArg } from '@/utils/shellEscape' @@ -269,7 +269,7 @@ export function query(config: { disallowedTools = [], maxTurns, mcpServers, - pathToClaudeCodeExecutable = getDefaultClaudeCodePath(), + pathToClaudeCodeExecutable, permissionMode = 'default', continue: continueConversation, resume, @@ -323,32 +323,32 @@ export function query(config: { args.push('--input-format', 'stream-json') } - // Determine how to spawn Claude Code - // - If it's just 'claude' command → spawn('claude', args) with shell on Windows - // - If it's a full path to binary or script → spawn(path, args) - const isCommandOnly = pathToClaudeCodeExecutable === 'claude' - - // Validate executable path (skip for command-only mode) - if (!isCommandOnly && !existsSync(pathToClaudeCodeExecutable)) { - throw new ReferenceError(`Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`) + // Resolve the Claude executable + // If user provided a path, resolve it (handles .cmd → node + entry script) + // Otherwise use getClaudeCodeExecutable() which searches known locations + const claude = pathToClaudeCodeExecutable + ? resolveClaudeExecutable(pathToClaudeCodeExecutable) + : getClaudeCodeExecutable() + + // Validate executable path (skip for command-only like 'claude') + const isCommandOnly = claude.command === 'claude' + if (!isCommandOnly && !existsSync(claude.command)) { + throw new ReferenceError(`Claude Code executable not found at ${claude.command}. Is options.pathToClaudeCodeExecutable set?`) } - const spawnCommand = pathToClaudeCodeExecutable - const spawnArgs = args + const spawnArgs = [...claude.prependArgs, ...args] cleanupMcpConfig = appendMcpConfigArg(spawnArgs, mcpServers) // Spawn Claude Code process const spawnEnv = withBunRuntimeEnv(process.env, { allowBunBeBun: false }) - logDebug(`Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(' ')}`) + logDebug(`Spawning Claude Code process: ${claude.command} ${spawnArgs.join(' ')}`) - const child = spawn(spawnCommand, spawnArgs, { + const child = spawn(claude.command, spawnArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'], signal: config.options?.abort, env: spawnEnv, - // Use shell: false with absolute path from getDefaultClaudeCodePath() - // This avoids cmd.exe resolution issues on Windows shell: false }) as ChildProcessWithoutNullStreams diff --git a/cli/src/claude/sdk/utils.ts b/cli/src/claude/sdk/utils.ts index e3518d1b0..e52aef829 100644 --- a/cli/src/claude/sdk/utils.ts +++ b/cli/src/claude/sdk/utils.ts @@ -3,43 +3,136 @@ * Provides helper functions for path resolution and logging */ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { execSync } from 'node:child_process' import { homedir } from 'node:os' +import { dirname, join } from 'node:path' import { logger } from '@/ui/logger' /** - * Find Claude executable path on Windows. - * Returns absolute path to claude.exe for use with shell: false + * Resolved Claude executable info. + * - command: the binary to invoke (absolute path to .exe, or node executable for npm .cmd installs) + * - prependArgs: args to insert before user args (e.g. the JS entry script for npm installs) */ -function findWindowsClaudePath(): string | null { +export type ClaudeExecutable = { + command: string + prependArgs: string[] +} + +/** Shorthand for constructing a node-based ClaudeExecutable from a JS entry script. */ +function nodeExecutable(entryScript: string): ClaudeExecutable { + return { command: process.execPath, prependArgs: [entryScript] } +} + +/** + * Resolve a npm .cmd wrapper to its underlying JS entry script. + * We extract the node_modules relative path from the .cmd and resolve it + * against the .cmd file's directory. + */ +function resolveNpmCmdEntryScript(cmdPath: string): string | null { + try { + const content = readFileSync(cmdPath, 'utf8') + // Match the node_modules entry script path regardless of how the prefix is expressed + const match = content.match(/(node_modules\\[^"*\n]+\.(?:mjs|cjs|js))/i) + if (match) { + const relativePath = match[1].replace(/\\/g, '/') + const cmdDir = dirname(cmdPath) + const entryScript = join(cmdDir, relativePath) + if (existsSync(entryScript)) { + logger.debug(`[Claude SDK] Resolved .cmd entry script: ${entryScript}`) + return entryScript + } + } + } catch { + // Failed to parse .cmd file + } + + // Fallback: look for the package.json bin entry directly + const cmdDir = dirname(cmdPath) + const pkgJsonPath = join(cmdDir, 'node_modules', '@anthropic-ai', 'claude-code', 'package.json') + try { + if (existsSync(pkgJsonPath)) { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + const binEntry = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.claude + if (binEntry) { + const entryScript = join(cmdDir, 'node_modules', '@anthropic-ai', 'claude-code', binEntry) + if (existsSync(entryScript)) { + logger.debug(`[Claude SDK] Resolved entry script from package.json: ${entryScript}`) + return entryScript + } + } + } + } catch { + // Failed to read package.json + } + + return null +} + +/** + * Find Claude executable on Windows. + * Returns ClaudeExecutable with absolute paths, no shell needed. + * + * Search order: + * 1. Known .exe install paths (installer, winget) + * 2. npm global install directories (.cmd → resolved to node + JS entry) + * 3. 'where claude' fallback + */ +function findWindowsClaudePath(): ClaudeExecutable | 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'), + // Known installation paths for Claude on Windows (.exe from installer/winget) + const exeCandidates = [ + join(homeDir, '.local', 'bin', 'claude.exe'), + join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), + join(homeDir, 'AppData', 'Local', 'Microsoft', 'WinGet', 'Packages', 'Anthropic.claude-code_Microsoft.Winget.Source_8wekyb3d8bbwe', 'claude.exe'), ] - for (const candidate of candidates) { + for (const candidate of exeCandidates) { if (existsSync(candidate)) { logger.debug(`[Claude SDK] Found Windows claude.exe at: ${candidate}`) - return candidate + return { command: candidate, prependArgs: [] } } } - // Try 'where claude' to find in PATH + // npm global install locations (.cmd wrappers → resolve to node + JS entry) + const npmPrefixPaths = getNpmGlobalPrefixes() + for (const prefix of npmPrefixPaths) { + const cmdPath = join(prefix, 'claude.cmd') + if (existsSync(cmdPath)) { + const entryScript = resolveNpmCmdEntryScript(cmdPath) + if (entryScript) { + logger.debug(`[Claude SDK] Found npm global claude, using node + ${entryScript}`) + return nodeExecutable(entryScript) + } + // .cmd exists but entry script unresolvable — don't retry via 'where' + logger.debug(`[Claude SDK] Found claude.cmd but could not resolve entry script: ${cmdPath}`) + return null + } + } + + // Try 'where claude' to find any extension (.exe, .cmd, .bat, etc.) in PATH try { - const result = execSync('where claude.exe', { + const results = execSync('where claude', { 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 + cwd: homeDir, + timeout: 5000 + }).trim().split('\n').map(s => s.trim()).filter(Boolean) + + for (const result of results) { + if (!existsSync(result)) continue + if (result.toLowerCase().endsWith('.exe')) { + logger.debug(`[Claude SDK] Found Windows claude.exe via where: ${result}`) + return { command: result, prependArgs: [] } + } + if (result.toLowerCase().endsWith('.cmd')) { + const entryScript = resolveNpmCmdEntryScript(result) + if (entryScript) { + logger.debug(`[Claude SDK] Found claude.cmd via where, using node + ${entryScript}`) + return nodeExecutable(entryScript) + } + } } } catch { // where didn't find it @@ -49,69 +142,128 @@ function findWindowsClaudePath(): string | null { } /** - * Try to find globally installed Claude CLI - * 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 + * Get potential npm global prefix directories on Windows. */ -function findGlobalClaudePath(): string | null { +function getNpmGlobalPrefixes(): string[] { const homeDir = homedir() + const prefixes: string[] = [] + const normalize = (p: string) => p.toLowerCase().replace(/[\\/]+$/, '') - // Windows: Always return absolute path for shell: false compatibility - if (process.platform === 'win32') { - return findWindowsClaudePath() + // Default npm global prefix on Windows: %APPDATA%\npm + const appData = process.env.APPDATA + if (appData) { + prefixes.push(join(appData, 'npm')) } - // Unix: Check if 'claude' command works directly from home dir + // Try to get actual npm prefix via 'npm config get prefix' try { - execSync('claude --version', { + const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], - cwd: homeDir - }) - logger.debug('[Claude SDK] Global claude command available') - return 'claude' + cwd: homeDir, + timeout: 5000 + }).trim() + if (npmPrefix && !prefixes.some(p => normalize(p) === normalize(npmPrefix))) { + prefixes.push(npmPrefix) + } } catch { - // claude command not available globally + // npm not available or timed out } - // FALLBACK for Unix: try which to get actual path + return prefixes +} + +/** + * Try to find globally installed Claude CLI. + * On Windows: Returns ClaudeExecutable with absolute path (no shell needed) + * On Unix: Returns absolute path via which, or 'claude' command as fallback + * Runs from home directory to avoid local cwd side effects + */ +function findGlobalClaudePath(): ClaudeExecutable | null { + const homeDir = homedir() + + if (process.platform === 'win32') { + return findWindowsClaudePath() + } + + // Unix: try 'which' first (fast PATH lookup, no startup overhead) try { const result = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], - cwd: homeDir + cwd: homeDir, + timeout: 5000 }).trim() if (result && existsSync(result)) { logger.debug(`[Claude SDK] Found global claude path via which: ${result}`) - return result + return { command: result, prependArgs: [] } } } catch { // which didn't find it } + // Fallback: verify 'claude' is available by running it + try { + execSync('claude --version', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: homeDir, + timeout: 5000 + }) + logger.debug('[Claude SDK] Global claude command available') + return { command: 'claude', prependArgs: [] } + } catch { + // claude command not available globally + } + return null } +// Module-level cache — the resolved executable is stable for the process lifetime +let _cachedExecutable: ClaudeExecutable | undefined + /** - * Get default path to Claude Code executable. + * Get default Claude Code executable info. + * + * Returns { command, prependArgs } so callers can spawn as: + * spawn(command, [...prependArgs, ...userArgs], { shell: false }) * * Environment variables: * - HAPI_CLAUDE_PATH: Force a specific path to claude executable */ -export function getDefaultClaudeCodePath(): string { - // Allow explicit override via env var +export function getClaudeCodeExecutable(): ClaudeExecutable { + // Env var override is always checked fresh (allows runtime changes) if (process.env.HAPI_CLAUDE_PATH) { logger.debug(`[Claude SDK] Using HAPI_CLAUDE_PATH: ${process.env.HAPI_CLAUDE_PATH}`) - return process.env.HAPI_CLAUDE_PATH + return { command: process.env.HAPI_CLAUDE_PATH, prependArgs: [] } } - // Find global claude - const globalPath = findGlobalClaudePath() - if (!globalPath) { + if (_cachedExecutable) return _cachedExecutable + + const result = findGlobalClaudePath() + if (!result) { throw new Error('Claude Code CLI not found on PATH. Install Claude Code or set HAPI_CLAUDE_PATH.') } - return globalPath + _cachedExecutable = result + return result +} + +/** + * Resolve a claude executable path string to a ClaudeExecutable. + * Handles .cmd files from npm global install by extracting the underlying + * JS entry script and using node directly (avoiding shell: true). + */ +export function resolveClaudeExecutable(executablePath: string): ClaudeExecutable { + if (process.platform === 'win32') { + const lower = executablePath.toLowerCase() + if (lower.endsWith('.cmd') || lower.endsWith('.bat')) { + const entryScript = resolveNpmCmdEntryScript(executablePath) + if (entryScript) { + return nodeExecutable(entryScript) + } + } + } + return { command: executablePath, prependArgs: [] } } /** From 1670cb02ec162e492024606c1a3920ccc443e766 Mon Sep 17 00:00:00 2001 From: Zxilly Date: Mon, 2 Mar 2026 00:17:20 +0800 Subject: [PATCH 2/2] fix(cli): add windowsHide option to spawn processes for better compatibility on Windows --- cli/src/agent/backends/acp/AcpStdioTransport.ts | 3 ++- cli/src/claude/sdk/query.ts | 3 ++- cli/src/claude/sdk/utils.ts | 3 ++- cli/src/codex/codexAppServerClient.ts | 3 ++- cli/src/modules/difftastic/index.ts | 3 ++- cli/src/modules/ripgrep/index.ts | 3 ++- cli/src/utils/spawnHappyCLI.ts | 5 ++++- cli/src/utils/spawnWithAbort.ts | 3 ++- 8 files changed, 18 insertions(+), 8 deletions(-) diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index 28673f00a..dce0fbcb9 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -57,7 +57,8 @@ export class AcpStdioTransport { this.process = spawn(options.command, options.args ?? [], { env: options.env, stdio: ['pipe', 'pipe', 'pipe'], - shell: process.platform === 'win32' + shell: process.platform === 'win32', + windowsHide: true }); this.process.stdout.setEncoding('utf8'); diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 7d67b2cb9..691b387dd 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -349,7 +349,8 @@ export function query(config: { stdio: ['pipe', 'pipe', 'pipe'], signal: config.options?.abort, env: spawnEnv, - shell: false + shell: false, + windowsHide: true }) as ChildProcessWithoutNullStreams // Handle stdin diff --git a/cli/src/claude/sdk/utils.ts b/cli/src/claude/sdk/utils.ts index e52aef829..0ffb0d2db 100644 --- a/cli/src/claude/sdk/utils.ts +++ b/cli/src/claude/sdk/utils.ts @@ -21,7 +21,8 @@ export type ClaudeExecutable = { /** Shorthand for constructing a node-based ClaudeExecutable from a JS entry script. */ function nodeExecutable(entryScript: string): ClaudeExecutable { - return { command: process.execPath, prependArgs: [entryScript] } + const nodeCommand = process.env.NODE ?? 'node' + return { command: nodeCommand, prependArgs: [entryScript] } } /** diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index b45b4976b..572546806 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -80,7 +80,8 @@ export class CodexAppServerClient { return acc; }, {} as Record), stdio: ['pipe', 'pipe', 'pipe'], - shell: process.platform === 'win32' + shell: process.platform === 'win32', + windowsHide: true }); this.process.stdout.setEncoding('utf8'); diff --git a/cli/src/modules/difftastic/index.ts b/cli/src/modules/difftastic/index.ts index 2cadcc7bd..833110c88 100644 --- a/cli/src/modules/difftastic/index.ts +++ b/cli/src/modules/difftastic/index.ts @@ -43,7 +43,8 @@ export function run(args: string[], options?: DifftasticOptions): Promise