fix(windows): use correct command separator for PowerShell terminals (#1159)
PowerShell 5.1 (Windows PowerShell) doesn't support '&&' for command chaining - it was only added in PowerShell 7+. This caused "Invoke Claude" to fail with a parse error when the user's terminal is set to PowerShell. Changes: - Add WindowsShellType to shared/types/terminal.ts (single source of truth) - Re-export WindowsShellType from main/terminal/types.ts and shell-escape.ts - Add detectShellType() in pty-manager to identify PowerShell 5.1 vs others - Update getWindowsShell() to return WindowsShellResult with shell type - Update spawnPtyProcess() to return SpawnPtyResult with shellType - Store shellType on TerminalProcess when spawning terminals - Update buildCdCommand() to use ';' for PowerShell 5.1, '&&' for others - Pass terminal's shellType when building cd commands for Claude invocation The fix is backwards compatible - shellType defaults to undefined which uses '&&' (same as cmd.exe behavior). Only powershell.exe (PS 5.1) uses ';' - pwsh.exe (PS 7+) supports '&&' so it's treated like cmd. Fixes #612 Signed-off-by: youngmrz <elliott.zach@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -568,7 +568,7 @@ export function invokeClaude(
|
||||
isDefault: activeProfile?.isDefault
|
||||
});
|
||||
|
||||
const cwdCommand = buildCdCommand(cwd);
|
||||
const cwdCommand = buildCdCommand(cwd, terminal.shellType);
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
|
||||
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
|
||||
@@ -775,7 +775,7 @@ export async function invokeClaudeAsync(
|
||||
});
|
||||
|
||||
// Async CLI invocation - non-blocking
|
||||
const cwdCommand = buildCdCommand(cwd);
|
||||
const cwdCommand = buildCdCommand(cwd, terminal.shellType);
|
||||
|
||||
// Add timeout protection for CLI detection (10s timeout)
|
||||
const cliInvocationPromise = getClaudeCliInvocationAsync();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import * as os from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
import type { TerminalProcess, WindowGetter } from './types';
|
||||
import type { TerminalProcess, WindowGetter, WindowsShellType } from './types';
|
||||
import { isWindows, getWindowsShellPaths } from '../platform';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
@@ -16,6 +16,23 @@ import type { SupportedTerminal } from '../../shared/types/settings';
|
||||
|
||||
// Windows shell paths are now imported from the platform module via getWindowsShellPaths()
|
||||
|
||||
/**
|
||||
* Result of spawning a PTY process
|
||||
*/
|
||||
export interface SpawnPtyResult {
|
||||
pty: pty.IPty;
|
||||
/** Shell type for Windows (affects command chaining syntax) */
|
||||
shellType?: WindowsShellType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of Windows shell detection
|
||||
*/
|
||||
interface WindowsShellResult {
|
||||
shell: string;
|
||||
shellType: WindowsShellType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track pending exit promises for terminals being destroyed.
|
||||
* Used to wait for PTY process exit on Windows where termination is async.
|
||||
@@ -53,13 +70,31 @@ export function waitForPtyExit(terminalId: string, timeoutMs?: number): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine shell type from shell path.
|
||||
* Only PowerShell 5.1 (powershell.exe) needs special handling with ';' separator.
|
||||
* PowerShell 7+ (pwsh.exe) supports '&&' like cmd.exe.
|
||||
*/
|
||||
function detectShellType(shellPath: string): WindowsShellType {
|
||||
// Extract just the filename from the path
|
||||
const filename = shellPath.split(/[/\\]/).pop()?.toLowerCase() || '';
|
||||
// Only powershell.exe (PS 5.1) needs ';' separator
|
||||
// pwsh.exe (PS 7+) supports '&&' so we treat it like cmd
|
||||
if (filename === 'powershell.exe') {
|
||||
return 'powershell';
|
||||
}
|
||||
// Everything else (cmd, pwsh, bash, etc.) uses && syntax
|
||||
return 'cmd';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Windows shell executable based on preferred terminal setting
|
||||
*/
|
||||
function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): string {
|
||||
function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): WindowsShellResult {
|
||||
// If no preference or 'system', use COMSPEC (usually cmd.exe)
|
||||
if (!preferredTerminal || preferredTerminal === 'system') {
|
||||
return process.env.COMSPEC || 'cmd.exe';
|
||||
const shell = process.env.COMSPEC || 'cmd.exe';
|
||||
return { shell, shellType: detectShellType(shell) };
|
||||
}
|
||||
|
||||
// Check if we have paths defined for this terminal type (from platform module)
|
||||
@@ -69,13 +104,14 @@ function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): stri
|
||||
// Find the first existing shell
|
||||
for (const shellPath of paths) {
|
||||
if (existsSync(shellPath)) {
|
||||
return shellPath;
|
||||
return { shell: shellPath, shellType: detectShellType(shellPath) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to COMSPEC for unrecognized terminals
|
||||
return process.env.COMSPEC || 'cmd.exe';
|
||||
const shell = process.env.COMSPEC || 'cmd.exe';
|
||||
return { shell, shellType: detectShellType(shell) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,18 +122,26 @@ export function spawnPtyProcess(
|
||||
cols: number,
|
||||
rows: number,
|
||||
profileEnv?: Record<string, string>
|
||||
): pty.IPty {
|
||||
): SpawnPtyResult {
|
||||
// Read user's preferred terminal setting
|
||||
const settings = readSettingsFile();
|
||||
const preferredTerminal = settings?.preferredTerminal as SupportedTerminal | undefined;
|
||||
|
||||
const shell = isWindows()
|
||||
? getWindowsShell(preferredTerminal)
|
||||
: process.env.SHELL || '/bin/zsh';
|
||||
let shell: string;
|
||||
let shellType: WindowsShellType | undefined;
|
||||
|
||||
if (isWindows()) {
|
||||
const windowsShell = getWindowsShell(preferredTerminal);
|
||||
shell = windowsShell.shell;
|
||||
shellType = windowsShell.shellType;
|
||||
} else {
|
||||
shell = process.env.SHELL || '/bin/zsh';
|
||||
shellType = undefined; // Not applicable on Unix
|
||||
}
|
||||
|
||||
const shellArgs = isWindows() ? [] : ['-l'];
|
||||
|
||||
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ')');
|
||||
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ', shellType:', shellType, ')');
|
||||
|
||||
// Create a clean environment without DEBUG to prevent Claude Code from
|
||||
// enabling debug mode when the Electron app is run in development mode.
|
||||
@@ -107,7 +151,7 @@ export function spawnPtyProcess(
|
||||
// show "Claude API" instead of "Claude Max" when ANTHROPIC_API_KEY is set.
|
||||
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
|
||||
|
||||
return pty.spawn(shell, shellArgs, {
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
@@ -119,6 +163,8 @@ export function spawnPtyProcess(
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
});
|
||||
|
||||
return { pty: ptyProcess, shellType };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,14 +67,14 @@ export async function createTerminal(
|
||||
effectiveCwd = projectPath || os.homedir();
|
||||
}
|
||||
|
||||
const ptyProcess = PtyManager.spawnPtyProcess(
|
||||
const { pty: ptyProcess, shellType } = PtyManager.spawnPtyProcess(
|
||||
effectiveCwd || os.homedir(),
|
||||
cols,
|
||||
rows,
|
||||
profileEnv
|
||||
);
|
||||
|
||||
debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid);
|
||||
debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid, 'shellType:', shellType);
|
||||
|
||||
const terminalCwd = effectiveCwd || os.homedir();
|
||||
const terminal: TerminalProcess = {
|
||||
@@ -84,7 +84,8 @@ export async function createTerminal(
|
||||
projectPath,
|
||||
cwd: terminalCwd,
|
||||
outputBuffer: '',
|
||||
title: `Terminal ${terminals.size + 1}`
|
||||
title: `Terminal ${terminals.size + 1}`,
|
||||
shellType
|
||||
};
|
||||
|
||||
terminals.set(id, terminal);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type * as pty from '@lydell/node-pty';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { TerminalWorktreeConfig } from '../../shared/types';
|
||||
import type { TerminalWorktreeConfig, WindowsShellType } from '../../shared/types';
|
||||
|
||||
// Re-export WindowsShellType for backwards compatibility
|
||||
export type { WindowsShellType } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Terminal process tracking
|
||||
@@ -21,6 +24,8 @@ export interface TerminalProcess {
|
||||
pendingClaudeResume?: boolean;
|
||||
/** Whether Claude was invoked with --dangerously-skip-permissions (YOLO mode) */
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/** Shell type for Windows (affects command chaining syntax) */
|
||||
shellType?: WindowsShellType;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
* Terminal-related types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shell type for Windows terminals.
|
||||
* Used to determine correct command chaining syntax:
|
||||
* - 'powershell': Uses ';' (PowerShell 5.1 doesn't support '&&')
|
||||
* - 'cmd': Uses '&&' (cmd.exe, PowerShell 7+, bash, etc.)
|
||||
*/
|
||||
export type WindowsShellType = 'powershell' | 'cmd';
|
||||
|
||||
export interface TerminalCreateOptions {
|
||||
id: string;
|
||||
cwd?: string;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
|
||||
import { isWindows } from '../platform';
|
||||
import type { WindowsShellType } from '../types/terminal';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { WindowsShellType };
|
||||
|
||||
/**
|
||||
* Escape a string for safe use as a shell argument.
|
||||
@@ -48,9 +52,11 @@ export function escapeShellPath(path: string): string {
|
||||
* and uses escapeForWindowsDoubleQuote for proper escaping inside double quotes.
|
||||
*
|
||||
* @param path - The directory path
|
||||
* @returns A safe "cd '<path>' && " string, or empty string if path is undefined
|
||||
* @param shellType - On Windows, specify 'powershell' or 'cmd' for correct command chaining.
|
||||
* PowerShell 5.1 doesn't support '&&', so ';' is used instead.
|
||||
* @returns A safe "cd '<path>' && " or "cd '<path>'; " string, or empty string if path is undefined
|
||||
*/
|
||||
export function buildCdCommand(path: string | undefined): string {
|
||||
export function buildCdCommand(path: string | undefined, shellType?: WindowsShellType): string {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
@@ -61,7 +67,10 @@ export function buildCdCommand(path: string | undefined): string {
|
||||
// For values inside double quotes, use escapeForWindowsDoubleQuote() because
|
||||
// caret is literal inside double quotes in cmd.exe (only double quotes need escaping).
|
||||
const escaped = escapeForWindowsDoubleQuote(path);
|
||||
return `cd /d "${escaped}" && `;
|
||||
// PowerShell 5.1 doesn't support '&&' - use ';' instead
|
||||
// cmd.exe uses '&&' for conditional execution
|
||||
const separator = shellType === 'powershell' ? '; ' : ' && ';
|
||||
return `cd /d "${escaped}"${separator}`;
|
||||
}
|
||||
|
||||
return `cd ${escapeShellPath(path)} && `;
|
||||
|
||||
Reference in New Issue
Block a user