diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index 51fa9df3..fc85a289 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -58,6 +58,8 @@ SDK_ENV_VARS = [ "API_TIMEOUT_MS", # Windows-specific: Git Bash path for Claude Code CLI "CLAUDE_CODE_GIT_BASH_PATH", + # Claude CLI path override (allows frontend to pass detected CLI path to SDK) + "CLAUDE_CLI_PATH", ] diff --git a/apps/frontend/scripts/download-python.cjs b/apps/frontend/scripts/download-python.cjs index c2e00c84..724bf246 100644 --- a/apps/frontend/scripts/download-python.cjs +++ b/apps/frontend/scripts/download-python.cjs @@ -105,7 +105,10 @@ const STRIP_PATTERNS = { // Format: 'package_name/subpath' - removes the entire subpath packagePaths: [ 'googleapiclient/discovery_cache/documents', // Cached Google API discovery docs (92MB!) - 'claude_agent_sdk/_bundled', // Bundled Claude CLI (224MB!) - users have it installed separately + // NOTE: claude_agent_sdk/_bundled is NO LONGER excluded. + // On Windows, the system-installed Claude CLI is claude.cmd (a batch script), + // which cannot be executed by anyio.open_process() / asyncio.create_subprocess_exec(). + // The SDK's bundled claude.exe is a proper executable and works correctly. ], // Packages that should NEVER be bundled (too large, specialized) // If these appear in dependencies, warn and skip diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index de10c034..ea6ce5b5 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -123,6 +123,8 @@ vi.mock('../cli-tool-manager', () => ({ } return { found: false, path: undefined, source: 'user-config', message: `${tool} not found` }; }), + // getClaudeCliPathForSdk returns null by default (simulates not found or .cmd file on Windows) + getClaudeCliPathForSdk: vi.fn(() => null), deriveGitBashPath: vi.fn(() => null), clearCache: vi.fn() })); @@ -159,7 +161,7 @@ import { AgentEvents } from './agent-events'; import * as profileService from '../services/profile'; import * as rateLimitDetector from '../rate-limit-detector'; import { pythonEnvManager } from '../python-env-manager'; -import { getToolInfo } from '../cli-tool-manager'; +import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager'; describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { let processManager: AgentProcessManager; @@ -754,11 +756,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { }); it('should set GITHUB_CLI_PATH with same precedence as CLAUDE_CLI_PATH', async () => { - // Mock both Claude CLI and gh CLI as found + // Mock Claude CLI via getClaudeCliPathForSdk (returns path directly, not .cmd file) + vi.mocked(getClaudeCliPathForSdk).mockReturnValue('/opt/homebrew/bin/claude'); + + // Mock gh CLI via getToolInfo (gh still uses standard detection) vi.mocked(getToolInfo).mockImplementation((tool: string) => { - if (tool === 'claude') { - return { found: true, path: '/opt/homebrew/bin/claude', source: 'homebrew', message: 'Claude CLI found via Homebrew' }; - } if (tool === 'gh') { return { found: true, path: '/opt/homebrew/bin/gh', source: 'homebrew', message: 'gh CLI found via Homebrew' }; } diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 26e7337d..76f0a4b4 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -23,7 +23,7 @@ import { readSettingsFile } from '../settings-utils'; import type { AppSettings } from '../../shared/types/settings'; import { getOAuthModeClearVars } from './env-utils'; import { getAugmentedEnv } from '../env-utils'; -import { getToolInfo } from '../cli-tool-manager'; +import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager'; import { killProcessGracefully } from '../platform'; /** @@ -134,6 +134,9 @@ export class AgentProcessManager { * Common issue: CLI tools installed via Homebrew or other non-standard locations * are not in subprocess PATH when app launches from Finder/Dock. * + * For 'claude' tool specifically, uses getClaudeCliPathForSdk() which returns null + * for Windows .cmd files, allowing the SDK to use its bundled claude.exe instead. + * * @param toolName - Name of the CLI tool (e.g., 'claude', 'gh') * @returns Record with env var set if tool was detected */ @@ -142,10 +145,23 @@ export class AgentProcessManager { const envVarName = CLI_TOOL_ENV_MAP[toolName]; if (!process.env[envVarName]) { try { - const toolInfo = getToolInfo(toolName); - if (toolInfo.found && toolInfo.path) { - env[envVarName] = toolInfo.path; - console.log(`[AgentProcess] Setting ${envVarName}:`, toolInfo.path, `(source: ${toolInfo.source})`); + // For 'claude' tool, use getClaudeCliPathForSdk() which returns null for Windows .cmd files + // This allows the Claude Agent SDK to use its bundled claude.exe instead + if (toolName === 'claude') { + const cliPath = getClaudeCliPathForSdk(); + if (cliPath) { + env[envVarName] = cliPath; + console.log(`[AgentProcess] Setting ${envVarName}:`, cliPath, '(source: cli-tool-manager)'); + } else { + console.log(`[AgentProcess] Claude CLI is .cmd file on Windows, not setting ${envVarName} - SDK will use bundled CLI`); + } + } else { + // For other tools, use standard detection + const toolInfo = getToolInfo(toolName); + if (toolInfo.found && toolInfo.path) { + env[envVarName] = toolInfo.path; + console.log(`[AgentProcess] Setting ${envVarName}:`, toolInfo.path, `(source: ${toolInfo.source})`); + } } } catch (error) { console.warn(`[AgentProcess] Failed to detect ${toolName} CLI path:`, error instanceof Error ? error.message : String(error)); diff --git a/apps/frontend/src/main/changelog/formatter.ts b/apps/frontend/src/main/changelog/formatter.ts index a43be3af..3272fa52 100644 --- a/apps/frontend/src/main/changelog/formatter.ts +++ b/apps/frontend/src/main/changelog/formatter.ts @@ -318,6 +318,9 @@ CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory /** * Create Python script for Claude generation + * + * On Windows, .cmd/.bat files require shell=True in subprocess.run() because + * they are batch scripts that need cmd.exe to execute, not direct executables. */ export function createGenerationScript(prompt: string, claudePath: string): string { // Convert prompt to base64 to avoid any string escaping issues in Python @@ -326,6 +329,10 @@ export function createGenerationScript(prompt: string, claudePath: string): stri // Escape the claude path for Python string const escapedClaudePath = claudePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + // Detect if this is a Windows batch file (.cmd or .bat) + // These require shell=True in subprocess.run() because they need cmd.exe to execute + const isCmdFile = /\.(cmd|bat)$/i.test(claudePath); + return ` import subprocess import sys @@ -337,12 +344,14 @@ try: # Use Claude Code CLI to generate # stdin=DEVNULL prevents hanging when claude checks for interactive input + # shell=${isCmdFile ? 'True' : 'False'} - Windows .cmd files require shell execution result = subprocess.run( ['${escapedClaudePath}', '-p', prompt, '--output-format', 'text', '--model', 'haiku'], capture_output=True, text=True, stdin=subprocess.DEVNULL, - timeout=300 + timeout=300, + shell=${isCmdFile ? 'True' : 'False'} ) if result.returncode == 0: diff --git a/apps/frontend/src/main/changelog/version-suggester.ts b/apps/frontend/src/main/changelog/version-suggester.ts index 6d4a9b91..e195f9c7 100644 --- a/apps/frontend/src/main/changelog/version-suggester.ts +++ b/apps/frontend/src/main/changelog/version-suggester.ts @@ -126,6 +126,9 @@ Respond with ONLY a JSON object in this exact format (no markdown, no extra text /** * Create Python script to run Claude analysis + * + * On Windows, .cmd/.bat files require shell=True in subprocess.run() because + * they are batch scripts that need cmd.exe to execute, not direct executables. */ private createAnalysisScript(prompt: string): string { // Escape the prompt for Python string literal @@ -134,6 +137,15 @@ Respond with ONLY a JSON object in this exact format (no markdown, no extra text .replace(/"/g, '\\"') .replace(/\n/g, '\\n'); + // Escape the claude path for Python string (handle Windows backslashes) + const escapedClaudePath = this.claudePath + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + + // Detect if this is a Windows batch file (.cmd or .bat) + // These require shell=True in subprocess.run() because they need cmd.exe to execute + const isCmdFile = /\.(cmd|bat)$/i.test(this.claudePath); + return ` import subprocess import sys @@ -142,11 +154,13 @@ import sys prompt = "${escapedPrompt}" try: + # shell=${isCmdFile ? 'True' : 'False'} - Windows .cmd files require shell execution result = subprocess.run( - ["${this.claudePath}", "chat", "--model", "haiku", "--prompt", prompt], + ["${escapedClaudePath}", "chat", "--model", "haiku", "--prompt", prompt], capture_output=True, text=True, - check=True + check=True, + shell=${isCmdFile ? 'True' : 'False'} ) print(result.stdout) except subprocess.CalledProcessError as e: diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index fb5c1d0c..d1bb0f85 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -330,6 +330,30 @@ class CLIToolManager { return tool; } + /** + * Get Claude CLI path for SDK usage + * + * Returns null when a .cmd file is detected on Windows, so the SDK + * can use its bundled claude.exe instead. The SDK's bundled CLI is + * a proper Windows executable that can be spawned by anyio.open_process(). + * + * @returns Claude CLI path, or null if SDK should use bundled CLI + */ + getClaudeCliPathForSdk(): string | null { + const claudePath = this.getToolPath('claude'); + + // On Windows, .cmd files cannot be executed by anyio.open_process() / asyncio.create_subprocess_exec(). + // Return null so the Claude Agent SDK uses its bundled claude.exe instead. + if (isWindows() && claudePath.toLowerCase().endsWith('.cmd')) { + console.warn( + `[CLI Tools] Claude CLI is .cmd file, returning null so SDK uses bundled CLI: ${claudePath}` + ); + return null; + } + + return claudePath; + } + /** * Detect the path for a specific CLI tool * @@ -1032,6 +1056,29 @@ class CLIToolManager { return tool; } + /** + * Get Claude CLI path for SDK usage asynchronously (non-blocking) + * + * Returns null when a .cmd file is detected on Windows, so the SDK + * can use its bundled claude.exe instead. + * + * @returns Promise resolving to Claude CLI path, or null if SDK should use bundled CLI + */ + async getClaudeCliPathForSdkAsync(): Promise { + const claudePath = await this.getToolPathAsync('claude'); + + // On Windows, .cmd files cannot be executed by anyio.open_process() / asyncio.create_subprocess_exec(). + // Return null so the Claude Agent SDK uses its bundled claude.exe instead. + if (isWindows() && claudePath.toLowerCase().endsWith('.cmd')) { + console.warn( + `[CLI Tools] Claude CLI is .cmd file, returning null so SDK uses bundled CLI: ${claudePath}` + ); + return null; + } + + return claudePath; + } + /** * Detect tool path asynchronously * @@ -1763,6 +1810,31 @@ export function getToolPath(tool: CLITool): string { return cliToolManager.getToolPath(tool); } +/** + * Get Claude CLI path for SDK usage + * + * Returns null when a .cmd file is detected on Windows, so the SDK + * can use its bundled claude.exe instead. The SDK's bundled CLI is + * a proper Windows executable that can be spawned by anyio.open_process(). + * + * Use this function when passing a CLI path to the Claude Agent SDK. + * For other uses (like spawning claude directly), use getToolPath('claude'). + * + * @returns Claude CLI path, or null if SDK should use bundled CLI + * + * @example + * ```typescript + * import { getClaudeCliPathForSdk } from './cli-tool-manager'; + * + * const cliPath = getClaudeCliPathForSdk(); + * // Pass to Python backend which uses Claude Agent SDK + * // If null, SDK will use its bundled claude.exe + * ``` + */ +export function getClaudeCliPathForSdk(): string | null { + return cliToolManager.getClaudeCliPathForSdk(); +} + /** * Configure CLI tools with user settings * @@ -1873,6 +1945,26 @@ export async function getToolPathAsync(tool: CLITool): Promise { return cliToolManager.getToolPathAsync(tool); } +/** + * Get Claude CLI path for SDK usage asynchronously (non-blocking) + * + * Returns null when a .cmd file is detected on Windows, so the SDK + * can use its bundled claude.exe instead. + * + * @returns Promise resolving to Claude CLI path, or null if SDK should use bundled CLI + * + * @example + * ```typescript + * import { getClaudeCliPathForSdkAsync } from './cli-tool-manager'; + * + * const cliPath = await getClaudeCliPathForSdkAsync(); + * // Pass to Python backend which uses Claude Agent SDK + * ``` + */ +export async function getClaudeCliPathForSdkAsync(): Promise { + return cliToolManager.getClaudeCliPathForSdkAsync(); +} + /** * Pre-warm the CLI tool cache asynchronously * diff --git a/apps/frontend/src/main/python-detector.ts b/apps/frontend/src/main/python-detector.ts index 1d993a65..eb74e5d4 100644 --- a/apps/frontend/src/main/python-detector.ts +++ b/apps/frontend/src/main/python-detector.ts @@ -291,6 +291,12 @@ const ALLOWED_PATH_PATTERNS: RegExp[] = [ /^.*\/miniconda\d*\/bin\/python\d*(\.\d+)?$/, /^.*\/anaconda\d*\/envs\/[^/]+\/bin\/python\d*(\.\d+)?$/, /^.*\/miniconda\d*\/envs\/[^/]+\/bin\/python\d*(\.\d+)?$/, + // Bundled Python in packaged Electron apps (macOS/Linux) + // Matches paths like: /path/to/app/resources/python/bin/python3 + /^.*\/resources\/python\/bin\/python\d*(\.\d+)?$/, + // Bundled Python in packaged Electron apps (Windows) + // Matches paths like: C:\path\to\app\resources\python\python.exe + /^.*\\resources\\python\\python\.exe$/i, ]; /**