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:
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user