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 <[email protected]>
This commit is contained in:
@@ -58,6 +58,8 @@ SDK_ENV_VARS = [
|
|||||||
"API_TIMEOUT_MS",
|
"API_TIMEOUT_MS",
|
||||||
# Windows-specific: Git Bash path for Claude Code CLI
|
# Windows-specific: Git Bash path for Claude Code CLI
|
||||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
"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
|
// Format: 'package_name/subpath' - removes the entire subpath
|
||||||
packagePaths: [
|
packagePaths: [
|
||||||
'googleapiclient/discovery_cache/documents', // Cached Google API discovery docs (92MB!)
|
'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)
|
// Packages that should NEVER be bundled (too large, specialized)
|
||||||
// If these appear in dependencies, warn and skip
|
// 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` };
|
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),
|
deriveGitBashPath: vi.fn(() => null),
|
||||||
clearCache: vi.fn()
|
clearCache: vi.fn()
|
||||||
}));
|
}));
|
||||||
@@ -159,7 +161,7 @@ import { AgentEvents } from './agent-events';
|
|||||||
import * as profileService from '../services/profile';
|
import * as profileService from '../services/profile';
|
||||||
import * as rateLimitDetector from '../rate-limit-detector';
|
import * as rateLimitDetector from '../rate-limit-detector';
|
||||||
import { pythonEnvManager } from '../python-env-manager';
|
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)', () => {
|
describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||||
let processManager: AgentProcessManager;
|
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 () => {
|
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) => {
|
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') {
|
if (tool === 'gh') {
|
||||||
return { found: true, path: '/opt/homebrew/bin/gh', source: 'homebrew', message: 'gh CLI found via Homebrew' };
|
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 type { AppSettings } from '../../shared/types/settings';
|
||||||
import { getOAuthModeClearVars } from './env-utils';
|
import { getOAuthModeClearVars } from './env-utils';
|
||||||
import { getAugmentedEnv } 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';
|
import { killProcessGracefully } from '../platform';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +134,9 @@ export class AgentProcessManager {
|
|||||||
* Common issue: CLI tools installed via Homebrew or other non-standard locations
|
* Common issue: CLI tools installed via Homebrew or other non-standard locations
|
||||||
* are not in subprocess PATH when app launches from Finder/Dock.
|
* 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')
|
* @param toolName - Name of the CLI tool (e.g., 'claude', 'gh')
|
||||||
* @returns Record with env var set if tool was detected
|
* @returns Record with env var set if tool was detected
|
||||||
*/
|
*/
|
||||||
@@ -142,10 +145,23 @@ export class AgentProcessManager {
|
|||||||
const envVarName = CLI_TOOL_ENV_MAP[toolName];
|
const envVarName = CLI_TOOL_ENV_MAP[toolName];
|
||||||
if (!process.env[envVarName]) {
|
if (!process.env[envVarName]) {
|
||||||
try {
|
try {
|
||||||
const toolInfo = getToolInfo(toolName);
|
// For 'claude' tool, use getClaudeCliPathForSdk() which returns null for Windows .cmd files
|
||||||
if (toolInfo.found && toolInfo.path) {
|
// This allows the Claude Agent SDK to use its bundled claude.exe instead
|
||||||
env[envVarName] = toolInfo.path;
|
if (toolName === 'claude') {
|
||||||
console.log(`[AgentProcess] Setting ${envVarName}:`, toolInfo.path, `(source: ${toolInfo.source})`);
|
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) {
|
} catch (error) {
|
||||||
console.warn(`[AgentProcess] Failed to detect ${toolName} CLI path:`, error instanceof Error ? error.message : String(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
|
* 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 {
|
export function createGenerationScript(prompt: string, claudePath: string): string {
|
||||||
// Convert prompt to base64 to avoid any string escaping issues in Python
|
// 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
|
// Escape the claude path for Python string
|
||||||
const escapedClaudePath = claudePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
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 `
|
return `
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -337,12 +344,14 @@ try:
|
|||||||
|
|
||||||
# Use Claude Code CLI to generate
|
# Use Claude Code CLI to generate
|
||||||
# stdin=DEVNULL prevents hanging when claude checks for interactive input
|
# stdin=DEVNULL prevents hanging when claude checks for interactive input
|
||||||
|
# shell=${isCmdFile ? 'True' : 'False'} - Windows .cmd files require shell execution
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['${escapedClaudePath}', '-p', prompt, '--output-format', 'text', '--model', 'haiku'],
|
['${escapedClaudePath}', '-p', prompt, '--output-format', 'text', '--model', 'haiku'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
timeout=300
|
timeout=300,
|
||||||
|
shell=${isCmdFile ? 'True' : 'False'}
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
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
|
* 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 {
|
private createAnalysisScript(prompt: string): string {
|
||||||
// Escape the prompt for Python string literal
|
// 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(/"/g, '\\"')
|
||||||
.replace(/\n/g, '\\n');
|
.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 `
|
return `
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -142,11 +154,13 @@ import sys
|
|||||||
prompt = "${escapedPrompt}"
|
prompt = "${escapedPrompt}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# shell=${isCmdFile ? 'True' : 'False'} - Windows .cmd files require shell execution
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["${this.claudePath}", "chat", "--model", "haiku", "--prompt", prompt],
|
["${escapedClaudePath}", "chat", "--model", "haiku", "--prompt", prompt],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True
|
check=True,
|
||||||
|
shell=${isCmdFile ? 'True' : 'False'}
|
||||||
)
|
)
|
||||||
print(result.stdout)
|
print(result.stdout)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -330,6 +330,30 @@ class CLIToolManager {
|
|||||||
return tool;
|
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
|
* Detect the path for a specific CLI tool
|
||||||
*
|
*
|
||||||
@@ -1032,6 +1056,29 @@ class CLIToolManager {
|
|||||||
return tool;
|
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
|
* Detect tool path asynchronously
|
||||||
*
|
*
|
||||||
@@ -1763,6 +1810,31 @@ export function getToolPath(tool: CLITool): string {
|
|||||||
return cliToolManager.getToolPath(tool);
|
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
|
* Configure CLI tools with user settings
|
||||||
*
|
*
|
||||||
@@ -1873,6 +1945,26 @@ export async function getToolPathAsync(tool: CLITool): Promise<string> {
|
|||||||
return cliToolManager.getToolPathAsync(tool);
|
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
|
* Pre-warm the CLI tool cache asynchronously
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -291,6 +291,12 @@ const ALLOWED_PATH_PATTERNS: RegExp[] = [
|
|||||||
/^.*\/miniconda\d*\/bin\/python\d*(\.\d+)?$/,
|
/^.*\/miniconda\d*\/bin\/python\d*(\.\d+)?$/,
|
||||||
/^.*\/anaconda\d*\/envs\/[^/]+\/bin\/python\d*(\.\d+)?$/,
|
/^.*\/anaconda\d*\/envs\/[^/]+\/bin\/python\d*(\.\d+)?$/,
|
||||||
/^.*\/miniconda\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