fix(windows): use SDK bundled Claude CLI for Windows packaged apps (#1382)

On Windows, the system-installed Claude CLI is `claude.cmd`, a batch script
that cannot be executed by anyio.open_process() / asyncio.create_subprocess_exec().
This caused the packaged app to fail when invoking Claude agents.

Changes:
- Stop excluding claude_agent_sdk/_bundled from packaged app
  (SDK's bundled claude.exe works with subprocess APIs)
- Add getClaudeCliPathForSdk() that returns null for .cmd files on Windows
  (SDK will use its bundled CLI when cli_path is null)
- Add CLAUDE_CLI_PATH to SDK_ENV_VARS passthrough list
- Add bundled Python paths to validation allowlist
- Update agent-process.ts to use getClaudeCliPathForSdk() for Claude CLI detection
- Update changelog formatter/version-suggester to use shell=True for .cmd files
- Update tests to mock new getClaudeCliPathForSdk function

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-20 22:44:21 +01:00
committed by GitHub
parent c7bc01d575
commit cd4e2d38d8
8 changed files with 158 additions and 14 deletions
+2
View File
@@ -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",
]
+4 -1
View File
@@ -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
@@ -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' };
}
+17 -1
View File
@@ -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,11 +145,24 @@ export class AgentProcessManager {
const envVarName = CLI_TOOL_ENV_MAP[toolName];
if (!process.env[envVarName]) {
try {
// 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));
}
+10 -1
View File
@@ -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:
@@ -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:
@@ -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<string | null> {
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<string> {
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<string | null> {
return cliToolManager.getClaudeCliPathForSdkAsync();
}
/**
* Pre-warm the CLI tool cache asynchronously
*
@@ -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,
];
/**